Files
sermon-clean/tests/test_waveform.py
T
sami7777 7db2832034 Add scan + auto subcommands
- waveform.py: fast ASCII waveform via ffmpeg PCM extract + numpy RMS
  (avoids the slow per-frame astats approach)
- transcribe.py: faster-whisper integration with substring bad-word matching
- cli.py: new 'scan' (ASCII waveform + silence marks, no whisper)
              and 'auto' (transcribe + bad-word flagging) subcommands
- numpy added to required dependencies
- 17 new tests (waveform + transcribe), 42 total all passing
2026-07-27 03:50:55 -07:00

126 lines
3.9 KiB
Python

"""Tests for sermon_clean.waveform."""
from pathlib import Path
import pytest
from sermon_clean.waveform import (
render_ascii_waveform,
find_silence_runs,
find_pauses,
_format_seconds,
_format_time_axis,
_downsample,
_per_chunk_rms,
)
class TestFormatSeconds:
@pytest.mark.parametrize("s,expected", [
(0, "0:00"),
(5, "0:05"),
(59, "0:59"),
(60, "1:00"),
(90, "1:30"),
(3599, "59:59"),
(3600, "1:00:00"),
(3661, "1:01:01"),
])
def test_format(self, s, expected):
assert _format_seconds(s) == expected
class TestFormatTimeAxis:
def test_basic(self):
result = _format_time_axis(60.0, 30)
assert len(result) == 30
# First tick at "0:00"
assert result[0] == "0"
# Should not overflow
for c in result:
assert c != "x" # no truncation artifacts
class TestDownsample:
def test_no_op_when_already_small(self):
assert _downsample([1.0, 2.0, 3.0], 5) == [1.0, 2.0, 3.0]
def test_even_buckets(self):
# 4 values into 2 buckets
result = _downsample([1.0, 2.0, 3.0, 4.0], 2)
assert result == pytest.approx([1.5, 3.5])
class TestPerChunkRms:
def test_empty(self):
import numpy as np
assert _per_chunk_rms(np.zeros(0, dtype=np.float32), 100) == []
def test_silence_is_low_db(self):
import numpy as np
samples = np.zeros(8000, dtype=np.float32)
rms = _per_chunk_rms(samples, 800)
# Very quiet silence should produce very low dB
assert rms[0] < -100.0
def test_loud_signal_is_high_db(self):
import numpy as np
# Full-scale sine wave at 100 Hz, 1 sec @ 8kHz
t = np.linspace(0, 1, 8000)
samples = 0.9 * np.sin(2 * np.pi * 100 * t).astype(np.float32)
rms = _per_chunk_rms(samples, 800)
# RMS of a sine wave = amplitude / sqrt(2) = 0.9/1.414 ≈ 0.636 → -3.9 dB
assert rms[0] > -5.0
@pytest.mark.skipif(
not Path("/root/.hermes/profiles/krystie/cache/audio").exists(),
reason="krystie audio cache not present",
)
def test_render_waveform_on_real_audio():
"""Smoke test: render a waveform on a real audio file."""
cache = Path("/root/.hermes/profiles/krystie/cache/audio")
audio = sorted(cache.glob("*.ogg"))[0]
result = render_ascii_waveform(audio, width=60)
lines = result.splitlines()
assert len(lines) >= 3
assert len(lines[0]) == 60 # the bar row
assert "total" in lines[2]
@pytest.mark.skipif(
not Path("/root/.hermes/profiles/krystie/cache/audio").exists(),
reason="krystie audio cache not present",
)
def test_find_silence_runs_on_real_audio():
"""Smoke test: silence detection on a real audio file."""
cache = Path("/root/.hermes/profiles/krystie/cache/audio")
audio = sorted(cache.glob("*.ogg"))[0]
silences = find_silence_runs(audio, threshold_db=-30.0, min_duration_seconds=0.3)
# Returns a list; could be empty for dense audio, but should be a list
assert isinstance(silences, list)
def test_find_pauses_returns_list():
"""find_pauses is just find_silence_runs with different defaults."""
import tempfile
# Create a tiny silent WAV for testing (no need for an audio file)
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
path = Path(f.name)
try:
# 5-second silence
import subprocess
subprocess.run([
"ffmpeg", "-y", "-v", "error",
"-f", "lavfi", "-i", "anullsrc=r=8000:cl=mono",
"-t", "5",
str(path),
], check=True)
pauses = find_pauses(path)
# A 5-second silent file should have at least 1 silence run
assert len(pauses) >= 1
# The first silence should start near 0 and end near 5
assert pauses[0][0] < 1.0
assert pauses[0][1] > 4.0
finally:
path.unlink(missing_ok=True)