AudioSumma/test_audio_processing.py
Pat Wendorf 9cf3f879d0 Improve UI, processing pipeline, and test coverage
Replace FFmpeg silence removal with dependency-free PCM processing, add in-app activity and status indicators, extract pipeline orchestration, prevent duplicate daily summaries, remove obsolete image assets, and add audio, pipeline, and Qt tests.
2026-08-13 10:44:52 -04:00

101 lines
3.8 KiB
Python

import math
import tempfile
import unittest
import wave
from array import array
from pathlib import Path
from audio_processing import remove_silence
SAMPLE_RATE = 16_000
def write_wav(
path: Path,
segments: list[tuple[float, int]],
*,
channels: int = 1,
sample_width: int = 2,
) -> None:
samples = array("h")
for duration, amplitude in segments:
samples.extend([amplitude] * round(duration * SAMPLE_RATE))
raw = samples.tobytes()
if channels > 1:
raw = b"".join(raw[i:i + sample_width] * channels
for i in range(0, len(raw), sample_width))
with wave.open(str(path), "wb") as wav:
wav.setnchannels(channels)
wav.setsampwidth(sample_width)
wav.setframerate(SAMPLE_RATE)
wav.writeframes(raw)
def wav_duration(path: Path) -> float:
with wave.open(str(path), "rb") as wav:
return wav.getnframes() / wav.getframerate()
class SilenceRemovalTests(unittest.TestCase):
def test_removes_long_silence_but_preserves_short_pauses(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = root / "meeting.wav"
output = root / "trimmed.wav"
write_wav(source, [(1.0, 4_000), (0.25, 0), (1.0, 4_000),
(2.0, 0), (1.0, 4_000)])
remove_silence(source, output, padding_seconds=0.1)
self.assertTrue(math.isclose(wav_duration(output), 3.45, abs_tol=0.08))
self.assertTrue(math.isclose(wav_duration(source), 5.25, abs_tol=0.01))
def test_in_place_removal_preserves_wav_metadata(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "in-place.wav"
write_wav(path, [(1.0, 5_000), (1.5, 0), (1.0, 5_000)])
remove_silence(path)
with wave.open(str(path), "rb") as wav:
self.assertEqual(wav.getnchannels(), 1)
self.assertEqual(wav.getsampwidth(), 2)
self.assertEqual(wav.getframerate(), SAMPLE_RATE)
self.assertLess(wav.getnframes(), round(3.5 * SAMPLE_RATE))
def test_all_silence_is_left_unchanged(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "silent.wav"
write_wav(path, [(2.0, 0)])
before = path.read_bytes()
result = remove_silence(path)
self.assertEqual(result, path)
self.assertEqual(path.read_bytes(), before)
def test_audio_without_long_silence_is_unchanged(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "speech.wav"
write_wav(path, [(1.0, 3_000), (0.2, 0), (1.0, 3_000)])
before = path.read_bytes()
remove_silence(path)
self.assertEqual(path.read_bytes(), before)
def test_rejects_unsupported_wav_shapes(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
stereo = root / "stereo.wav"
write_wav(stereo, [(1.0, 3_000)], channels=2)
with self.assertRaises(ValueError):
remove_silence(stereo)
def test_rejects_invalid_parameters(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "speech.wav"
write_wav(path, [(1.0, 3_000)])
with self.assertRaises(ValueError):
remove_silence(path, threshold_db=float("nan"))
with self.assertRaises(ValueError):
remove_silence(path, padding_seconds=-1)
with self.assertRaises(ValueError):
remove_silence(path, min_silence_seconds=-1)
if __name__ == "__main__":
unittest.main()