Add multiband waveform view

- waveform.py: render_multiband_waveform() — N rows of ASCII bars, each row
  labeled with its band-start time. Makes it easy to find timestamps in
  long audio by counting row + column.
- cli.py: new 'multiband' / 'mb' subcommand
- Fixed: multiband band alignment was drifting (off-by-one in samples)
- tests: 1 new multiband smoke test; 62 total all passing
This commit is contained in:
2026-07-27 04:25:18 -07:00
parent fa0628f0e2
commit 6b12130bbd
3 changed files with 103 additions and 1 deletions
+22
View File
@@ -164,6 +164,21 @@ def cmd_paste(args: argparse.Namespace) -> int:
return 0
def cmd_multiband(args: argparse.Namespace) -> int:
"""Print a multi-band ASCII waveform. Each row is N seconds labeled."""
from .waveform import render_multiband_waveform
audio = Path(args.audio)
if not audio.exists():
print(f"error: {audio} not found", file=sys.stderr)
return 1
print(render_multiband_waveform(
audio,
width=args.width,
band_seconds=args.band_seconds,
))
return 0
def cmd_slices(args: argparse.Namespace) -> int:
"""Extract overlapping audio slices for manual review.
@@ -324,6 +339,13 @@ def main(argv: list[str] | None = None) -> int:
p_paste.add_argument("--replacements", nargs="+", help="glob(s) for replacement MP3s in order")
p_paste.set_defaults(func=cmd_paste)
# multiband (multi-row ASCII waveform with band labels)
p_mb = sub.add_parser("multiband", aliases=["mb"], help="Multi-band ASCII waveform with band-start labels")
p_mb.add_argument("audio")
p_mb.add_argument("--width", type=int, default=60, help="waveform width in columns per band")
p_mb.add_argument("--band-seconds", type=float, default=60.0, help="seconds of audio per band (one row)")
p_mb.set_defaults(func=cmd_multiband)
# slices (extract overlapping audio chunks for manual review)
p_slices = sub.add_parser("slices", help="Extract overlapping audio slices for manual review")
p_slices.add_argument("audio")
+56 -1
View File
@@ -228,4 +228,59 @@ def find_pauses(
"""
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)
+25
View File
@@ -122,5 +122,30 @@ def test_find_pauses_returns_list():
# 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)