"""Tests for sermon_clean.waveform.""" from pathlib import Path import pytest from sermon_clean.waveform import ( render_ascii_waveform, find_silence_runs, find_pauses, render_silence_index, _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) def test_render_multiband_smoke(): """render_multiband_waveform: 6-sec audio, 3 bands of 2 sec each → 3 rows.""" from sermon_clean.waveform import render_multiband_waveform import tempfile, subprocess with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: path = Path(f.name) try: # 6-sec 440Hz tone subprocess.run([ "ffmpeg", "-y", "-v", "error", "-f", "lavfi", "-i", "sine=frequency=440:duration=6", str(path), ], check=True) result = render_multiband_waveform(path, width=30, band_seconds=2.0) band_lines = [line for line in result.splitlines() if "|" in line] # 6-sec audio at 2-sec bands → 3 full bands + 1 partial = 4 rows assert len(band_lines) in (3, 4), f"expected 3-4 bands, got {len(band_lines)}" # First band should be labeled 0:00 assert band_lines[0].startswith("0:00") # Second should be 0:02 assert band_lines[1].startswith("0:02") finally: path.unlink(missing_ok=True) def test_render_silence_index_empty_audio(): """A silent file produces at least one run, and render_silence_index prints a tabular format with bars.""" import tempfile, subprocess with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: path = Path(f.name) try: subprocess.run([ "ffmpeg", "-y", "-v", "error", "-f", "lavfi", "-i", "anullsrc=r=8000:cl=mono", "-t", "5", str(path), ], check=True) result = render_silence_index(path, threshold_db=-30.0, min_duration=0.3, width=20) # Should produce a header + at least one row lines = result.splitlines() assert lines[0].startswith("=== silence runs") assert "total" in lines[0] # Each row should have the duration in parentheses assert any("(5.00s)" in line for line in lines), f"no row lines: {result!r}" # The row line includes the position bar row_line = next(line for line in lines if "(5.00s)" in line) assert "█" in row_line, f"no position bar in row: {row_line!r}" finally: path.unlink(missing_ok=True) def test_render_silence_index_no_silences_message(): """When threshold is impossibly low, output is the 'no runs' message (not an empty string, not a crash).""" import tempfile, subprocess # Use a LOUD 440Hz tone, threshold of -100dB → no silences with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: path = Path(f.name) try: subprocess.run([ "ffmpeg", "-y", "-v", "error", "-f", "lavfi", "-i", "sine=frequency=440:duration=3", str(path), ], check=True) result = render_silence_index(path, threshold_db=-100.0, min_duration=0.1) assert "no silence runs" in result finally: path.unlink(missing_ok=True)