Commit 0ae4116a by PLN (Algolia)

feat(probe): measure level OVER TIME — an aggregate cannot see a fade

PLN, after I misdiagnosed a decaying orbit as a sparse one: "do you lack tooling to
detect these over time? do you just listen to the loops once lol". Yes, and he was right.

THE MEASUREMENT BUG. probe-chain reported ONE peak/rms pair per orbit over the whole
capture window. That aggregate is mathematically incapable of showing a slope, and on
this rig the slope IS the diagnosis. d7 in vague_de_crime measured peak -22.5 / rms
-68.2 dBFS in a 14s window, and I read the 46 dB crest factor as "a sparse stab per
cycle, just buried in the mix". It was nothing of the kind: it was a pattern sounding
for ~3s and then dying, i.e. the `dN` = `xfade N` tail of the OUTGOING pattern while a
SILENT new one faded in. PLN heard it correctly from the first sentence — "I hear it on
ctrl+enter, then it fades in the back over 3s" — while my instrument said "quiet".

A sparse part and a dying part produce the SAME aggregate: low rms, one lone peak. That
ambiguity is not a judgement call to be made more carefully next time; it is missing
information, and the fix is to stop throwing the time axis away.

WHAT THIS ADDS. `probe-chain.py -t [BIN_S]` bins each capture (default 1s) into per-bin
rms and prints a sparkline plus a classification:

    DIED      loud at first, then silent for the tail -> xfade tail, the eval did NOT take
    DECAYING  >12 dB downward trend -> almost certainly an xfade tail, not a part
    RISING    fading in
    STEADY    a real, sustained part

Rendered as blocks with silence as a space, so a die-off reads as a visibly empty tail
instead of a number you have to interpret. It reuses the existing verified tap machinery,
so it inherits the contamination guard from 872676a2 — a row marked UNRELIABLE gets no
timeline, because a fabricated slope would be worse than a fabricated level.

VALIDATED TWICE. First against synthetic series, to prove the classifier can actually
separate the two cases that fooled me:
    xfade tail  [-12..-45,-inf x7]      |▇▆▆▅▄       |  -> DIED
    sparse part [-20,-55,-19,-58,...]   |▆▂▆▂▆▃▆▂▆▂▆▂|  -> (spiky, no dead tail)
    steady part [-18..-20 flat]         |▆▆▆▆▆▆▆▆▆▆▆▆|  -> STEADY
Then on the real rig, on the very orbit that caused this: d7 over 18s in 1.5s bins,
|▅▆▅▅▆▅▆▅▅▅▆▆|, STEADY -25.0 -> -23.4 dB. Flat. Confirmed by PLN's ears in the same
minute ("now its sticking i hear d7 stay").

ALSO HERE: check-mix.py no longer audits the wrong mixer. It hardcoded
"Tidal Multi/Tidal Multi.ardour" while the session in use is "Tidal Live" — so it
confidently reported five faders at -inf that were actually up, a stale file audited as
if it were live. It now resolves the session from the running Ardour process's own argv
(matching on cmdline, because the binary is ArdourGUI and `pgrep ardour` finds nothing —
a detail that already caused one wrong conclusion), falling back to Tidal Live by name.
A mixer auditor reading the wrong mixer is worse than no auditor.

Standing caveat unchanged and worth repeating: check-mix reads the session AS SAVED, so
unsaved fader moves are invisible to it. Same family as the trap found minutes later —
Pulsar evaluates the editor BUFFER, not the file on disk, so a grep of the file can
disagree with what is actually playing. Verify against the layer that is live.

Refs #26, #62, #65.
parent b4f62757
#!/usr/bin/env python3
"""check-mix — audit the Ardour "Tidal Multi" mixer for silent-by-configuration orbits.
"""check-mix — audit the live Ardour mixer for silent-by-configuration orbits.
Why this exists (2026-07-27, mid-rehearsal, J-8 to OPAL)
-------------------------------------------------------
......@@ -57,7 +57,49 @@ import sys
import xml.etree.ElementTree as ET
from pathlib import Path
SESSION = Path.home() / "Work/Sound/Ardour/Tidal Multi/Tidal Multi.ardour"
ARDOUR_DIR = Path.home() / "Work/Sound/Ardour"
# THE SESSION IS "Tidal Live". This used to be hardcoded to "Tidal Multi", which on
# 2026-07-28 made this tool confidently report five faders at -inf while the ACTUALLY
# OPEN session had them up — a stale file audited as if it were live. A mixer auditor
# reading the wrong mixer is worse than no auditor, so resolve the RUNNING session from
# the Ardour process's own argv and only fall back to a guess if that fails.
def running_session() -> Path | None:
"""The .ardour file the live Ardour process was launched with (argv), or None.
Ardour's process is `ardour-N.N.N /path/to/Foo.ardour` and the binary is named
ArdourGUI, not "ardour" — a `pgrep ardour` returns nothing, which already caused
one wrong conclusion. Match on the cmdline instead.
"""
try:
for proc in Path("/proc").iterdir():
if not proc.name.isdigit():
continue
try:
argv = (proc / "cmdline").read_bytes().split(b"\0")
except OSError:
continue
if not argv or b"ardour" not in argv[0].lower():
continue
for arg in argv[1:]:
if arg.endswith(b".ardour"):
p = Path(arg.decode(errors="replace"))
if p.is_file():
return p
except OSError:
pass
return None
def default_session() -> Path:
live = running_session()
if live:
return live
guess = ARDOUR_DIR / "Tidal Live" / "Tidal Live.ardour"
return guess
SESSION = default_session()
# Below this linear gain a track is inaudible in practice (~ -60 dB).
SILENT_GAIN = 1e-3
......
......@@ -163,6 +163,40 @@ class Tap:
except subprocess.TimeoutExpired:
self.proc.kill()
def timeline(self, bin_s: float) -> list[float]:
"""Per-bin rms dBFS across the capture — the shape of the level IN TIME.
WHY THIS EXISTS (PLN, 2026-07-28: "do you lack tooling to detect these over
time? do you just listen to the loops once lol" — yes, and he was right).
A single aggregate peak/rms per orbit CANNOT show a slope. A pattern that
sounds for 3 s and then dies looks, in a 14 s aggregate, exactly like a quiet
sparse pattern: same low rms, same lone peak. That ambiguity made me call d7
"sparse" when it was actually decaying — the `dN` = `xfade N` tail of the
OUTGOING pattern while a silent new one faded in.
Binning turns that guess into a reading: a fade is a monotonic slope, a sparse
part is spiky-but-flat, and a dead orbit is a cliff to -inf that never returns.
"""
try:
with wave.open(str(self.path), "rb") as w:
n, sw = w.getnframes(), w.getsampwidth()
ch, fr = w.getnchannels(), w.getframerate()
raw = w.readframes(n)
except Exception:
return []
if not raw or sw != 2:
return []
vals = struct.unpack(f"<{len(raw)//2}h", raw[: (len(raw) // 2) * 2])
per_bin = max(1, int(fr * ch * bin_s))
out: list[float] = []
for i in range(0, len(vals), per_bin):
chunk = vals[i : i + per_bin]
if len(chunk) < per_bin // 2: # drop a ragged final bin
break
rms = math.sqrt(sum(v * v for v in chunk) / len(chunk)) / 32768.0
out.append(-math.inf if rms <= 0 else 20 * math.log10(rms))
return out
def levels(self) -> tuple[float, float]:
"""(peak dBFS, rms dBFS); -inf for digital silence."""
try:
......@@ -187,6 +221,56 @@ def fmt(db: float) -> str:
return " -inf " if db == -math.inf else f"{db:+7.1f}"
SPARK = " ▁▂▃▄▅▆▇█"
def sparkline(series: list[float], floor: float = -80.0) -> str:
"""Render per-bin dBFS as blocks. Silence renders as a space, so a die-off is a
visibly empty tail rather than a number you have to interpret."""
out = []
for db in series:
if db == -math.inf or db <= floor:
out.append(SPARK[0])
continue
frac = (db - floor) / (0.0 - floor)
idx = max(1, min(len(SPARK) - 1, int(round(frac * (len(SPARK) - 1)))))
out.append(SPARK[idx])
return "".join(out)
def shape_of(series: list[float], bin_s: float) -> str:
"""Classify the level's behaviour over time.
The distinction that matters on this rig: DECAYING is the `dN` = `xfade N` tail of
an outgoing pattern while a SILENT new one fades in — i.e. "my eval did not take".
A sparse part is loud-then-quiet-then-loud (spiky, no trend). Judging on an
aggregate confuses the two, which is exactly what went wrong with d7.
"""
live = [d for d in series if d != -math.inf]
if not live:
return "SILENT for the whole window"
if len(series) < 4:
return "window too short to judge a trend — raise -s"
q = max(1, len(series) // 4)
head = [d for d in series[:q] if d != -math.inf]
tail = [d for d in series[-q:] if d != -math.inf]
head_avg = sum(head) / len(head) if head else -math.inf
if not tail:
return (f"DIED — audible at first ({head_avg:+.1f} dB), then SILENT for the last "
f"{q * bin_s:g}s. Classic xfade tail: the pattern you hear is the OLD one "
f"dying; the new one is silent (eval did not take)")
tail_avg = sum(tail) / len(tail)
drop = head_avg - tail_avg
if drop > 12:
return (f"DECAYING — {head_avg:+.1f} -> {tail_avg:+.1f} dB ({drop:.0f} dB down). "
f"Almost certainly an xfade tail, not a part")
if drop < -12:
return f"RISING — {head_avg:+.1f} -> {tail_avg:+.1f} dB (fading IN)"
return f"STEADY — {head_avg:+.1f} -> {tail_avg:+.1f} dB (a real, sustained part)"
def verdict(sc: float, trk: float, mst: float) -> str:
SIL = -70.0 # below this is silence for our purposes
if sc <= SIL and trk <= SIL and mst <= SIL:
......@@ -206,6 +290,11 @@ def main() -> int:
ap.add_argument("-d", "--orbits", nargs="+", type=int,
default=list(range(1, 13)), help="dN orbits to probe")
ap.add_argument("-s", "--seconds", type=float, default=4.0)
ap.add_argument("-t", "--timeline", nargs="?", type=float, const=1.0,
metavar="BIN_S",
help="also show level OVER TIME per orbit (default 1s bins). Use "
"this whenever a pattern 'plays then fades' — an aggregate "
"cannot tell a decay from a sparse part.")
args = ap.parse_args()
for tool in ("pw-record", "pw-link"):
......@@ -275,6 +364,12 @@ def main() -> int:
print(f" d{dn:<5d} {fmt(s_peak)} {fmt(s_rms)} {fmt(t_peak):>9s} "
f"{fmt(t_rms):>8s} {note}")
if args.timeline and not bad:
series = sc.timeline(args.timeline)
if series:
print(f" SC over time [{args.timeline:g}s bins] "
f"|{sparkline(series)}| {shape_of(series, args.timeline)}")
if contaminated:
print(f"\n !! {len(contaminated)} tap(s) were NOT measuring only what they claimed.")
print(" Rows above marked UNRELIABLE must be discarded, not interpreted.")
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment