Files
benilla-octo/scripts/mixsum.py
T
SamandClaude Opus 5.5 7c17921601 comments: the generators, the tools and the reference table headers
Docstrings keep their usage and exit codes; table headers keep the
column contract and the client addresses and lose the dates, the
correction history and every path into another repository. A
generated header changes in its generator's template, so each of the
four generators still reproduces its output byte for byte, and no data
row changed in any of the seven tables.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 19:32:13 +07:00

153 lines
5.9 KiB
Python
Executable File

#!/usr/bin/env python3
"""Read a `$WOW_MIX_TAP` capture and say, in numbers, what the mix was doing when.
The tap records the final mix to a stereo float-32 WAV. This prints a windowed RMS/peak envelope
with a bar, and a `spans` report naming every silent stretch and every onset/offset edge: a hole
is a silent span with a start and an end, and a linear fade-to-zero is a straight line in dBFS, a
cut a cliff, a crossfade an overlap. `--json` for a machine, the default for a session log.
scripts/mixsum.py entry.wav # 250 ms windows, the envelope + the spans
scripts/mixsum.py entry.wav --window 0.1 # finer, for a click or a declick ramp
scripts/mixsum.py entry.wav --from 2 --to 12 # just the covered window
scripts/mixsum.py entry.wav --quiet # the spans only, no per-window rows
Pure stdlib, so it runs without installing anything. Python's `wave` module refuses format 3
(IEEE float), so the tap's 44-byte header is parsed directly; it must match
`sound::mix_tap::wav_header`.
"""
import argparse
import json
import math
import struct
import sys
from array import array
# Below this a window is silence: the tap is pre-clamp and dither-free, so true silence reads
# -inf, and -80 dBFS still counts an audible fade tail as sound.
SILENCE_DBFS = -80.0
def read_tap(path):
"""(sample_rate, interleaved float32 array). Tolerates the tap's crash-safe truncated tail."""
with open(path, "rb") as f:
head = f.read(44)
if len(head) < 44 or head[0:4] != b"RIFF" or head[8:12] != b"WAVE":
sys.exit(f"mixsum: {path} is not a RIFF/WAVE file")
fmt, channels, rate, _brate, _align, bits = struct.unpack("<HHIIHH", head[20:36])
if (fmt, channels, bits) != (3, 2, 32):
sys.exit(
f"mixsum: expected stereo float-32 (fmt 3), got fmt={fmt} ch={channels} bits={bits}"
)
if head[36:40] != b"data":
sys.exit("mixsum: no `data` chunk where the tap header puts it")
raw = f.read()
# The declared size is patched per flush and a hard kill can leave the file shorter: trust the
# bytes present, floored to whole frames.
usable = len(raw) - (len(raw) % 8)
samples = array("f")
samples.frombytes(raw[:usable])
if sys.byteorder == "big":
samples.byteswap()
return rate, samples
def db(x):
return -math.inf if x <= 1e-12 else 20.0 * math.log10(x)
def envelope(rate, samples, window_s, t_from, t_to):
"""Per-window (t, rms_dbfs, peak_dbfs) over the requested slice."""
per_win = max(1, int(rate * window_s)) * 2 # interleaved: 2 samples per frame
lo = min(len(samples), int(t_from * rate) * 2)
hi = len(samples) if t_to is None else min(len(samples), int(t_to * rate) * 2)
out = []
for start in range(lo, hi, per_win):
chunk = samples[start : min(start + per_win, hi)]
if not chunk:
break
acc = 0.0
peak = 0.0
for v in chunk:
acc += v * v
a = abs(v)
if a > peak:
peak = a
out.append((start / 2 / rate, db(math.sqrt(acc / len(chunk))), db(peak)))
return out
def spans(env, window_s, floor):
"""Contiguous runs of silence and of sound, as (kind, start, end, duration)."""
runs = []
for t, rms, _peak in env:
kind = "silence" if rms < floor else "sound"
if runs and runs[-1][0] == kind:
runs[-1][2] = t + window_s
else:
runs.append([kind, t, t + window_s])
return [(k, a, b, b - a) for k, a, b in runs]
def bar(rms_dbfs, floor=-72.0, width=40):
if rms_dbfs == -math.inf:
return ""
filled = int(round(max(0.0, (rms_dbfs - floor) / -floor) * width))
return "#" * max(0, min(width, filled))
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("wav", help="a $WOW_MIX_TAP capture")
ap.add_argument("--window", type=float, default=0.25, help="window seconds (default 0.25)")
ap.add_argument("--from", dest="t_from", type=float, default=0.0)
ap.add_argument("--to", dest="t_to", type=float, default=None)
ap.add_argument("--floor", type=float, default=SILENCE_DBFS, help="silence threshold, dBFS")
ap.add_argument("--quiet", action="store_true", help="spans only")
ap.add_argument("--json", action="store_true")
a = ap.parse_args()
rate, samples = read_tap(a.wav)
total = len(samples) / 2 / rate
env = envelope(rate, samples, a.window, a.t_from, a.t_to)
runs = spans(env, a.window, a.floor)
if a.json:
json.dump(
{
"path": a.wav,
"sample_rate": rate,
"duration_s": total,
"window_s": a.window,
"floor_dbfs": a.floor,
"windows": [
{"t": t, "rms_dbfs": None if r == -math.inf else r,
"peak_dbfs": None if p == -math.inf else p}
for t, r, p in env
],
"spans": [{"kind": k, "from": s, "to": e, "secs": d} for k, s, e, d in runs],
},
sys.stdout,
indent=2,
)
print()
return
print(f"mixsum: {a.wav} — {total:.1f}s stereo float-32 @ {rate} Hz, {a.window*1000:.0f} ms windows")
if not a.quiet:
for t, rms, peak in env:
r = " -inf" if rms == -math.inf else f"{rms:6.1f}"
p = " -inf" if peak == -math.inf else f"{peak:6.1f}"
print(f" {t:7.2f}s rms {r} peak {p} {bar(rms)}")
print(f"\nspans (silence = rms < {a.floor:.0f} dBFS):")
for kind, start, end, dur in runs:
print(f" {kind:8} {start:7.2f}s → {end:7.2f}s {dur:6.2f}s")
holes = [r for r in runs if r[0] == "silence" and r[1] > 0.0]
if holes:
longest = max(holes, key=lambda r: r[3])
print(f"\nlongest interior silence: {longest[3]:.2f}s at {longest[1]:.2f}s")
if __name__ == "__main__":
main()