diff --git a/sermon_clean/cli.py b/sermon_clean/cli.py index 5a73ed6..dd627df 100644 --- a/sermon_clean/cli.py +++ b/sermon_clean/cli.py @@ -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") diff --git a/sermon_clean/waveform.py b/sermon_clean/waveform.py index 1a9f3ec..a5bd070 100644 --- a/sermon_clean/waveform.py +++ b/sermon_clean/waveform.py @@ -228,4 +228,59 @@ def find_pauses( """ return find_silence_runs( src, threshold_db=threshold_db, min_duration_seconds=min_duration_seconds - ) \ No newline at end of file + ) + +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) diff --git a/tests/test_waveform.py b/tests/test_waveform.py index a6527a2..34a3901 100644 --- a/tests/test_waveform.py +++ b/tests/test_waveform.py @@ -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) \ No newline at end of file