e5420589f9
The uncommitted code from the last session was a working silence-index subcommand wired through cli.py — but it never landed on main. This commit: - Adds render_silence_index() to waveform.py (was already on disk but uncommitted) — tabular view of silence runs with start/end/duration and a position bar showing where each run falls in the audio. - Wires it through cli.py as 'silence-index' (alias: 'si') with --silence-threshold and --silence-min-duration args. - Adds 2 tests: test_render_silence_index_empty_audio (silent file produces 1 run with bar) and test_render_silence_index_no_silences_message (loud sine at -100dB threshold → 'no silence runs' message). - Updates README: 'si' added to step-by-step workflow and the subcommand table, alongside the previously-uncommitted 'mb' (multiband). 64/64 tests passing in 14.6s. Use case: when picking natural splice points to bracket a bad word, you want a tabular list of silence runs you can eyeball, not a waveform. si is the right tool for that — also faster than mb on long files since it doesn't need to render bars per band.
198 lines
6.8 KiB
Python
198 lines
6.8 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,
|
|
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) |