Files
Hermes Agent e5420589f9 Add silence-index (si) subcommand + tests + README
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.
2026-07-27 12:55:16 -07:00

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)