e5420589f9
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.
335 lines
12 KiB
Python
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)
|