mirror of
https://github.com/patw/AudioSumma.git
synced 2026-10-21 04:45:01 +00:00
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.
101 lines
3.8 KiB
Python
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()
|