"""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)