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 #!/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) Why this exists (2026-07-27, mid-rehearsal, J-8 to OPAL)
------------------------------------------------------- -------------------------------------------------------
...@@ -57,7 +57,49 @@ import sys ...@@ -57,7 +57,49 @@ import sys
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from pathlib import Path 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). # Below this linear gain a track is inaudible in practice (~ -60 dB).
SILENT_GAIN = 1e-3 SILENT_GAIN = 1e-3
......
...@@ -163,6 +163,40 @@ class Tap: ...@@ -163,6 +163,40 @@ class Tap:
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
self.proc.kill() 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]: def levels(self) -> tuple[float, float]:
"""(peak dBFS, rms dBFS); -inf for digital silence.""" """(peak dBFS, rms dBFS); -inf for digital silence."""
try: try:
...@@ -187,6 +221,56 @@ def fmt(db: float) -> str: ...@@ -187,6 +221,56 @@ def fmt(db: float) -> str:
return " -inf " if db == -math.inf else f"{db:+7.1f}" 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: def verdict(sc: float, trk: float, mst: float) -> str:
SIL = -70.0 # below this is silence for our purposes SIL = -70.0 # below this is silence for our purposes
if sc <= SIL and trk <= SIL and mst <= SIL: if sc <= SIL and trk <= SIL and mst <= SIL:
...@@ -206,6 +290,11 @@ def main() -> int: ...@@ -206,6 +290,11 @@ def main() -> int:
ap.add_argument("-d", "--orbits", nargs="+", type=int, ap.add_argument("-d", "--orbits", nargs="+", type=int,
default=list(range(1, 13)), help="dN orbits to probe") default=list(range(1, 13)), help="dN orbits to probe")
ap.add_argument("-s", "--seconds", type=float, default=4.0) 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() args = ap.parse_args()
for tool in ("pw-record", "pw-link"): for tool in ("pw-record", "pw-link"):
...@@ -275,6 +364,12 @@ def main() -> int: ...@@ -275,6 +364,12 @@ def main() -> int:
print(f" d{dn:<5d} {fmt(s_peak)} {fmt(s_rms)} {fmt(t_peak):>9s} " print(f" d{dn:<5d} {fmt(s_peak)} {fmt(s_rms)} {fmt(t_peak):>9s} "
f"{fmt(t_rms):>8s} {note}") 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: if contaminated:
print(f"\n !! {len(contaminated)} tap(s) were NOT measuring only what they claimed.") print(f"\n !! {len(contaminated)} tap(s) were NOT measuring only what they claimed.")
print(" Rows above marked UNRELIABLE must be discarded, not interpreted.") 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