Commit 7b2e5b4d by PLN (Algolia)

fix(control-lens): a control is only proven if its swing beats the signal's own drift

Three corrections after the first real run against the rig, each one a case of the
tool answering a question it could not actually answer.

1. THE PLY VERDICT WAS A DIVISION BY ZERO. `ply` reported "EFFECTIVE, +inf %
   onsets/s" on d4. The truth: the envelope-ratio onset detector returned a flat
   0.0 events/s, because d4 is a SUSTAINED bass and that detector only fires on a
   1.6x jump between adjacent bins. Percentage against zero is infinity, and the
   tool declared an effect from an artifact — precisely what its own docstring
   promises never to do. Replaced with SPECTRAL FLUX (half-wave-rectified
   per-band magnitude increase, adaptive median + 2.5*MAD threshold), which fires
   on a NEW NOTE even when total level barely moves — exactly what ply does, since
   it subdivides events without getting louder. d4's baseline went 0.0 -> 8.1
   onsets/s, and ply then measured a real, REVERTING change: 8.1 -> 4.7 at CC127
   -> 7.7 back at 0.
   Plus a floor: under 0.8 onsets/s the answer is INCONCLUSIVE with an
   explanation, not a number. inf/nan deltas print NOT JUDGED and are excluded.

2. NO CHANGE MEANT THE WRONG LENS, NOT A DEAD CONTROL. gMask on d2 read "NO
   CHANGE, 0.5 dB rms" — correct arithmetic, wrong question. gMask is
   `midiOn "^41" (mask "t!7 f")`: it removes one eighth of a cycle, so it changes
   the PATTERN and costs ~0.6 dB. On the density lens the same control reads a
   clean -42% onsets. A "no change" verdict is only meaningful if the measure
   could have seen the change.

3. A-B-A, BECAUSE THE PATTERNS MOVE BY THEMSELVES. gMask showed an IDENTICAL -42%
   at both test values, which is either a control that fails to revert or a
   pattern that just varied — and d2 of vague_de_crime is
   `"~ c . <[~ c ~ c] [<c ~ ~ c> ...]>"`, a different bar every cycle. An A-B
   comparison cannot tell those apart. So the baseline condition is now
   re-measured at the END, the self-drift is reported, and any swing under 2x the
   drift is INCONCLUSIVE rather than EFFECTIVE. First use immediately paid off:
   ply's timbre swing 19.9% against 4.7% drift = trustworthy.

Self-inflicted note for next time: these edits were made WHILE a batch loop was
invoking the file, so one run died on a half-applied change (AttributeError on an
argparse dest added two edits later). Its captured data was fine, but don't hot-edit
a tool a running job is calling.
parent 90f20cf7
......@@ -126,27 +126,44 @@ def capture(orbit: int, seconds: float) -> tuple[np.ndarray | None, str]:
def onsets_per_s(sig: np.ndarray) -> float:
"""Crude but honest onset rate: peaks in a 10 ms-hop energy envelope.
Deliberately not librosa — this runs on the live rig under system python3,
and the question ("did event density change?") needs a RELATIVE number, not
a musicologically correct one.
"""Onset rate from SPECTRAL FLUX, not raw envelope ratio.
The first version of this counted bins where energy rose 1.6x over the
previous bin. On a percussive orbit that works; on a SUSTAINED one (d4 here is
a bass) it returns a flat 0.0 events/s — and a baseline of zero made the
percentage delta `inf`, so the tool cheerfully declared a control EFFECTIVE on
the strength of a division by zero. That is the precise failure this file's
docstring promises not to commit, so it is fixed rather than tolerated.
Spectral flux (half-wave-rectified increase in per-band magnitude) fires on a
NEW NOTE even when total level barely moves, which is exactly what `ply` does:
it subdivides events without making things louder. Still deliberately not
librosa — this must run on the live rig under system python3 — and still only
needs to be RELATIVE, not musicologically correct.
"""
hop = SR // 100
n = len(sig) // hop
if n < 8:
win, hop = 1024, SR // 100 # ~21 ms window, 10 ms hop
n = (len(sig) - win) // hop
if n < 16:
return 0.0
w = np.hanning(win)
frames = np.lib.stride_tricks.sliding_window_view(sig, win)[::hop][:n]
mag = np.abs(np.fft.rfft(frames * w, axis=1))
flux = np.maximum(0.0, np.diff(mag, axis=0)).sum(axis=1)
if flux.max() <= 1e-9:
return 0.0
env = np.sqrt(np.array([np.mean(sig[i*hop:(i+1)*hop] ** 2) for i in range(n)]) + 1e-12)
# Onset = a rise well above the local floor, with a 60 ms refractory gap so
# one transient is not counted six times.
floor = np.median(env)
thresh = max(floor * 3.0, env.max() * 0.12)
# Adaptive threshold: median + 2 * MAD is robust to a few huge transients in a
# way that mean + k*std is not.
med = float(np.median(flux))
mad = float(np.median(np.abs(flux - med))) + 1e-9
thresh = max(med + 2.5 * mad, flux.max() * 0.08)
count, last = 0, -99
for i in range(1, n):
if env[i] > thresh and env[i] > env[i-1] * 1.6 and i - last > 6:
for i in range(1, len(flux) - 1):
# Local peak above threshold, with a 50 ms refractory gap so one transient
# is not counted five times.
if flux[i] > thresh and flux[i] >= flux[i-1] and flux[i] > flux[i+1] and i - last > 5:
count += 1
last = i
return count / (n / 100.0)
return count / (len(flux) / 100.0)
def measure(sig: np.ndarray) -> dict:
......@@ -167,8 +184,15 @@ def measure(sig: np.ndarray) -> dict:
}
# Below this an onset rate is not a measurement, it is a rounding artifact. A
# percentage against a near-zero baseline is arbitrarily large, and on the first
# real run that made `ply` read "+inf %, EFFECTIVE" off a division by zero on a
# sustained bass orbit. NaN is returned instead so the caller must handle it.
MIN_ONSETS = 0.8
def pct(new: float, old: float) -> float:
return 100.0 * (new - old) / old if old > 1e-9 else float("inf")
return 100.0 * (new - old) / old if old > 1e-9 else float("nan")
def delta_for(kind: str, base: dict, test: dict) -> tuple[float, float, str]:
......@@ -195,6 +219,9 @@ def main() -> int:
ap.add_argument("-s", "--seconds", type=float, default=8.0,
help="capture length per value (default 8s ~ 4 bars at 120)")
ap.add_argument("--label", default="", help="name for the report line")
ap.add_argument("--no-aba", dest="aba", action="store_false",
help="skip the closing A-again capture (faster, but then a swing "
"cannot be told apart from a pattern that varies per cycle)")
args = ap.parse_args()
if 77 <= args.cc <= 84:
......@@ -218,6 +245,14 @@ def main() -> int:
print(f"control-lens: DISCARD — baseline unusable: {why}", file=sys.stderr)
return 2
b = measure(base)
if args.kind == "density" and b["onsets"] < MIN_ONSETS:
print(f"control-lens: INCONCLUSIVE — d{args.orbit} shows only "
f"{b['onsets']:.2f} onsets/s at baseline, which is below what this "
f"detector can measure a CHANGE against. Density is the wrong lens "
f"for a sustained or near-silent orbit; test a ply control on a "
f"PERCUSSIVE orbit instead. (Refusing to divide by ~zero and call "
f"the result an effect.)", file=sys.stderr)
return 3
if b["rms_db"] < -70.0:
print(f"control-lens: SKIPPED — d{args.orbit} is SILENT at baseline "
f"({b['rms_db']:.1f} dB rms). Every control would read 'no change'; "
......@@ -242,6 +277,10 @@ def main() -> int:
continue
m = measure(sig)
d, thr, unit = delta_for(args.kind, b, m)
if math.isnan(d) or math.isinf(d):
print(f" {v:6d} NOT JUDGED (delta undefined against this baseline)",
file=sys.stderr)
continue
best = max(best, abs(d))
rows.append((v, m, d, unit))
print(f" {v:6d} {m['rms_db']:8.1f} {m['centroid']:8.0f}Hz "
......@@ -253,14 +292,48 @@ def main() -> int:
lcxl.send_cc(port, args.cc, args.restore)
print(f"\n restored CC{args.cc} -> {args.restore}")
# A-B-A: re-measure the baseline condition at the END.
#
# Without this the design cannot separate "the control did it" from "the
# pattern did it on its own", and on this rig patterns MOVE BY THEMSELVES:
# d2 of vague_de_crime is `"~ c . <[~ c ~ c] [<c ~ ~ c> ...]>"`, i.e. a
# different bar every cycle. On 2026-07-28 gMask showed an identical -42%
# onset drop at BOTH test values, which is either a control that does not
# revert or a pattern that simply varied — indistinguishable from an A-B
# comparison alone. So measure A again and report the drift; a swing smaller
# than the drift is not a result.
drift = None
if args.aba:
time.sleep(1.5)
again, why = capture(args.orbit, args.seconds)
if again is None:
print(f" A-B-A control capture discarded ({why})", file=sys.stderr)
else:
a2 = measure(again)
d2, _, unit2 = delta_for(args.kind, b, a2)
if not (math.isnan(d2) or math.isinf(d2)):
drift = abs(d2)
print(f" {'A again':>6s} {a2['rms_db']:8.1f} {a2['centroid']:8.0f}Hz "
f"{a2['onsets']:8.1f} {a2['bands']['<150']:5.1f} "
f"{a2['bands']['150-2k']:6.1f} {a2['bands']['2k+']:5.1f} "
f"{d2:+.1f} {unit2} <- drift with the control back at base")
_, thr, unit = THRESH[args.kind]
if not rows:
print("\ncontrol-lens: NO VERDICT — every capture was discarded.",
file=sys.stderr)
return 2
if drift is not None and best < 2.0 * drift:
print(f"\ncontrol-lens: {name} INCONCLUSIVE — best swing {best:.1f} {unit} is "
f"not clear of the {drift:.1f} {unit} the signal drifted on its own with "
f"the control back at baseline. The pattern may simply vary per cycle "
f"(many do). Re-run with a longer -s, or judge it on a steadier orbit.",
file=sys.stderr)
return 3
if best >= thr:
extra = f", drift {drift:.1f}" if drift is not None else ""
print(f"\ncontrol-lens: {name} is EFFECTIVE — best swing {best:.1f} {unit} "
f"(>= {thr:g} threshold)")
f"(>= {thr:g} threshold{extra})")
return 0
print(f"\ncontrol-lens: {name} shows NO CHANGE — best swing only {best:.1f} "
f"{unit} (< {thr:g}). Either the control is not wired to this orbit, or "
......
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