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

335 lines
12 KiB
Python

"""ASCII waveform + silence detection — fast audio exploration without whisper.
Useful when you don't have the bad-word list in advance, just want to scan
the audio visually for natural breath pauses, long silences, or weird gaps.
Renders to a plain-text ASCII waveform that fits in any terminal/chat.
Performance: extracts raw PCM via ffmpeg, then computes per-chunk RMS via numpy.
For a 27-min audio file this takes ~50s (Opus decoding is the bottleneck).
"""
from __future__ import annotations
import math
import subprocess
from pathlib import Path
from typing import List, Tuple
import numpy as np
def _extract_pcm(src: Path, *, sample_rate: int = 8000) -> np.ndarray:
"""Extract mono 16-bit PCM from the source via ffmpeg. Returns float32 in [-1, 1].
Downsamples to `sample_rate` (default 8kHz = enough for amplitude analysis)
to keep extraction fast.
"""
proc = subprocess.Popen([
"ffmpeg", "-v", "error", "-y",
"-i", str(src),
"-ar", str(sample_rate),
"-ac", "1",
"-f", "s16le",
"-",
], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
pcm, _ = proc.communicate()
if not pcm:
return np.zeros(0, dtype=np.float32)
return np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0
def _per_chunk_rms(samples: np.ndarray, chunk_size: int) -> List[float]:
"""Compute RMS in dB for each non-overlapping chunk of `chunk_size` samples."""
if len(samples) == 0:
return []
n_full = len(samples) // chunk_size
if n_full == 0:
rms = float(np.sqrt(np.mean(samples ** 2)))
return [20.0 * math.log10(rms + 1e-10)]
chunks = samples[:n_full * chunk_size].reshape(n_full, chunk_size)
rms = np.sqrt(np.mean(chunks ** 2, axis=1))
return [20.0 * math.log10(float(v) + 1e-10) for v in rms]
def _db_to_bar(db: float, max_height: int = 8) -> str:
"""Map dB in -60..0 to a Unicode block character."""
norm = max(0.0, min(1.0, (db + 60.0) / 60.0))
norm = norm ** 0.5 # emphasize quieter sounds
height = int(round(norm * max_height))
bars = " ▁▂▃▄▅▆▇█"
return bars[height]
def _downsample(values: List[float], target: int) -> List[float]:
"""Reduce `values` to `target` entries by averaging buckets."""
n = len(values)
if n <= target:
return list(values)
bucket = n / target
out: List[float] = []
for i in range(target):
start = int(i * bucket)
end = int((i + 1) * bucket)
window = values[start:end] if end > start else [values[start]]
out.append(sum(window) / len(window))
return out
def render_ascii_waveform(
src: Path,
*,
width: int = 80,
sample_rate: int = 8000,
) -> str:
"""Return an ASCII art waveform of the audio.
The waveform is downsampled to `width` columns. Y-axis is the audio
amplitude (in dB, so quieter = shorter bar).
Useful for:
- Quick visual scan of any audio file
- Finding natural breath pauses (long runs of low bars)
- Embedding in chat messages (no binary attachment needed)
"""
src = Path(src)
samples = _extract_pcm(src, sample_rate=sample_rate)
if len(samples) == 0:
return f"[no audio data found in {src}]"
duration = len(samples) / sample_rate
# Chunk size: aim for 4-8 samples per column for smooth visuals
target_chunks = max(width * 4, 80)
chunk_size = max(1, len(samples) // target_chunks)
chunk_dur = chunk_size / sample_rate
rms = _per_chunk_rms(samples, chunk_size)
if not rms:
return f"[no chunks extracted from {src}]"
rms = _downsample(rms, width)
bar_chars = "".join(_db_to_bar(v) for v in rms)
time_axis = _format_time_axis(duration, width)
return (
f"{bar_chars}\n"
f"{time_axis}\n"
f"({duration:.1f}s total, {chunk_dur*1000:.0f}ms per bar)"
)
def _format_time_axis(duration: float, width: int) -> str:
"""Render a `width`-column time axis with tick labels every ~10 cols."""
result = [" "] * width
n_ticks = max(2, width // 10)
for i in range(n_ticks + 1):
col = int(i * (width - 1) / n_ticks)
secs = duration * i / n_ticks
label = _format_seconds(secs)
# Clamp label length so it doesn't overflow the width
max_label_len = min(len(label), width - col) if i < n_ticks else min(len(label), width - col - 1)
for j in range(max_label_len):
if col + j < width:
result[col + j] = label[j]
return "".join(result)
def _format_seconds(s: float) -> str:
"""Render seconds as M:SS or H:MM:SS."""
if s < 0:
return "-" + _format_seconds(-s)
h = int(s // 3600)
m = int((s % 3600) // 60)
sec = int(s % 60)
if h:
return f"{h}:{m:02d}:{sec:02d}"
return f"{m}:{sec:02d}"
def find_silence_runs(
src: Path,
*,
threshold_db: float = -40.0,
min_duration_seconds: float = 0.5,
) -> List[Tuple[float, float]]:
"""Use ffmpeg's silencedetect filter to find silence runs.
Faster than per-chunk RMS analysis for narrow silences (down to 0.1s).
Use `min_duration_seconds=0.5` for sentence-end pauses.
"""
# silencedetect output goes to ffmpeg's info-level stderr, NOT error.
# Don't use -v error here or we silently drop all the silence events.
out = subprocess.run([
"ffmpeg", "-v", "info",
"-i", str(src),
"-af", f"silencedetect=noise={threshold_db}dB:d={min_duration_seconds}",
"-f", "null", "-",
], capture_output=True, text=True)
starts = []
ends = []
# silencedetect emits lines like:
# [silencedetect @ 0x5c07b88b7440] silence_start: 0
# [silencedetect @ 0x5c07b88b7440] silence_end: 5.248 | silence_duration: 5.248
# The filter-name prefix must be stripped before parsing.
import re
pattern = re.compile(r"silence_(start|end):\s*([0-9.]+)")
for line in out.stderr.splitlines():
for m in pattern.finditer(line):
kind = m.group(1)
val = float(m.group(2))
if kind == "start":
starts.append(val)
else:
ends.append(val)
return list(zip(starts, ends))
def render_silence_marks(
src: Path,
*,
threshold_db: float = -40.0,
min_duration_seconds: float = 0.5,
width: int = 80,
) -> str:
"""Render a row of `_` characters where silences occur, aligned to the waveform."""
silences = find_silence_runs(
src, threshold_db=threshold_db, min_duration_seconds=min_duration_seconds
)
# Get duration via the same fast pcm extract path
samples = _extract_pcm(src)
duration = len(samples) / 8000.0
if duration <= 0:
return "(empty audio)"
marks = [" "] * width
for a, b in silences:
c0 = int(a / duration * width)
c1 = max(c0 + 1, int(b / duration * width))
for c in range(c0, min(c1, width)):
marks[c] = "_"
time_axis = _format_time_axis(duration, width)
return (
f"{''.join(marks)}\n"
f"{time_axis}\n"
f"({len(silences)} silence runs >= {min_duration_seconds}s @ noise<{threshold_db}dB)"
)
def find_pauses(
src: Path,
*,
threshold_db: float = -35.0,
min_duration_seconds: float = 0.3,
) -> List[Tuple[float, float]]:
"""Find natural breath pauses — short silences between speech segments.
Returns (start, end) of each pause. Use these as candidate splice points
when you need to remove a bad segment — picking pauses that bracket the
bad word gives the cleanest possible splice.
"""
return find_silence_runs(
src, threshold_db=threshold_db, min_duration_seconds=min_duration_seconds
)
def render_multiband_waveform(
src: Path,
*,
width: int = 80,
band_seconds: float = 60.0,
sample_rate: int = 8000,
) -> str:
"""Render a multi-band ASCII waveform — N rows of width-M bars, each band
labeled with its start time. Lets you eyeball long audio in chunks and
count timestamps by row/column.
Example output (width=60, band_seconds=30):
0:00 |▇▇▆▇▇▇▆▇▇▆▇▇▇▇▇▇▇▇▇▇▆▇▇▇▇▇▇▇▇▇▇▆▇▇▇▇▇▇▇▇▇▆▇▇▇▇▇▇▇▇▇▇▇▆▇▇|
0:30 |▆▇▆▆▇▇▆▇▆▇▇▆▇▇▇▇▇▇▇▇▇▆▇▇▇▇▇▇▇▇▇▆▇▇▇▇▇▇▇▇▇▆▇▇▇▇▇▇▇▇▆▇▇|
1:00 |▇▇▇▆▇▇▇▇▇▆▇▇▇▇▇▇▇▇▇▇▆▇▇▇▇▇▇▇▇▆▇▇▇▇▇▇▇▇▇▇▆▇▇▇▇▇▇▇▇▇▇▇|
...
Each character represents (band_seconds / width) seconds of audio.
Count column position and add to the band start time to get a timestamp.
"""
src = Path(src)
samples = _extract_pcm(src, sample_rate=sample_rate)
if len(samples) == 0:
return f"[no audio data found in {src}]"
duration = len(samples) / sample_rate
# Each band = exactly band_seconds of audio. Downsample within each band
# so it fits in `width` columns. Use exact sample counts to avoid drift.
band_size_samples = int(band_seconds * sample_rate)
chunks_per_band = max(width * 2, 40)
chunk_size = max(1, band_size_samples // chunks_per_band)
out_lines = []
cursor_sample = 0
while cursor_sample < len(samples):
end_sample = min(cursor_sample + band_size_samples, len(samples))
band_samples = samples[cursor_sample:end_sample]
if len(band_samples) == 0:
break
# Compute RMS for this band
band_rms = _per_chunk_rms(band_samples, chunk_size)
band_rms = _downsample(band_rms, width)
bar_chars = "".join(_db_to_bar(v) for v in band_rms)
# Label: "MM:SS |" prefix
band_seconds_elapsed = cursor_sample / sample_rate
label = _format_seconds(band_seconds_elapsed) + " |"
out_lines.append(f"{label}{bar_chars}")
cursor_sample = end_sample
header = f"=== multiband waveform ({len(out_lines)} bands of {_format_seconds(band_seconds)} each, width={width}) ==="
out_lines.append("")
out_lines.append(header)
out_lines.append(f"each character = {band_seconds/width:.2f}s of audio")
out_lines.append(f"add (band_row * band_seconds) + (column * band_seconds/width) to find timestamp")
return "\n".join(out_lines)
def render_silence_index(
src: Path,
*,
threshold_db: float = -35.0,
min_duration: float = 0.3,
width: int = 60,
) -> str:
"""Render a tabular index of silence runs with their timestamps.
Output like:
=== silence runs (5 total) ===
0:02.31 - 0:03.45 (1.14s) ████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
0:15.02 - 0:16.10 (1.08s) ████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
...
Each row shows the silence start, end, duration, and a bar showing where
it falls in the audio (so you can eyeball the gap distribution).
Use this to pick natural splice points — pick pauses that bracket the
bad word for the cleanest cut.
"""
runs = find_silence_runs(src, threshold_db=threshold_db, min_duration_seconds=min_duration)
if not runs:
return f"(no silence runs >= {min_duration}s @ noise<{threshold_db}dB)"
# Get duration
samples = _extract_pcm(src)
duration = len(samples) / 8000.0
if duration <= 0:
return "(empty audio)"
lines = [f"=== silence runs ({len(runs)} total, >= {min_duration}s @ noise<{threshold_db}dB) ==="]
for a, b in runs:
dur = b - a
# Bar showing where this silence falls in the audio
bar = [" "] * width
c0 = int(a / duration * width)
c1 = max(c0 + 1, int(b / duration * width))
for c in range(c0, min(c1, width)):
bar[c] = ""
bar_str = "".join(bar)
# Format: "MM:SS.SS - MM:SS.SS (D.DDs) [bar]"
start_str = _format_seconds(a) + f".{int((a % 1) * 100):02d}"
end_str = _format_seconds(b) + f".{int((b % 1) * 100):02d}"
lines.append(f" {start_str} - {end_str} ({dur:.2f}s) {bar_str}")
return "\n".join(lines)