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:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user