notepilot / scripts /follower_sim.mjs
jasonmdong's picture
Sync from GitHub via hub-sync
11330a4 verified
Raw History Blame Contribute Delete
160 kB
#!/usr/bin/env node
// follower_sim.mjs — offline simulator for the frame-based score follower
// (spec: docs/frame-follower-spec.md §8). Node 22 ESM, no dependencies.
//
// WHY: the follower's numbers cannot be tuned on the live rig (one run per
// minute, no ground truth better than typed keys). The sim synthesises a
// deliberately mismatched piano from a trace's score, adds the accompaniment
// bleed on the true timeline exactly as the app reports it, runs the SAME
// FrameAnalyzer and ScoreFollower the browser runs, and scores commits against
// the true onsets. Every scenario in §8.4 has a pass/fail target; exit code 1
// on any miss.
//
// node scripts/follower_sim.mjs --all
// node scripts/follower_sim.mjs --scenario bleed --seeds 3 --wav /tmp/bleed.wav --out /tmp/bleed.json
// node scripts/follower_sim.mjs --scenario bleed --timeline # per-event: onset, commit, hold reasons
// node scripts/follower_sim.mjs --scenario bleed --synth legacy # the pre-2026-09-15 tone, for audit
// node scripts/follower_sim.mjs --scenario phone-bleed # the mobile case: speaker and mic inches apart
// SIM_DEBUG_MIX=1 node scripts/follower_sim.mjs --scenario phone-bleed # print the mix's measured SER and nonlinear residual
//
// THE TONE THIS SYNTHESISES was measured off the rig on 2026-09-15, not
// guessed: see the RIG block below and scenario table's re-baseline note. Until
// then it was 8 partials with h^-1.3 amplitudes — fundamental-dominant, nothing
// above 784 Hz at a G2 — which is to say it was a picture of the notes the
// engine's shipped 6-partial template already fits, so every attempt to model
// the real rig's upper tail looked like a regression here. `--synth legacy`
// restores that tone exactly (same random draws, same order) so every
// before/after number in the scenario table can be re-checked in one command.
//
// Debug environment (frame-level, for one scenario at a time):
// SIM_DEBUG=t0,t1 print one line per frame between t0 and t1 seconds of audio
// SIM_DEBUG_H=1 add the candidate/committed hypothesis internals (caps, gains, onset score, pitch margin)
// SIM_DEBUG_ELL=1 add the candidate's and committed state's likelihoods and NNLS gains
// SIM_DEBUG_X=1 dump the whitened observation and the best hypothesis' templates
// SIM_DEBUG_RES=1 dump the committed fit, its residual and the observation's top rows
// SIM_DEBUG_BEAM=1 list every live hypothesis (index, weight, evidence, birth, parent) per frame
// SIM_VERBOSE=1 print false/missed lists and hold histograms for passing scenarios too
//
// KNOWN LIMIT, found while building `low-note-weak` and not fixed here. Through
// a laptop speaker a G2 arrives as its partials, and its strongest one is
// 196 Hz — which IS G3. So a plain G3 fits a fundamental-starved G2 template
// almost exactly, while a G2 does NOT fit a G3 template (294 Hz is left over).
// The confusion is one-way, and it is sharp: with a G2 written immediately
// before a G3, the G3's attack spawns a fresh "the G2 starts NOW" hypothesis
// that out-weighs the real one, the committed state's onset clock jumps a beat
// late, and the future guard then refuses the G3 for a whole IOI — measured
// recall 0.05 with the engine tracking the right rhythm five events behind.
// Twinkle never writes that interval (all five of its G2s are followed by B2 or
// C3), so no scenario here contains it; a piece that did would need the engine
// to price the missing 294 Hz, not just tolerate a missing fundamental.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(HERE, '..');
const STATIC = path.join(ROOT, 'static');
// Import the very modules the browser loads (query strings are part of the
// specifier in follower.js; Node resolves them as distinct file URLs).
const salience = await import(pathToFileURL(path.join(STATIC, 'salience.js')).href);
const followerMod = await import(pathToFileURL(path.join(STATIC, 'follower.js')).href);
const { FrameAnalyzer } = salience;
const { ScoreFollower, FOLLOWER_DEFAULTS, FOLLOWER_VERSION, normaliseEvents } = followerMod;
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
function parseArgs(argv) {
const a = { opt: {}, seeds: 1, seed: 1, scenario: null, all: false, sr: 48000, json: false, quiet: false, dumpSal: false, trackerDebounce: false, reverb: false };
for (let i = 0; i < argv.length; i++) {
const k = argv[i];
const next = () => argv[++i];
switch (k) {
case '--trace': a.trace = next(); break;
case '--score': a.score = next(); break;
case '--scenario': a.scenario = next(); break;
case '--all': a.all = true; break;
case '--seeds': a.seeds = Number(next()); break;
case '--seed': a.seed = Number(next()); break;
case '--bpm': a.bpm = Number(next()); break;
case '--tempo-scale': a.tempoScale = Number(next()); break;
case '--bleed': a.bleed = Number(next()); break;
case '--snr': a.snr = Number(next()); break;
case '--hpf': a.hpf = Number(next()); break;
case '--jitter': a.jitter = Number(next()); break;
case '--rubato': a.rubato = Number(next()); break;
case '--bleed-lag': a.bleedLag = Number(next()); break;
case '--bleed-clip': a.bleedClip = Number(next()); break; // phone-speaker soft clip drive (0 = linear)
case '--sr': a.sr = Number(next()); break;
case '--synth': a.synth = next(); break; // rig (measured, default) | legacy (pre-2026-09-15 8-partial h^-1.3)
case '--opt': { const s = next(); const eq = s.indexOf('='); a.opt[s.slice(0, eq)] = parseOptValue(s.slice(eq + 1)); break; }
case '--wav': a.wav = next(); break;
case '--out': a.out = next(); break;
case '--replay': a.replay = next(); break;
case '--json': a.json = true; break;
case '--dump-sal': a.dumpSal = true; break;
case '--quiet': a.quiet = true; break;
case '--reverb': a.reverb = true; break;
case '--tracker-debounce': a.trackerDebounce = true; break;
case '--timeline': a.timeline = true; break;
case '--help': case '-h': usage(); process.exit(0);
default: console.error('unknown argument ' + k); usage(); process.exit(2);
}
}
if (a.scenario === 'all') { a.all = true; a.scenario = null; }
return a;
}
function parseOptValue(s) {
if (s === 'true') return true;
if (s === 'false') return false;
if (s.startsWith('[') || s.startsWith('{')) return JSON.parse(s);
const n = Number(s);
return Number.isFinite(n) ? n : s;
}
function usage() {
console.log(`node scripts/follower_sim.mjs [--trace PATH] [--score twinkle|repeated] [--scenario NAME|all|--all] [--seeds N] [--seed N]
[--bpm X] [--tempo-scale X] [--bleed G] [--snr DB] [--hpf HZ] [--jitter MS] [--rubato PCT] [--bleed-lag MS] [--bleed-clip G]
[--sr 48000|44100] [--synth rig|legacy] [--opt key=value ...] [--wav OUT.wav] [--out TRACE.json] [--replay SESSION.json]
[--json] [--dump-sal] [--quiet] [--reverb] [--tracker-debounce]`);
}
// ---------------------------------------------------------------------------
// Deterministic randomness
// ---------------------------------------------------------------------------
function makeRng(seed) {
let s = (seed >>> 0) || 1;
const rnd = () => {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
return {
uniform: (lo = 0, hi = 1) => lo + (hi - lo) * rnd(),
normal: (mu = 0, sd = 1) => {
let u = 0; while (u === 0) u = rnd();
return mu + sd * Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * rnd());
},
next: rnd,
};
}
// ---------------------------------------------------------------------------
// Scores
// ---------------------------------------------------------------------------
// Twinkle as recorded in .accompy_traces (solo G2–A3 with a chord at the ends,
// accompaniment one octave up — the harmonic-overlap bleed the rig actually has).
const TWINKLE_RH = [[[48],0],[[55],1],[[52],2],[[55],3],[[53],4],[[57],5],[[48],6],[[55],7],[[43],8],[[47],9],[[48],10],[[52],11],[[43],12],[[47],13],[[48,52],14],[[52],16],[[55],17],[[50],18],[[55],19],[[48],20],[[55],21],[[47],22],[[55],23],[[52],24],[[55],25],[[50],26],[[55],27],[[48],28],[[55],29],[[47],30],[[43],31],[[48],32],[[55],33],[[52],34],[[55],35],[[53],36],[[57],37],[[48],38],[[55],39],[[43],40],[[47],41],[[48],42],[[52],43],[[43],44],[[47],45],[[48,52],46]];
const TWINKLE_LH = [[[60],0],[[60],1],[[67],2],[[67],3],[[69],4],[[69],5],[[67],6],[[65],8],[[65],9],[[64],10],[[64],11],[[62],12],[[62],13],[[60],14],[[67],16],[[67],17],[[65],18],[[65],19],[[64],20],[[64],21],[[62],22],[[67],24],[[67],25],[[65],26],[[65],27],[[64],28],[[64],29],[[62],30],[[60],32],[[60],33],[[67],34],[[67],35],[[69],36],[[69],37],[[67],38],[[65],40],[[65],41],[[64],42],[[64],43],[[62],44],[[62],45],[[60],46]];
// Repeats and unison-with-bleed that Twinkle lacks (§8.1).
const REPEATED_RH = [48, 48, 48, 48, 55, 55, 48, 48, 52, 52, 48, 48].map((p, i) => [[p], i, 1]);
const REPEATED_LH = [0, 2, 4, 6, 8, 10].map((b) => [[REPEATED_RH[b][0][0] + 12], b, 2]);
// The index-8/index-12 configuration of .accompy_traces/20260915-081347,
// stretched out. G2 (MIDI 43, 98.00 Hz) is the lowest note of the solo part,
// and every one of its audible partials belongs to something else in the piece:
// 2f0 = 196.0 Hz is G3 (MIDI 55, ITSELF a solo pitch), 3f0 = 294.0 Hz is D4
// (MIDI 62, +2 cents — the same FFT bin at 48 kHz/2048) and 4f0 = 392.0 Hz is
// G4 (MIDI 67, exact); D4 and G4 are the two commonest accompaniment pitches
// here. B2 (123.47 Hz) is the same trap one step up: 3f0 = 370.4 Hz is F#4.
// The accompaniment strikes G4 and D4 under the low notes, including two
// D4+G4 chords, so the bleed component and the solo template compete for the
// same rows — the configuration in which the real session died. Bars 1–2 are
// Twinkle's own opening, which is what the real engine got right (12 clean
// advances in 16.7 s) before it met index 8; from there on half the events are
// G2 or B2.
//
// One ordering is deliberately absent, and it is not a convenience: NO G2 is
// followed by a G3. With the fundamental gone a G2 IS a G3 plus a 294 Hz
// partial, so a G3 fits a starved-G2 template while a G2 does NOT fit a G3
// template (the 294 Hz is unexplained) — the confusion is one-way. Twinkle
// never writes that interval either (its five G2s are followed by B2 or C3
// every time), so a scenario that used it would be testing a figure the
// repertoire does not contain. See the note in the header of this file.
const LOWNOTE_RH = [
48, 55, 52, 55, 53, 57, 48, 55, // Twinkle bar 1: the beam locks on
43, 47, 48, 52, 43, 47, 48, 52, // the real trace's index 8–15
43, 47, 43, 48, 52, 43, 47, 43, // G2-dense, D4/G4 underneath
48, 43, 47, 43,
].map((p, i) => [[p], i, 1]);
const LOWNOTE_LH = [[60], [67], [69], [67], [62], [67], [62], [67], [62, 67], [62], [67], [62], [67], [62, 67]].map((p, i) => [p, i * 2, 2]);
// The note density that broke the engine, taken from the score that broke it:
// .accompy_traces/20260916-113517_cello-concerto-no1-in-c-major-hobviib1-joseph-ha
// (Haydn cello concerto no. 1, 143 solo notes over 120 beats at 60 bpm, recall
// 7 %). `DENSE_PITCHES` and `DENSE_GAPS24` ARE that trace's meta.rh, first 200
// solo events, read out verbatim — not a fast pattern invented here. Two
// properties are taken from the real score together, because it is the pair
// that makes the passage hard and either one alone is easy:
//
// 1. THE SPACING. Gaps in this prefix: p10 0.125 b, p25 0.125 b, MEDIAN
// 0.250 b, p75 0.500 b; 66 of 199 (33 %) are under 0.25 beat. The whole
// excerpt reads p10 0.125 / median 0.250 with 128 of 350 under 0.25 beat
// (37 %), so the prefix carries the same distribution. At bps 1 that is
// 250 ms median and 125 ms in the fastest decile, against an engine whose
// own end-to-end commit latency on the two reference Twinkles is 175-182 ms
// median and 203-212 ms p90. A third of these notes are therefore OVER
// before the evidence for them has been accumulated — by construction, not
// by bad luck. (Both Twinkles' shortest IOI is 985 ms: nothing in the
// existing suite is within 4x of this.)
// 2. THE PITCH AMBIGUITY. 16 distinct pitches over 200 events, MIDI 60-84,
// with 79/74/76/72 carrying most of them. On the full excerpt one pitch
// leaves a median of 37 candidate positions and it takes a 5-gram before
// the median position is unique. A dense score written out of a scale
// would be localisable from one frame and would test the commit rule
// instead of the search, which is the thing under test here.
//
// Gaps longer than 1.5 beats are CLAMPED to 1.5. The real prefix contains a
// 16-beat and a 103-beat tutti; a long rest is the one thing that hands the
// engine a free re-lock, and this scenario is the runaway player. Clamping
// drops 121 beats of silence, so the piece runs 83 beats instead of 204.
const DENSE_PITCHES = [
72, 74, 72, 71, 72, 74, 76, 77, 77, 76, 74, 76, 77, 76, 71, 72, 67, 64, 60, 72,
71, 67, 74, 72, 72, 74, 72, 71, 72, 74, 76, 77, 77, 76, 74, 69, 81, 79, 77, 76,
74, 72, 71, 83, 83, 81, 81, 81, 81, 79, 79, 79, 76, 78, 78, 79, 79, 79, 81, 83,
84, 81, 83, 84, 83, 84, 83, 84, 83, 79, 79, 74, 76, 72, 71, 69, 67, 69, 69, 71,
69, 71, 69, 71, 69, 71, 67, 79, 79, 84, 84, 79, 82, 82, 81, 80, 81, 79, 77, 76,
74, 72, 74, 74, 76, 74, 76, 74, 76, 74, 76, 72, 79, 81, 79, 79, 79, 84, 84, 84,
84, 84, 84, 84, 83, 81, 79, 77, 77, 76, 74, 72, 74, 76, 74, 76, 74, 76, 74, 76,
72, 72, 72, 79, 81, 79, 78, 79, 81, 83, 84, 84, 83, 81, 83, 84, 83, 78, 79, 79,
77, 76, 76, 78, 79, 78, 81, 81, 79, 79, 77, 76, 76, 78, 79, 78, 79, 81, 79, 79,
81, 83, 79, 74, 71, 72, 74, 71, 67, 76, 78, 79, 76, 72, 72, 74, 76, 72, 69, 69,
];
// Twenty-fourths of a beat, so the score's own quantisation survives: 3 = 0.125 b
// (x56), 6 = 0.25 b (x55), 12 = 0.5 b (x33), 24 = 1 b (x17), plus the 4s and 9s
// of the triplet and dotted figures.
const DENSE_GAPS24 = [
18, 3, 3, 9, 3, 9, 3, 12, 24, 12, 18, 3, 3, 12, 12, 12, 12, 24, 12, 12,
24, 12, 12, 24, 18, 3, 3, 9, 3, 9, 3, 12, 24, 12, 6, 6, 12, 6, 6, 6,
6, 24, 36, 12, 36, 12, 36, 12, 36, 12, 12, 12, 30, 6, 6, 6, 30, 6, 6, 6,
30, 6, 6, 6, 24, 24, 24, 24, 6, 6, 6, 6, 6, 6, 6, 6, 18, 6, 3, 3,
3, 3, 3, 3, 3, 3, 36, 36, 36, 6, 24, 6, 6, 6, 6, 6, 6, 6, 6, 6,
6, 18, 6, 3, 3, 3, 3, 3, 3, 3, 3, 12, 24, 24, 12, 24, 30, 6, 6, 6,
30, 6, 6, 6, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
12, 12, 36, 18, 3, 3, 9, 3, 9, 3, 12, 24, 12, 18, 3, 3, 12, 12, 12, 24,
12, 12, 16, 4, 4, 4, 4, 4, 12, 24, 12, 12, 16, 4, 4, 4, 4, 4, 6, 3,
3, 6, 6, 6, 3, 3, 6, 6, 6, 3, 3, 6, 6, 6, 3, 3, 6, 6, 6,
];
const DENSE_TRANSPOSE = -12; // see REGISTER below
const DENSE_RH = DENSE_PITCHES.map((p, i) => {
let b = 0; for (let k = 0; k < i; k++) b += DENSE_GAPS24[k] / 24;
return [[p + DENSE_TRANSPOSE], +b.toFixed(4)];
});
// REGISTER. The solo is transposed DOWN AN OCTAVE and the accompaniment line
// sits above it, which is the one place this scenario departs from the
// recording, so it is stated plainly rather than buried.
//
// The real excerpt writes the solo at MIDI 60-84 over an orchestral reduction
// whose commonest pitches are 72 (x538), 62 (x507), 67 (x477) and 60 (x472) —
// i.e. the accompaniment is mostly BELOW the solo. In the 33..96 bank that
// configuration inverts the partial counts: a MIDI 84 solo note shows TWO
// partials below the bank top (1046 and 2093 Hz, bankTopHz 2154) while a MIDI
// 60 accompaniment note shows eight and a MIDI 55 one shows ten, so the bleed
// component's comb covers every row the solo template owns and a great many
// more. Measured with the accompaniment there: the bleed explained a median
// 0.73 of the frame against the expected solo note's 0.11, `ev` never rose
// above 0 on the committed event, and the scenario read recall 0.10 with the
// accompaniment ON against 0.54 with it OFF. The wide search went with it:
// top-1 error median +4 events, only 6 % within +-1, 25 of 31 scans pointing
// AHEAD of the pianist — which the 1.5-nat margin bar correctly refused every
// single time (31 scans, 31 `ambiguous`), so nothing unsafe happened, but the
// scenario was measuring an inverted-register bleed and not the note density.
//
// That register is ALSO the one part of this bench that has never been
// measured. The RIG profile was taken on Twinkle, where the accompaniment is
// an octave ABOVE the solo, and both other built-in scores keep it there. So
// the choice is between reproducing an unvalidated corner of the synth and
// testing the thing the scenario is named for. It tests the density: the solo
// moves to MIDI 48-72 (Twinkle's own register, where a note has 8-21 visible
// partials) and the accompaniment to a line in 72-79 above it, which is the
// relationship BASE's `bleed` 1.0 was calibrated against. The RHYTHM — the
// only thing under test — is untouched.
//
// What that costs in fidelity is worth naming: with the accompaniment below,
// this scenario reproduces the real session almost exactly (recall 0.10 here
// against 0.07 on the rig). Above, it reads 0.45. The difference is the
// register, not the density, and it is the measurement that says the Haydn's
// remaining 90 % is a BLEED problem in the solo's own register — the same
// conclusion the mic-distance work reached from the other side. Set
// DENSE_TRANSPOSE to 0 and DENSE_LH_LINE back below the solo to re-run it.
const DENSE_LH_LINE = [72, 79, 76, 74, 72, 77, 79, 76, 74, 72, 76, 79, 77, 74, 72, 76];
const DENSE_LH = (() => {
const out = [];
for (let b = 0; b <= Math.floor(DENSE_RH[DENSE_RH.length - 1][1]); b++) out.push([[DENSE_LH_LINE[b % DENSE_LH_LINE.length]], b, 1]);
return out;
})();
function loadScore(args) {
if (args.score === 'repeated') return { name: 'repeated', rh: REPEATED_RH, lh: REPEATED_LH, bps: 1 };
if (args.score === 'lownote') return { name: 'lownote', rh: LOWNOTE_RH, lh: LOWNOTE_LH, bps: 1 };
if (args.score === 'dense') return { name: 'dense', rh: DENSE_RH, lh: DENSE_LH, bps: 1 };
const tracePath = args.trace || path.join(ROOT, '.accompy_traces', 'latest.json');
try {
const tr = JSON.parse(fs.readFileSync(tracePath, 'utf8'));
const m = tr.meta || {};
if (Array.isArray(m.rh) && m.rh.length) return { name: m.score || path.basename(tracePath), rh: m.rh, lh: Array.isArray(m.lh) ? m.lh : [], bps: m.initialBps || 1 };
} catch (e) { /* fall through to the built-in copy */ }
return { name: 'twinkle (built-in)', rh: TWINKLE_RH, lh: TWINKLE_LH, bps: 1 };
}
// ---------------------------------------------------------------------------
// Synthesis (§8.2) — the measured rig, with `--synth legacy` as the fallback
// ---------------------------------------------------------------------------
const midiToFreq = (m) => 440 * 2 ** ((m - 69) / 12);
// ---------------------------------------------------------------------------
// THE MEASURED RIG (§8.2). One block: edit this and nothing else when the rig
// is re-measured.
// ---------------------------------------------------------------------------
// SOURCE: .accompy_traces/20260915-083040_twinkle-twinkle-little-star.json.
// Every verbose follow.frame's quantised `sal` was decoded with the engine's
// own dequantiseSal and re-whitened with the follower's own noise-floor
// parameters as recorded in that trace's follower.init (nfMargin 2, nfDown 0.3,
// nfUp 1.002, floorInitFrames 20, nfSmoothBins 4, compress 0.5); each typed
// input.key gave a (post − pre) mean-POWER spectrum — sustain +160..+340 ms,
// attack +40..+140 ms, pre-roll −350..−100 ms — which was deconvolved against
// the analyser's own toneResponse() columns by NNLS (one column per partial,
// plus free columns for any accompaniment pitch ringing in the span and for the
// sub-fundamental bins). 43 presses with no other key within ±400 ms: MIDI 43
// ×16 (12 with no accompaniment ringing at all), 55 ×9, 48 ×5, 52 ×5, 47 ×3,
// 50 ×2, 41/53/57 ×1. NNLS R² 0.983–0.9997. Profile script and the full JSON
// (per-pitch a(h) with p25/p75, the noise floor, the overlap tables):
// scratchpad/partial_profile5.mjs → scratchpad/rig_partial_profile.json
//
// Everything below is in the RAW (post − pre) power domain, never the whitened
// one. Whitening divides by the min-statistics floor, which on this session is
// +1.88 dB at row 43 and −13.22 dB at row 89 — a ~15 dB tilt that belongs to
// the mic and the analyser, not to the note. Partials fitted to whitened
// numbers would apply that tilt twice; the sim's own pink noise reproduces it
// (measured floor −3.94 dB/octave in this constant-Q bank against pink's −3.0,
// i.e. right to within ~1 dB — the floor is NOT what the bench got wrong).
//
// WHAT THE OLD SYNTH GOT WRONG — three independent things, and it is worth
// naming them because five attempts at modelling the real rig's upper tail were
// reverted for "regressing" scenarios that could not contain the phenomenon:
//
// 1. TRUNCATION. `for (let h = 1; h <= 8; h++)` stopped every pitch at 8
// partials, so a synthetic G2 had NOTHING above 784 Hz. The real G2 is a
// near-flat-topped comb running to the top of the 33..96 bank, sitting at
// −7 to −13 dB (re its strongest partial) all the way to h = 20 (1960 Hz,
// row 95). h^-1.3 puts h8 at −11.7 dB and h9+ at −∞; the rig is 20–40 dB
// louder than that over half the bank. The deep raw NULLS in between
// (MIDI 43 h8 at −34.2 dB with h7 at −10.4 and h10 at −7.0) prove this is
// LINE structure, not a broadband hiss a noise floor could stand in for.
// 2. NO PLAYBACK RESPONSE. The exponent was never the problem: alternating
// least squares on L(pitch,h) = C_pitch − 20·alpha·log10(h) + R(f) over all
// nine measured pitches gives alpha = 1.14 (rmse 5.12 dB) against 8.20 dB
// for the same fit with no R, and the per-pitch exponents then agree with
// each other (0.92–1.30). One source law plus ONE rig curve explains every
// pitch; there is no pitch-specific tone. R rises ~22 dB from 110 Hz to
// 1.8 kHz — that rise is what creates the tail, and the old synth had no
// counterpart to it at all.
// 3. THE WRONG LOW END. speakerHpf 180 / speakerOrder 4 takes 21.2 dB off a
// 98 Hz fundamental. Measured, the rig takes 12–14 dB off 98–139 Hz
// relative to its 440–880 Hz plateau — roughly 1st-order with a corner near
// 200–250 Hz, and a TILT rather than a corner. The laptop mic shares a
// chassis with the laptop speaker, so the fundamental arrives
// structure-borne: 1.8 dB (row readout) / 5.2 dB (deconvolved) below the
// strongest partial in a G2's sustain. What buries it in the ENGINE's
// domain is the noise floor's tilt, not the speaker.
//
// Cross-check of the whole chain against numbers already recorded in
// FOLLOWER_DEFAULTS for the real G2 at 15.657 s — expected whitened peak row 83,
// row94 0.70, row95 0.66, row74 0.70, row81 0.53, rows40–47 0.09; the profile
// pipeline reproduces 83 / 0.682 / 0.608 / 0.613 / 0.510 / 0.094.
const RIG = {
// H(m) = floor(bankTopHz / f0): every partial the 33..96 filterbank can see.
// 2154 Hz = midiToFreq(96)·2^(1/24), the top row's upper edge. 24 partials at
// MIDI 41, 21 at G2 (43), 16 at C3, 10 at G3, 8 at C4, 4 at C5.
bankTopHz: 2154,
// Source law a_src(h) = h^-alpha. Measured 1.14 over nine pitches; 1.10 is
// the value the per-pitch medians centre on once R is removed (41:0.92,
// 43:1.08, 47:1.06, 48:1.10, 50:1.22, 52:1.10, 53:1.58, 55:1.12, 57:1.30).
alpha: 1.10,
// Playback response R(f), 1/3-octave bands, dB, with the press count each
// band was averaged over. +8.5 dB resonance at 349 Hz, −4.6 dB dip at 698 Hz,
// +7..+9 dB above 1.1 kHz. Interpolated in log f, clamped to the end values.
// CAVEAT, stated rather than buried: the f0 span of the measurement is only
// 1.3 octaves, so the alpha/R split is under-determined — treat R as a
// measured curve of this laptop, not as physics.
responseDb: [
[110, -13.56], [139, -12.17], [175, -6.23], [220, -1.63], [277, 3.94],
[349, 8.52], [440, 0.11], [554, -0.34], [698, -4.56], [880, -2.48],
[1109, 3.78], [1397, 7.06], [1760, 9.30], [2217, 8.26],
],
// Every partial except the fundamental repeats press to press: the measured
// p75−p25 per partial has a median of 1.9 dB at MIDI 43 (16 presses) and
// 0.6–1.3 dB at 48/52/55, so each note draws an independent ±2 dB ripple on
// h ≥ 2 (uniform(−2,2) has an inter-quartile range of 2 dB).
partialRippleDb: 2,
// ...the fundamental alone is stochastic, and only at the bottom of the
// range. Measured p75−p25 of a(1): MIDI 43 (98 Hz, 16 presses) 17.0 dB,
// 52 (165 Hz, 5) 25.4 dB, 48 (131 Hz, 5) 7.6 dB, 47 (123 Hz, 3) 6.3 dB,
// against 0.0 dB at MIDI 55 (196 Hz) over NINE presses, where a(1) is the
// strongest partial every single time. So below `fundamentalSpreadBelowHz`
// the fundamental is drawn with MIDI 43's own quartiles (median −5.2 dB re
// the strongest partial, p25 −18.8, p75 −1.8 → σ = 20.2 dB below the median
// and 5.0 dB above it) and above it the law's own a(1) stands. "The note
// that loses its fundamental" is a per-PRESS event, not a property of the
// pitch, and no scenario produced one before this.
// These are MIDI 43's own quartiles, which is what the profile recommends;
// pooling all 31 presses below 180 Hz (43, 47, 48, 50, 52) weighted by press
// count gives median −8.9 / p25 −20.9 / p75 −6.1 instead, so the choice moves
// the fundamental by under 4 dB and is not load-bearing. Below p25 the normal
// tail is an extrapolation — `min` bounds it at −45 dB, and MIDI 52's
// measured p25 of −39.6 dB says collapses that deep do happen.
fundamentalDb: { median: -5.2, sdLow: 20.2, sdHigh: 5.0, max: 2, min: -45 },
fundamentalSpreadBelowHz: 180,
// The profile above is a SUSTAIN profile, measured +160..+340 ms after the
// input.key EVENT — and the rig's speaker→mic loop is ~110 ms (the app's own
// measured median on this session, see low-note-lag-over below), so in NOTE
// time that window is +50..+230 ms and its centre is 140 ms. Partial
// amplitudes are referred to t = profileSec, so a rendered note matches the
// measured a(h) in the window a(h) was measured in rather than at t = 0.
// This also reproduces, for free, the measured "partials above h = 15 are
// 5–15 dB louder in the attack than in the sustain" (MIDI 43 h20: rawAttDb
// −6.45 against rawSusDb −17.22).
profileSec: 0.14,
// The player types on the LAPTOP keyboard, a few centimetres from the mic:
// 28 % of the power a G2 press adds in the attack window sits BELOW the
// fundamental, falling to 7 % by the sustain. The old hammer burst was
// band-passed 1–5 kHz and had no low thump at all.
thumpShareAttack: 0.28,
thumpHz: 100,
thumpTauSec: 0.035,
thumpWindowSec: 0.14,
// Inharmonicity B in fh = h·f0·√(1 + B·h²). The old 4e-4 was a piano STRING
// and it was harmless while h stopped at 8 (+1.3 % at h8); at h = 20 it is
// +6.5 %, more than a semitone, so every partial above ~h12 landed in the
// wrong filterbank row. The rig's solo voice is the app's own digital synth
// through a speaker: measured, MIDI 43's partials sit on their harmonic rows
// out to h = 21 (h10 → row 83, h14 → row 89, h20 → row 95). 2e-5 keeps a
// touch of stretch (+7 cents at h20, +10 at h24) without moving rows.
inharmonicity: 2e-5,
};
// The pre-2026-09-15 synth, kept so every number in this file stays auditable:
// `--synth legacy` (or `synth: 'legacy'` in a scenario mut) restores it exactly
// — 8 partials, h^-1.3, no playback response, no fundamental spread, no thump.
// It is fundamental-dominant by construction (h8 at −11.7 dB, nothing above),
// which is to say it is a picture of the notes the engine's shipped 6-partial
// template already fits well. Do not baseline against it.
const LEGACY_SYNTH = { partials: 8, alpha: 1.3, inharmonicity: 4e-4 };
// R(f) in dB, log-f interpolation between the measured 1/3-octave bands.
function rigResponseDb(f) {
const B = RIG.responseDb;
if (!(f > B[0][0])) return B[0][1];
if (f >= B[B.length - 1][0]) return B[B.length - 1][1];
for (let i = 1; i < B.length; i++) {
if (f <= B[i][0]) {
const k = Math.log(f / B[i - 1][0]) / Math.log(B[i][0] / B[i - 1][0]);
return B[i - 1][1] + k * (B[i][1] - B[i - 1][1]);
}
}
return B[B.length - 1][1];
}
// Two-sided normal: the fundamental's press-to-press spread is strongly skewed
// (p25 −18.8, median −5.2, p75 −1.8), so one σ per side, both from the quartiles.
function splitNormalDb(rng, { median, sdLow, sdHigh, max, min }) {
const z = rng.normal(0, 1);
return Math.min(max, Math.max(min, median + z * (z < 0 ? sdLow : sdHigh)));
}
// Piano-like additive tone, deliberately NOT the engine's template. Under the
// default `rig` model (see RIG above) it is the laptop speaker + laptop mic
// measured on 20260915-083040: H(m) = floor(2154/f0) partials, a(h) =
// h^-1.10 · R(h·f0) referred to the sustain window, ±3 dB of per-press ripple
// on h ≥ 2, a fundamental drawn with its own 17 dB spread, inharmonicity,
// per-partial decays with random spread, a hammer burst, a keyboard thump
// (solo only — the accompaniment is not typed) and a release tail. Amplitudes
// are normalised so `gain` still means "the strongest partial's amplitude",
// exactly as it did when the strongest partial was always h = 1.
// `legacy: true` restores the old 8-partial h^-1.3 tone. Renders into `mix` at
// `t0` seconds.
function renderNote(mix, sr, rng, { midi, t0, noteOffSec, gain, detuneCents = 0, legacy = false, thumpShare = 0 }) {
const f0 = midiToFreq(midi) * 2 ** (detuneCents / 1200);
const start = Math.max(0, Math.round(t0 * sr));
const relSec = 0.15;
const endSec = noteOffSec + relSec + 0.05;
const nSamp = Math.min(mix.length - start, Math.ceil(endSec * sr));
if (nSamp <= 0) return;
const hMax = legacy ? LEGACY_SYNTH.partials : Math.max(1, Math.floor(RIG.bankTopHz / f0));
const alpha = legacy ? LEGACY_SYNTH.alpha : RIG.alpha;
const B = legacy ? LEGACY_SYNTH.inharmonicity : RIG.inharmonicity;
const parts = [];
for (let h = 1; h <= hMax; h++) {
const fh = h * f0 * Math.sqrt(1 + B * h * h);
if (fh >= sr / 2) break;
const tau = 0.9 * (200 / fh) ** 0.35 * rng.uniform(0.8, 1.2);
// `sus` is the decay already suffered by t = profileSec: dividing it out
// makes the SUSTAIN spectrum equal the measured a(h) instead of the t = 0
// one, which also reproduces the measured "h ≥ 15 is 5–15 dB louder in the
// attack than in the sustain" for free.
const sus = legacy ? 1 : Math.exp(-RIG.profileSec / tau);
let amp = h ** -alpha;
if (!legacy) amp *= 10 ** ((rigResponseDb(fh) + (h === 1 ? 0 : rng.uniform(-RIG.partialRippleDb, RIG.partialRippleDb))) / 20) / sus;
parts.push({ amp, sus, w: 2 * Math.PI * fh / sr, tau, ph: rng.uniform(0, 2 * Math.PI) });
}
if (!parts.length) return;
if (!legacy) {
// The fundamental is drawn against the strongest of the OTHER partials,
// which is where it was measured from; for a G2 that strongest partial is
// h3 (294 Hz, on the rig's 349 Hz resonance), never h1.
let peakSus = 0;
for (let i = 1; i < parts.length; i++) peakSus = Math.max(peakSus, parts[i].amp * parts[i].sus);
if (peakSus > 0 && f0 < RIG.fundamentalSpreadBelowHz) parts[0].amp = peakSus * 10 ** (splitNormalDb(rng, RIG.fundamentalDb) / 20) / parts[0].sus;
// `gain` keeps meaning "amplitude of the strongest partial in the sustain".
let peak = 0;
for (const p of parts) peak = Math.max(peak, p.amp * p.sus);
if (peak > 0) for (const p of parts) p.amp /= peak;
}
const attackN = Math.max(1, Math.round(0.004 * sr));
const offN = Math.round(noteOffSec * sr);
const relN = Math.round(relSec * sr);
for (let i = 0; i < nSamp; i++) {
const t = i / sr;
let env = i < attackN ? i / attackN : 1;
if (i >= offN) { const k = (i - offN) / relN; if (k >= 1) break; env *= 1 - k; }
let v = 0;
for (const p of parts) v += p.amp * Math.exp(-t / p.tau) * Math.sin(p.w * i + p.ph);
mix[start + i] += gain * env * v;
}
// Hammer: 15 ms of band-passed (1–5 kHz) noise at −20 dB relative to the tone.
const hN = Math.round(0.015 * sr);
const bp = biquadBandpass(sr, 1000, 5000);
for (let i = 0; i < hN && start + i < mix.length; i++) {
const n = bp.process(rng.normal(0, 1)) * 0.3;
mix[start + i] += gain * 0.1 * n * (1 - i / hN);
}
// Keyboard thump — the one part of a press that is not a note. Measured: 28 %
// of the power a G2 press adds over the attack window sits below f0. Scaled
// against this note's own tonal power over the same window, so the share is
// what was measured rather than an absolute level.
if (thumpShare > 0 && thumpShare < 1) {
const tN = Math.min(mix.length - start, Math.round(RIG.thumpWindowSec * sr));
if (tN > 0) {
let msTone = 0;
for (const p of parts) { // mean square of Σ amp·e^{−t/τ}·sin(...) over the window
const r = Math.exp(-2 / (p.tau * sr));
msTone += 0.5 * p.amp * p.amp * (r === 1 ? tN : (1 - r ** tN) / (1 - r)) / tN;
}
const lp1 = biquadLowpass(sr, RIG.thumpHz), lp2 = biquadLowpass(sr, RIG.thumpHz);
const buf = new Float64Array(tN);
let msBuf = 0;
for (let i = 0; i < tN; i++) { buf[i] = lp2.process(lp1.process(rng.normal(0, 1))) * Math.exp(-(i / sr) / RIG.thumpTauSec); msBuf += buf[i] * buf[i]; }
msBuf /= tN;
if (msBuf > 0 && msTone > 0) {
const g = gain * Math.sqrt(thumpShare / (1 - thumpShare) * msTone / msBuf);
for (let i = 0; i < tN; i++) mix[start + i] += g * buf[i];
}
}
}
}
// Second-order sections. Butterworth high-pass (Q = 1/√2) for the speaker/mic
// model; a wide band-pass for the hammer burst.
function biquad(b0, b1, b2, a1, a2) {
let x1 = 0, x2 = 0, y1 = 0, y2 = 0;
return { process(x) { const y = b0 * x + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2; x2 = x1; x1 = x; y2 = y1; y1 = y; return y; } };
}
function biquadHighpass(sr, fc, Q = Math.SQRT1_2) {
const w = 2 * Math.PI * fc / sr, c = Math.cos(w), s = Math.sin(w), al = s / (2 * Q);
const a0 = 1 + al;
return biquad((1 + c) / 2 / a0, -(1 + c) / a0, (1 + c) / 2 / a0, -2 * c / a0, (1 - al) / a0);
}
// A laptop speaker, not a microphone: almost nothing below ~150–200 Hz reaches
// the room. Two cascaded Butterworth sections roll off as (f/fc)^4, so at
// fc = 180 Hz a G2 loses 21.2 dB of its 98 Hz fundamental and 0.3 dB of its
// 196 Hz second partial — the physics behind the index-12 stall in
// 20260915-081347, where the lowest solo note reached the mic as its harmonics
// and nothing else. `hpf` (the mic's own high-pass) stays separate: this one
// sits in the PLAYBACK path, so it shapes the solo and the accompaniment
// together and leaves the room noise alone.
function speakerChain(sr, fc, order) {
const secs = [];
for (let i = 0; i < Math.max(1, Math.round(order / 2)); i++) secs.push(biquadHighpass(sr, fc));
return { process(x) { for (const s of secs) x = s.process(x); return x; } };
}
// Two of these in series make the laptop-chassis keyboard thump: a soft
// low-pass at 100 Hz, i.e. below every solo fundamental in the repertoire.
function biquadLowpass(sr, fc, Q = Math.SQRT1_2) {
const w = 2 * Math.PI * fc / sr, c = Math.cos(w), s = Math.sin(w), al = s / (2 * Q);
const a0 = 1 + al;
return biquad((1 - c) / 2 / a0, (1 - c) / a0, (1 - c) / 2 / a0, -2 * c / a0, (1 - al) / a0);
}
function biquadBandpass(sr, fLo, fHi) {
const fc = Math.sqrt(fLo * fHi), Q = fc / (fHi - fLo);
const w = 2 * Math.PI * fc / sr, c = Math.cos(w), s = Math.sin(w), al = s / (2 * Q);
const a0 = 1 + al;
return biquad(al / a0, 0, -al / a0, -2 * c / a0, (1 - al) / a0);
}
// Pink noise, Voss–McCartney with 16 rows.
function pinkNoise(n, rng) {
const rows = 16, vals = new Float64Array(rows);
let sum = 0;
for (let r = 0; r < rows; r++) { vals[r] = rng.normal(); sum += vals[r]; }
const out = new Float32Array(n);
for (let i = 0; i < n; i++) {
// Row k updates every 2^k samples (trailing-zero count of the counter).
let k = 0, c = i + 1;
while ((c & 1) === 0 && k < rows - 1) { c >>= 1; k++; }
sum -= vals[k]; vals[k] = rng.normal(); sum += vals[k];
out[i] = (sum + rng.normal()) / Math.sqrt(rows + 1);
}
return out;
}
function rmsOf(buf) { let s = 0; for (let i = 0; i < buf.length; i++) s += buf[i] * buf[i]; return Math.sqrt(s / buf.length); }
// Build the performance: true onset times, which events are played/wrong/
// skipped, the accompaniment fire list, and the audio mix.
function synthesise(score, cfg, rng) {
const sr = cfg.sr;
const bps = cfg.bps * cfg.tempoScale;
const rubato = cfg.rubato / 100;
// Rubato is a ±rubato TEMPO modulation over a 16-beat cycle, integrated so
// time stays monotonic: dT/dbeat = (1 − rubato·sin(2π·beat/16)) / bps.
const A = rubato * 16 / (2 * Math.PI);
const timeOfBeat = (beat) => (beat - A * (1 - Math.cos(2 * Math.PI * beat / 16))) / bps;
const tempoAtBeat = (beat) => bps / (1 - rubato * Math.sin(2 * Math.PI * beat / 16));
const solo = normaliseEvents(score.rh);
const acc = normaliseEvents(score.lh);
// Which tone model renders every note below (see RIG / LEGACY_SYNTH above).
const legacy = cfg.synth === 'legacy';
const thumpShare = legacy ? 0 : RIG.thumpShareAttack;
const preRoll = 1.0; // the mic is open before Play: the floors must learn on noise alone
// Session started at a later event (startAt): the prefix is never played
// and the accompaniment starts from that beat, as in the app. The engine is
// seeked there startLead × IOI before the first note (see runEngine).
const startAt = Math.min(Math.max(0, cfg.startAt || 0), solo.length - 1);
const startBeat = startAt > 0 ? solo[startAt].beat : -Infinity;
// A run of notes that are in the piece nowhere: cfg.wrongInsert = [index, n]
// puts n intruders in front of event `index` and pushes the rest of the piece
// — the solo AND the ideal accompanist with it — n beats later. The pitches
// miss every pitch in the score and every pitch a partial of one would land
// on, so the WRONG absorber is the only thing that can explain those frames
// and takes the mass legitimately: the state the real session entered at
// 17.5 s and never left.
//
// They are INSERTED, not substituted, so that when the pianist recovers he is
// playing the very event the engine is already waiting for. Substituting
// score events would also demand a four-event skip on the way back, which is
// what `skipped-note` measures; this scenario is about whether the beam can
// come back at all once the absorber owns the posterior.
const inPiece = new Set();
for (const e of solo) for (const p of e.pitches) for (const d of [0, 12, 19, 24, 28]) inPiece.add(p + d);
for (const e of acc) for (const p of e.pitches) for (const d of [0, 12, 19]) inPiece.add(p + d);
// The nearest pitch to `p` that the piece does not contain — and that the
// engine can actually TELL is not in the piece. Below pitchMinMidi (54) an
// off-score pitch is not off-score to the follower: at 48 kHz/2048 rows 40–44
// are all bin 4, so an A2 played where a G2 is written is the same
// observation and an intruder there would be asking the engine to hear a
// difference that is not in the signal (it cost 18 early advances when this
// scenario first put its intruders at 42/45/46).
const offScore = (p) => {
const q = Math.min(84, Math.max(54, Math.round(p)));
for (let d = 0; d <= 24; d++) for (const s of d ? [1, -1] : [1]) { const m = q + d * s; if (m >= 54 && m <= 84 && !inPiece.has(m)) return m; }
return q;
};
const ins = cfg.wrongInsert || null;
const insBeat = ins ? solo[Math.min(ins[0], solo.length - 1)].beat : Infinity;
const insDelay = ins ? ins[1] / bps : 0;
const shift = (beat) => (beat >= insBeat ? insDelay : 0);
const events = solo.map((e, k) => {
const ioi = k + 1 < solo.length ? timeOfBeat(solo[k + 1].beat) - timeOfBeat(e.beat) : (e.durBeats ?? 1) / bps;
const T = preRoll + timeOfBeat(e.beat) + shift(e.beat) + rng.normal(0, cfg.jitter / 1000);
let kind = 'played', pitches = e.pitches.slice();
if (cfg.wrongEvery && (k + 1) % cfg.wrongEvery === 0 && k > 0) {
kind = 'wrong';
// A fumble must not land on a pitch the score writes NEXT DOOR. Twinkle
// writes B2 at index 9 and C3 at index 10, so the old `p + 1` played
// index 10's own note one beat early and then scored the engine's
// (correct) recognition of it as an early advance. Under the
// fundamental-dominant synth that never bit — a B2 and a C3 arrived as
// nearly the same observation. Under the measured 21-partial comb the
// engine can tell them apart, and the un-guarded mutation cost 1 early
// advance and 12 false ones (recall 1.00 → 0.22) the moment the synth
// became real. `wrongInsert`'s offScore() has carried exactly this guard
// since it was written, for exactly this reason; this is the same rule.
const near = new Set();
for (let d = -1; d <= 2; d++) { const e2 = solo[k + d]; if (e2) for (const p of e2.pitches) near.add(p); }
const order = rng.next() < 0.5 ? [1, 2, -1, -2, 3, -3] : [2, 1, -2, -1, 3, -3];
pitches = pitches.map((p) => { for (const d of order) if (!near.has(p + d)) return p + d; return p + order[0]; });
}
if (cfg.skipEvery && (k + 1) % cfg.skipEvery === 0 && k > 0) { kind = 'skipped'; }
if (cfg.noPlay || k < startAt) kind = 'silent';
const restrike = cfg.doubleStrike && kind === 'played' && rng.next() < 0.25;
const durSec = Math.min(0.95 * ioi, (e.durBeats ?? ioi * bps) / bps);
return { k, beat: e.beat, T, ioi, kind, pitches, expected: e.pitches, durSec, restrike, tempo: tempoAtBeat(e.beat) };
});
const lastEnd = Math.max(...events.map((e) => e.T + e.durSec)) + 1.5;
const n = Math.ceil(lastEnd * sr);
const soloBuf = new Float32Array(n);
for (const e of events) {
if (e.kind === 'skipped' || e.kind === 'silent') continue;
const vel = 0.6 * rng.uniform(0.7, 1.4);
for (const p of e.pitches) renderNote(soloBuf, sr, rng, { midi: p, t0: e.T, noteOffSec: e.durSec, gain: vel, detuneCents: rng.uniform(-3, 3), legacy, thumpShare });
if (e.restrike) for (const p of e.pitches) renderNote(soloBuf, sr, rng, { midi: p, t0: e.T + 0.09, noteOffSec: Math.max(0.05, e.durSec - 0.09), gain: vel * 0.8, detuneCents: rng.uniform(-3, 3), legacy, thumpShare });
}
// The intruders, in the n beats the rest of the piece was pushed back by.
const intruders = [];
if (ins) {
const base = solo[Math.min(ins[0], solo.length - 1)];
for (let j = 0; j < ins[1]; j++) {
const T = preRoll + timeOfBeat(base.beat) + j / bps + rng.normal(0, cfg.jitter / 1000);
// A fumble in the pianist's own register, not down where the filterbank
// gives up: these must be audibly, resolvably not the written note.
const pitches = [offScore(58 + [0, 7, -3, 11, 3, 8][j % 6])];
const durSec = 0.95 / bps;
intruders.push({ T, pitches, durSec });
const vel = 0.6 * rng.uniform(0.7, 1.4);
for (const p of pitches) renderNote(soloBuf, sr, rng, { midi: p, t0: T, noteOffSec: durSec, gain: vel, detuneCents: rng.uniform(-3, 3), legacy, thumpShare });
}
}
// Accompaniment on the true solo timeline of its beat (an ideal accompanist),
// audible bleedLag later; the fire is reported at fireMs, before the loop.
const accBuf = new Float32Array(n);
const fires = [];
for (let k = 0; k < acc.length; k++) {
const a = acc[k];
if (a.beat < startBeat) continue;
const nextBeat = k + 1 < acc.length ? acc[k + 1].beat : a.beat + (a.durBeats ?? 1);
const durSec = (a.durBeats ?? (nextBeat - a.beat)) / bps;
const fireSec = preRoll + timeOfBeat(a.beat) + shift(a.beat);
fires.push({ pitches: a.pitches, fireSec, durSec, beat: a.beat });
// Same speaker, therefore the same R(f) and the same partial-count rule;
// no keyboard thump, because the accompanist is not typing. Level: measured
// median −5.0 dB re the solo note it covers (range −2.2 to −27.3 over 22
// presses), which BASE's bleed 1.0 against the solo's 0.6·uniform(0.7,1.4)
// already brackets — the bleed path is the one part of this bench that
// matched the rig, and it is unchanged.
if (cfg.bleed > 0) for (const p of a.pitches) renderNote(accBuf, sr, rng, { midi: p, t0: fireSec + cfg.bleedLag / 1000, noteOffSec: durSec, gain: 0.6 * cfg.bleed, detuneCents: rng.uniform(-3, 3), legacy });
}
// PHONE SPEAKER NONLINEARITY (`bleedClip`, off unless a scenario asks).
// A memoryless sigmoidal saturation on the accompaniment ONLY — the solo
// reaches the mic through the air from a real piano and never goes through
// our loudspeaker. This is the Hammerstein front end the AEC literature uses
// for hand-held devices (Hofmann et al., EURASIP JASP 2016:113: "increasingly
// common nonlinear distortions produced by miniaturized amplifiers and
// loudspeakers in modern portable devices"), and it is exactly what the
// ICASSP AEC Challenge applies to 80 % of its far-end signals: "clipping the
// maximum amplitude, using a sigmoidal function ... or applying learned
// distortion functions" (Cutler et al., arXiv:2009.04972). Peak-preserving so
// `bleed` stays the level knob and `bleedClip` is purely the drive:
// y = A·tanh(g·x/A)/tanh(g), A = peak|accBuf|, g = cfg.bleedClip
// g = 0 is the identity (bit-exact skip, so every existing scenario is
// untouched). The residual it injects is measured and printed by
// SIM_DEBUG_MIX=1 as `nl` — see the phone-bleed comment for the number.
let nlResidDb = null;
if (cfg.bleedClip > 0) {
let A = 0; for (let i = 0; i < n; i++) A = Math.max(A, Math.abs(accBuf[i]));
if (A > 0) {
const g = cfg.bleedClip;
// Two passes: shape, then restore the RMS the linear signal had, so
// `bleed` stays the only level knob and `bleedClip` is pure drive.
let l2 = 0, y2 = 0;
for (let i = 0; i < n; i++) { const x = accBuf[i], y = Math.tanh(g * x / A); l2 += x * x; y2 += y * y; }
const k = y2 > 0 ? Math.sqrt(l2 / y2) : 1;
let d2 = 0;
for (let i = 0; i < n; i++) {
const x = accBuf[i], y = k * Math.tanh(g * x / A);
d2 += (y - x) * (y - x);
accBuf[i] = y;
}
nlResidDb = l2 > 0 ? 10 * Math.log10(d2 / l2) : null;
}
}
// The playback path. Under the default `rig` synth the measured response
// R(f) is ALREADY in every partial's amplitude, so this chain is off unless a
// scenario asks for it by name: with speakerHpf set it is the explicit
// `speaker-dead-low` variant — a laptop whose low end is deader than the one
// that was measured — and NOT the model of this rig (see RIG note 3). Under
// `--synth legacy` it is the only low-end model there is. Solo and
// accompaniment leave the SAME speaker, so it applies to both (every
// accompaniment pitch here is ≥ 261 Hz, so in practice it only bites the low
// solo notes). Filtered before the mix so the --snr noise is still measured
// against the solo level the mic actually hears; the room noise itself
// reaches the mic directly and is not filtered.
if (cfg.speakerHpf > 0) {
const spS = speakerChain(sr, cfg.speakerHpf, cfg.speakerOrder ?? 4), spA = speakerChain(sr, cfg.speakerHpf, cfg.speakerOrder ?? 4);
for (let i = 0; i < n; i++) { soloBuf[i] = spS.process(soloBuf[i]); accBuf[i] = spA.process(accBuf[i]); }
}
const mix = new Float32Array(n);
for (let i = 0; i < n; i++) mix[i] = soloBuf[i] + accBuf[i];
// SIGNAL-TO-ECHO RATIO, measured rather than asserted. The AEC literature's
// mixing parameter is the SER over DOUBLE-TALK frames only (ICASSP AEC
// Challenge: "a signal to echo ratio uniformly sampled from -10 dB to 10 dB"),
// not over the whole file — the accompaniment's duty cycle is far higher than
// the solo's, so a whole-file ratio would flatter it. Measured here over
// 512-sample hops in which BOTH sources are audibly present, as
// 20·log10(median soloRms / median accRms). Reported so every scenario's
// `bleed` gain can be quoted in the field's own units.
let serDb = null;
{
const H = 512, sHop = [], aHop = [];
for (let o = 0; o + H <= n; o += H) {
let ss = 0, aa = 0;
for (let i = o; i < o + H; i++) { ss += soloBuf[i] * soloBuf[i]; aa += accBuf[i] * accBuf[i]; }
sHop.push(Math.sqrt(ss / H)); aHop.push(Math.sqrt(aa / H));
}
const p90 = (v) => { const q = v.slice().sort((x, y) => x - y); return q.length ? q[Math.min(q.length - 1, Math.floor(0.9 * q.length))] : 0; };
const sOn = 0.1 * p90(sHop), aOn = 0.1 * p90(aHop);
const sD = [], aD = [];
for (let i = 0; i < sHop.length; i++) if (sHop[i] > sOn && aHop[i] > aOn) { sD.push(sHop[i]); aD.push(aHop[i]); }
const med = (v) => { const q = v.slice().sort((x, y) => x - y); return q.length ? q[Math.floor(q.length / 2)] : 0; };
if (sD.length > 8 && med(aD) > 0) serDb = 20 * Math.log10(med(sD) / med(aD));
if (process.env.SIM_DEBUG_MIX) console.log(` mix: bleed ${cfg.bleed} lag ${cfg.bleedLag}ms clip ${cfg.bleedClip} -> SER ${serDb === null ? '-' : serDb.toFixed(1)} dB over ${sD.length}/${sHop.length} double-talk hops (${(100 * sD.length / sHop.length).toFixed(0)}%), nl residual ${nlResidDb === null ? '-' : nlResidDb.toFixed(1)} dB`);
}
// Pink noise at --snr dB below the solo RMS (a silent solo uses the nominal level).
const soloRms = rmsOf(soloBuf) || 0.05;
const noise = pinkNoise(n, rng);
const noiseGain = soloRms / rmsOf(noise) * 10 ** (-cfg.snr / 20);
for (let i = 0; i < n; i++) mix[i] += noiseGain * noise[i];
if (cfg.reverb) { const d = Math.round(0.04 * sr), g = 10 ** (-12 / 20); for (let i = n - 1; i >= d; i--) mix[i] += g * mix[i - d]; }
if (cfg.hpf > 0) { const hp = biquadHighpass(sr, cfg.hpf); for (let i = 0; i < n; i++) mix[i] = hp.process(mix[i]); }
// Keep the mix inside ±1 like a real capture would.
let peak = 0; for (let i = 0; i < n; i++) peak = Math.max(peak, Math.abs(mix[i]));
if (peak > 0.98) { const g = 0.98 / peak; for (let i = 0; i < n; i++) mix[i] *= g; }
const seekSec = startAt > 0 ? events[startAt].T - (cfg.startLead ?? 0.3) * events[startAt].ioi : null;
return { mix, events, fires, intruders, sr, bps, preRoll, startAt, seekSec, serDb, nlResidDb };
}
// ---------------------------------------------------------------------------
// Pipeline (§8.3): identical to the worklet
// ---------------------------------------------------------------------------
const SIM_TRANSPORT_MS = 15; // audio → caller clock (models main-thread delivery)
// The app's role: forward-only, maxJump, ack; optional classic debounce mirror.
class FakeTracker {
constructor(score, opts = {}) {
this.score = score; this.position = 0; this.commits = []; this.rejects = 0;
this.debounce = !!opts.debounce; this.bps = opts.bps || 1;
this._lastTime = null; this._lastBeat = null; this._lastPitch = null;
}
advanceTo(idx, { maxJump, inputPitch }, nowSec) {
if (idx < this.position) { this.rejects++; return null; }
if (idx > this.position + maxJump) { this.rejects++; return null; }
if (idx >= this.score.length) { this.rejects++; return null; }
const ev = this.score[idx];
if (this.debounce && this._lastTime !== null) {
const elapsed = nowSec - this._lastTime;
const beatDelta = Math.max(0, ev.beat - this._lastBeat);
const expected = beatDelta > 0 ? beatDelta / this.bps : 0.18;
const minAdvanceSec = Math.min(0.18, Math.max(0.07, expected * 0.45));
const minRepeatedSec = Math.min(0.34, Math.max(0.12, expected * 0.65));
if (elapsed < minAdvanceSec) { this.rejects++; return null; }
if (this._lastPitch !== null && Math.abs(this._lastPitch - inputPitch) <= 1 && elapsed < minRepeatedSec) { this.rejects++; return null; }
}
this._lastTime = nowSec; this._lastBeat = ev.beat; this._lastPitch = inputPitch;
this.position = idx + 1;
return ev.beat;
}
}
function runEngine(perf, score, cfg, hooks = {}) {
const { mix, events, fires, sr } = perf;
const win = cfg.opt.win ?? 2048, hop = cfg.opt.hop ?? 512;
const engineOpts = { ...cfg.opt };
delete engineOpts.win; delete engineOpts.hop;
const analyzer = new FrameAnalyzer(sr, { win, hop, minMidi: 33, maxMidi: 96, startAt: 0 });
const follower = new ScoreFollower({
solo: score.rh, accompaniment: score.lh, bps: cfg.bps, sampleRate: sr, win, hop,
filterbank: analyzer.filterbank, position: 0,
options: { accompFallback: false, ...engineOpts },
});
const solo = normaliseEvents(score.rh);
const tracker = new FakeTracker(solo, { debounce: cfg.trackerDebounce, bps: cfg.bps * cfg.tempoScale });
const gate = cfg.gate ?? 0.001;
const outputs = []; // { tMs, out } for scoring pWrong etc.
const advances = [];
const resyncs = []; // out.resync: the follower's wide-window jumps (follower.js _resyncScan)
const scans = []; // out.resyncScan: EVERY armed wide scan, fired or refused, with its reason
const holds = [];
const bleeds = [];
const states = [];
const frames = [];
let fireIdx = 0;
const chunk = new Float32Array(128);
let seqCheck = -1, lastToIndex = -1, offered = new Set();
let seeked = perf.seekSec === null;
let seekAudioMs = null; // when the seek below actually fired, so buildTrace can record it (see follow.seek there)
for (let off = 0; off < mix.length; off += 128) {
const nCh = Math.min(128, mix.length - off);
for (let i = 0; i < 128; i++) chunk[i] = i < nCh ? mix[off + i] : 0;
for (const f of analyzer.push(chunk)) {
const audioMs = f.at / sr * 1000;
const nowMs = audioMs + SIM_TRANSPORT_MS;
// Start at a later beat: the app moves the tracker and rebuilds the
// beam there with seek(position, performance.now()) while the engine
// already runs on the open mic. Position 0 never exercises this path
// (the pre-start state is exempt from the future guard).
if (!seeked && audioMs >= perf.seekSec * 1000) { seeked = true; seekAudioMs = audioMs; follower.seek(perf.startAt, nowMs); tracker.position = perf.startAt; }
// Report accompaniment fires whose time has come, exactly as the app does (before the loop delay).
while (fireIdx < fires.length && fires[fireIdx].fireSec * 1000 <= audioMs) {
const fr = fires[fireIdx++];
const atMs = fr.fireSec * 1000 + SIM_TRANSPORT_MS;
follower.accompanimentFired({ pitches: fr.pitches, atMs, durMs: fr.durSec * 1000, beat: fr.beat });
hooks.onFire?.({ pitches: fr.pitches, beat: fr.beat, atMs, durMs: fr.durSec * 1000, audioMs: fr.fireSec * 1000 });
}
const frame = {
type: 'frame', seq: f.seq, at: f.at, sr, win: f.win, hop: f.hop, rms: f.rms, db: f.db, floorDb: f.floorDb,
flux: f.flux, onset: f.onset, hfc: f.hfc, sal: f.sal, peak: f.peak, noiseFloor: null, gated: f.rms < gate, attack: false,
};
if (cfg.dumpSal) console.log(`${(audioMs / 1000).toFixed(3)}s db=${f.db.toFixed(1)} floor=${f.floorDb.toFixed(1)} onset=${f.onset.toFixed(2)} peak=${f.peak}`);
const out = follower.observe(frame, nowMs);
// §3.3 guarantees, asserted.
if (out.seq !== frame.seq) throw new Error('output.seq mismatch');
if (seqCheck >= 0 && out.seq !== seqCheck + 1) throw new Error('seq gap in sim');
seqCheck = out.seq;
// RESYNCHRONISATION (follower 2026-09-16.3). Its own emission, never
// counted as a detection: the app moves the tracker with the engine's
// OWN explicit maxJump (MAX_ACOUSTIC_EVENT_JUMP = 1 would refuse it) and
// then seeks the follower, which is what _acceptOffer does internally on
// the ack. Modelled here so `--replay` measures the same behaviour the
// app will produce.
if (out.resyncScan) scans.push({ ...out.resyncScan, tMs: out.tMs, audioMs });
if (out.resync) {
const r = out.resync;
const beat = tracker.advanceTo(r.toIndex, { maxJump: r.maxJump, inputPitch: r.pitches[r.pitches.length - 1] }, audioMs / 1000);
follower.ack(r.id, beat !== null);
if (beat !== null) { offered.add(r.toIndex); lastToIndex = r.toIndex; }
resyncs.push({ ...r, accepted: beat !== null, tMs: out.tMs, audioMs: out.tMs - SIM_TRANSPORT_MS });
}
if (out.advance) {
const adv = out.advance;
if (adv.toIndex < tracker.position) throw new Error(`advance.toIndex ${adv.toIndex} < position ${tracker.position}`);
if (offered.has(adv.toIndex)) throw new Error(`toIndex ${adv.toIndex} offered twice without a refused ack`);
const beat = tracker.advanceTo(adv.toIndex, { maxJump: adv.maxJump, inputPitch: adv.pitches[adv.pitches.length - 1] }, audioMs / 1000);
follower.ack(adv.id, beat !== null);
if (beat !== null) offered.add(adv.toIndex);
advances.push({ ...adv, accepted: beat !== null, tMs: out.tMs, audioMs: out.tMs - SIM_TRANSPORT_MS, mode: out.mode, heardMidi: adv.heardMidi });
lastToIndex = adv.toIndex;
} else if (out.hold) holds.push({ ...out.hold, tMs: out.tMs, pos: out.position });
if (out.state) states.push({ ...out.state, tMs: out.tMs, pos: out.position, p: out.best?.p ?? null });
// The watchdog moved off out.state (it was overwritten by BACKTRACK and
// by the mode change); keep it visible in the states column.
if (out.recovered) states.push({ mode: 'RECOVER', reason: out.recovered.reason, tMs: out.tMs, pos: out.position, p: out.best?.p ?? null });
if (out.bleed) bleeds.push({ ...out.bleed });
outputs.push({ tMs: out.tMs, audioMs, pWrong: out.pWrong, best: out.best, informative: out.informative });
frames.push({ frame, out });
hooks.onFrame?.(frame, out, follower);
}
}
return { follower, tracker, advances, resyncs, scans, holds, bleeds, states, outputs, frames, events, lastToIndex, seekAudioMs };
}
// ---------------------------------------------------------------------------
// Scoring (§8.4)
// ---------------------------------------------------------------------------
const quantile = (arr, q) => {
if (!arr.length) return null;
const a = arr.slice().sort((x, y) => x - y);
const pos = (a.length - 1) * q, lo = Math.floor(pos), hi = Math.ceil(pos);
return a[lo] + (a[hi] - a[lo]) * (pos - lo);
};
const EARLY_MS = 60; // a commit this far before the true onset is an advance AHEAD of the pianist
// A wide-window jump may land this many events BEHIND the pianist's true
// event and no more (see resyncBad in scoreRun for the measurement this comes
// from). THE TOLERANCE IS ONE-SIDED, and the asymmetry is the whole point: a
// jump that lands short is a catch-up the next ordinary advance closes, while
// a jump that lands on an event the pianist has NOT BEGUN is the accompaniment
// playing a note nobody played -- the one direction `early` forbids for
// ordinary advances, and the one the comment above resyncBad says was never
// observed. A symmetric assertion passed exactly that: on `dense-drift
// --seed 1` the jump 13 -> 62 at t 34293 ms, with the pianist's last begun
// event 61 and event 62 starting 161 ms later, read `resyncBad 0` while
// directly causing the run's only `early` (event 64 committed 235 ms before
// its onset, 448 ms after the jump).
const RESYNC_LAND_EVENTS = 1;
// Drift the engine is allowed to carry without it counting as an interval of
// being behind: 1 is what perfect tracking reads, 2 is one note of slack for a
// commit that lands after the next onset — which at this bench's 125 ms fastest
// decile is unavoidable and is not the failure being measured.
const DRIFT_OK = 2;
function scoreRun(res, perf) {
const events = perf.events;
const played = events.filter((e) => e.kind === 'played');
const hits = new Map();
const falses = [];
const latencies = [], onsetErrs = [], tempoErrs = [];
for (const adv of res.advances) {
if (!adv.accepted) continue;
const e = events[adv.toIndex];
const tCommit = adv.audioMs / 1000;
const inWindow = e && e.kind === 'played' && tCommit >= e.T - 0.05 && tCommit <= e.T + 0.4 && !hits.has(adv.toIndex);
if (inWindow) {
hits.set(adv.toIndex, adv);
latencies.push((tCommit - e.T) * 1000);
onsetErrs.push(adv.onsetAtMs - SIM_TRANSPORT_MS - e.T * 1000);
tempoErrs.push(adv.bps - e.tempo);
} else falses.push(adv);
}
const missed = played.filter((e) => !hits.has(e.k));
// Early advances: any accepted commit more than EARLY_MS before the true
// onset of the event it names, whatever that event's kind. A subset of
// `false`, counted separately because it is the one failure the follower
// exists to eliminate (the accompaniment moving ahead of the pianist) and
// no scenario may tolerate even one of them, including those whose `false`
// target allows a stray late or mis-indexed commit.
const early = res.advances.filter((a) => a.accepted && events[a.toIndex] && a.audioMs / 1000 < events[a.toIndex].T - EARLY_MS / 1000);
// Resync after skips: a hit on one of the next two played events.
let resyncFails = 0;
for (const e of events) if (e.kind === 'skipped') {
const after = events.filter((x) => x.k > e.k && x.kind === 'played').slice(0, 2);
if (after.length && !after.some((x) => hits.has(x.k))) resyncFails++;
}
// EVERY SCENARIO, not only the ones whose target names resync: a wide-window
// jump may only land where the pianist actually is. The Study phase measured
// the search on the real Haydn audio at the shipped 1.5-nat bar — 97 % of
// fires landed exactly, 100 % within one event, 0 % landed AHEAD of the
// player — and on the reference Twinkle every fire was a distance-1 jump the
// ordinary rule would have made anyway. +-1 event is therefore the band the
// mechanism is CLAIMED to hold, so anything outside it is a jump the engine
// should not have made, whatever it does to recall. This is the resync's
// counterpart to `early` and it fails every scenario the same way.
//
// `trueK` is the last event that had BEGUN when the scan fired — including
// wrong and skipped ones, because the pianist's hands are past them either
// way. A scenario in which nothing has been played yet (no-play) leaves it
// null and any accepted jump at all is a failure.
// How far the wide search's top-1 was from where the pianist actually was, on
// EVERY armed scan — fired or refused. This is the calibration number behind
// `resyncMarginNats`: a suite in which the refused scans were right is a
// suite whose bar is too high, and one in which the fired scans were wrong is
// a precision bug. Reported alongside the refusal histogram.
const scanAtTrue = (t) => { let k = null; for (const e of events) if (e.kind !== 'silent' && e.T <= t) k = e.k; return k; };
const scanErrs = [], scanErrsFired = [];
for (const sc of res.scans || []) {
const k = scanAtTrue(sc.audioMs / 1000);
if (k === null || sc.toIndex < 0) continue;
scanErrs.push(sc.toIndex - k);
if (!sc.refused) scanErrsFired.push(sc.toIndex - k);
}
const resyncBad = [];
for (const r of res.resyncs || []) {
if (!r.accepted) continue;
const t = r.audioMs / 1000;
let trueK = null;
for (const e of events) if (e.kind !== 'silent' && e.T <= t) trueK = e.k;
const err = trueK === null ? null : r.toIndex - trueK;
if (err === null || err > 0 || err < -RESYNC_LAND_EVENTS) resyncBad.push({ to: r.toIndex, at: Math.round(r.audioMs), trueK, err });
}
// DRIFT — the metric the Haydn failure was measured in (Study phase): how many
// events the pianist is ahead of the engine's committed position, sampled at
// every played onset. An engine tracking perfectly reads 1, because event k's
// commit lands DURING event k and at k's onset the engine still holds k-1; the
// real Haydn session read 8 at t=3.6 s, 57 at t=41 s and 98 at t=82 s and
// never came back. `driftRunSec` is the longest unbroken stretch of time spent
// above DRIFT_OK, i.e. how long the accompaniment is left playing music from
// somewhere else — the quantity the user heard as "completely disorganized".
// Resyncs count towards the committed position exactly as advances do: it is
// the accompaniment's position that matters, not how it got there.
const moves = [...res.advances, ...(res.resyncs || [])].filter((a) => a.accepted)
.map((a) => ({ t: a.audioMs / 1000, to: a.toIndex })).sort((a, b) => a.t - b.t);
// A session that starts at a later event (startAt) has the tracker SEEKED
// there rather than advanced there, so the seek is not in `moves` and the
// prefix it skipped is not drift — start-late read 8 before this line.
let driftMax = 0, driftRunSec = 0, driftFrom = null, mi = 0, committedIdx = (perf.startAt || 0) - 1;
for (const e of played) {
while (mi < moves.length && moves[mi].t <= e.T) committedIdx = Math.max(committedIdx, moves[mi++].to);
const d = e.k - committedIdx;
driftMax = Math.max(driftMax, d);
if (d > DRIFT_OK) { if (driftFrom === null) driftFrom = e.T; driftRunSec = Math.max(driftRunSec, e.T + e.durSec - driftFrom); }
else driftFrom = null;
}
// pWrong while a note the score does not contain is sounding — substituted
// (wrongEvery) or inserted (wrongInsert) alike.
const pWrongs = [];
const wrongWins = [
...events.filter((e) => e.kind === 'wrong').map((e) => [e.T * 1000 + 100, e.T * 1000 + 0.9 * e.ioi * 1000]),
...(perf.intruders || []).map((e) => [e.T * 1000 + 100, (e.T + 0.9 * e.durSec) * 1000]),
];
for (const [a, b] of wrongWins) for (const o of res.outputs) if (o.informative && o.audioMs >= a && o.audioMs <= b) pWrongs.push(o.pWrong);
// Longest unbroken run of SOUND in which the WRONG absorber owned the
// posterior. This is the quantity the engine's wrongDwellMs watchdog counts,
// so it is the only way to tell from the outside whether a scenario reaches
// it: in 20260915-081347 the longest legitimate run was 854 ms and the
// failure was 49 523 ms, and a scenario that never passes wrongDwellMs is not
// testing recovery however high its median pWrong looks.
let pWrongRunMs = 0, runMs = 0, runPrev = null;
for (const o of res.outputs) {
if (!o.informative) continue;
if (o.pWrong >= 0.5) { runMs += runPrev === null ? 0 : o.audioMs - runPrev; pWrongRunMs = Math.max(pWrongRunMs, runMs); } else runMs = 0;
runPrev = o.audioMs;
}
// Longest stall: the longest stretch in which the pianist is playing notes the
// engine is supposed to be following and NO advance is accepted.
//
// The old form — the largest gap between consecutive hits — could not see the
// failure this metric exists for. It only measured intervals that ENDED in a
// hit, so the real session (20260915-081347: 28 keys and zero advances over
// the last 45 s, the engine dead and never recovering) would have scored its
// stall from the last hit it ever made and reported nothing. Here the
// timeline is split at accepted advances and each segment is measured from
// the first played onset still waiting to the end of the last one, or to the
// advance that ended the wait — so a segment that never ends is the whole
// remaining piece, which is what a dead engine deserves.
//
// Wrong and skipped events are not counted as waiting: holding through a note
// the score does not contain is correct behaviour, not a stall. What that
// leaves in a wrong-note scenario is exactly the recovery time — the gap from
// the first CORRECT note after the run to the advance that re-locks on it.
const advT = res.advances.filter((a) => a.accepted).map((a) => a.audioMs / 1000).sort((a, b) => a - b);
let stall = 0, segFrom = -Infinity;
for (const bound of [...advT, Infinity]) {
const waiting = played.filter((e) => e.T > segFrom && e.T < bound);
if (waiting.length) {
const last = waiting[waiting.length - 1];
stall = Math.max(stall, Math.min(bound, last.T + last.durSec) - waiting[0].T);
}
segFrom = bound;
}
const holdsByReason = {};
for (const h of res.holds) holdsByReason[h.reason] = (holdsByReason[h.reason] || 0) + 1;
return {
played: played.length, hits: hits.size, recall: played.length ? hits.size / played.length : 1,
false: falses.length,
// `lateMs` is how far past the named event's own onset the commit landed.
// On a dense score most of the `false` list is not a mis-indexed commit at
// all but the RIGHT event claimed after the 400 ms hit window had closed —
// the same failure `drift` measures, seen from the other end. Printed so
// the two can be told apart without re-running.
falseList: falses.map((a) => ({ to: a.toIndex, at: Math.round(a.audioMs), lateMs: events[a.toIndex] ? Math.round(a.audioMs - events[a.toIndex].T * 1000) : null, kind: events[a.toIndex]?.kind, repeatLike: a.repeatLike, ev: +a.ev.toFixed(2), p: +a.p.toFixed(2) })),
missed: missed.length, missedList: missed.map((e) => e.k),
early: early.length, earlyList: early.map((a) => ({ to: a.toIndex, at: Math.round(a.audioMs), aheadMs: Math.round(events[a.toIndex].T * 1000 - a.audioMs), mode: a.mode })),
latencyMedian: quantile(latencies, 0.5), latencyP90: quantile(latencies, 0.9),
onsetErrMedian: quantile(onsetErrs, 0.5), onsetErrP90: quantile(onsetErrs, 0.9),
tempoRmse: tempoErrs.length ? Math.sqrt(tempoErrs.reduce((s, v) => s + v * v, 0) / tempoErrs.length) : null,
rejects: res.tracker.rejects, stall, pWrongMedian: quantile(pWrongs, 0.5), pWrongRunMs, resyncFails, holdsByReason,
resyncs: (res.resyncs || []).filter((r) => r.accepted).length, resyncBad: resyncBad.length, resyncBadList: resyncBad, driftMax, driftRunSec,
scans: (res.scans || []).length, scansByReason: (res.scans || []).reduce((a, x) => (a[x.refused || 'fired'] = (a[x.refused || 'fired'] || 0) + 1, a), {}),
scanMarginMed: quantile((res.scans || []).map((x) => x.marginNats).filter((v) => v !== null && Number.isFinite(v)), 0.5),
scanMarginP90: quantile((res.scans || []).map((x) => x.marginNats).filter((v) => v !== null && Number.isFinite(v)), 0.9),
scanLeadMed: quantile((res.scans || []).map((x) => x.leadNats).filter((v) => v !== null && Number.isFinite(v)), 0.5),
scanDistMed: quantile((res.scans || []).map((x) => x.distance), 0.5),
scanErrMed: quantile(scanErrs, 0.5), scanErrAbs1: scanErrs.length ? scanErrs.filter((e) => Math.abs(e) <= 1).length / scanErrs.length : null,
scanErrAbs2: scanErrs.length ? scanErrs.filter((e) => Math.abs(e) <= 2).length / scanErrs.length : null,
// "did a jump that ACTUALLY FIRED pick a place the pianist had not
// reached?" — over the fired scans only, and at e > 0, not e > 1. Over all
// scans and at e > 1 it printed `ahead 0` for the dense-drift seed 1 run
// whose accepted jump to event 62 preceded that event's onset by 161 ms,
// i.e. it read 0 on exactly the run it exists to catch.
scanErrAhead: scanErrsFired.filter((e) => e > 0).length, scanErrFiredList: scanErrsFired,
bleedLagMedian: quantile(res.bleeds.map((b) => b.lagMs), 0.5), states: res.states.map((s) => `${s.mode}@${Math.round(s.tMs)}`),
};
}
// ---------------------------------------------------------------------------
// Scenarios and targets (§8.4)
// ---------------------------------------------------------------------------
// `synth: 'rig'` is the measured laptop-speaker/laptop-mic model (see RIG);
// `'legacy'` is the pre-2026-09-15 8-partial h^-1.3 tone, kept for audit only.
const BASE = { bleed: 1.0, snr: 30, hpf: 0, jitter: 25, rubato: 8, bleedLag: 58, bleedClip: 0, tempoScale: 1.0, score: null, synth: 'rig' };
// 2026-09-15 RE-BASELINE against the measured rig synth (see RIG above).
// Nothing in the engine changed and no target below was touched: the synth
// stopped being a fundamental-dominant 8-partial piano and became the
// 21-partial comb this laptop speaker actually produces, so every number moved.
// `--synth legacy` reproduces the "before" column below exactly, press for
// press (the legacy path draws the same random numbers in the same order), so
// any line here can be re-checked in one command.
//
// BEFORE (legacy synth) RIG SYNTH, engine .13 RIG SYNTH, engine .14
// clean 0.96 f0 e0 lat 68/90 ok 0.98 f0 e0 lat 75/152 ok 0.98 f0 e0 ok
// bleed 1.00 f0 e0 stall 0.14 ok 0.87 f0 e0 stall 3.90 FAIL 0.98 f0 e0 ok
// bleed-loud 0.98 f0 e0 ok 0.00 f21 e0 stall 5.84 FAIL 0.91 f0 e0 ok
// noisy 0.98 f0 e0 ok 0.89 f0 e0 ok 0.98 f0 e0 ok
// no-fundamental 1.00 f0 e0 ok 0.93 f0 e0 ok 0.93 f0 e0 ok
// double-strike 0.98 f0 e0 ok 0.96 f0 e0 ok 0.96 f0 e0 ok
// wrong-note 1.00 f0 e0 ok 1.00 f0 e0 ok 1.00 f0 e0 ok (bench bug fixed, see below)
// skipped-note 1.00 f0 e0 ok 0.95 f1 e0 FAIL 0.92 f2 e0 FAIL
// repeated-notes 1.00 f0 e0 ok 0.92 f0 e0 ok 0.92 f0 e0 ok
// no-play – f0 e0 ok – f0 e0 ok – f0 e0 ok
// fast 0.93 f0 e0 ok 0.91 f1 e0 ok 0.96 f0 e0 ok
// rubato-heavy 1.00 f0 e0 ok 0.96 f0 e0 ok 0.96 f0 e0 ok
// bleed-lag-sweep 0.98 f0 e0 ok 0.91 f1 e1 FAIL 0.91 f1 e1 FAIL
// sr44 0.91 f0 e0 ok 0.89 f0 e0 FAIL 0.91 f0 e0 ok
// start-late 0.97 f0 e0 stall 1.12 ok 0.87 f0 e0 stall 5.99 FAIL 0.93 f0 e0 ok
// low-note-weak 0.93 f0 e0 ok 0.86 f1 e0 FAIL 0.89 f0 e0 ok
// low-note-lag-over 0.89 f0 e0 ok 0.89 f0 e0 ok 0.89 f0 e0 ok
// wrong-lock-recovery 0.98 f0 e0 ok 0.98 f0 e0 ok 0.98 f0 e0 ok
// (seed 1 throughout; f = false, e = early. No target was changed in either
// step. Seed 2 under engine .14: everything ok except double-strike 0.91 f1
// and start-late 0.95 f1, both of which read f1 under engine .13 as well.)
//
// ENGINE .16 IS BEHAVIOUR-IDENTICAL TO .15 on this bench, line for line — it
// only adds FOLLOWER_DEFAULTS.rigResponse, which ships null. Re-measured after
// that change, so the seed-2 column above has current numbers next to it:
// bleed-loud seed 1 0.91 f0 ok seed 2 0.83 f1 FAIL (recall < 0.85 too)
// double-strike seed 1 0.96 f0 ok seed 2 0.93 f1 FAIL
// skipped-note seed 1 0.92 f2 FAIL seed 2 1.00 f0 ok
// All five of those `false`s are LATE commits of notes the player really did
// play — +414, +483, +994 and +2017 ms against the scorer's +400 ms window
// (scoreRun) — and `early` is 0 in every one of them. None of them is the
// accompaniment moving ahead of the pianist; they are the follower catching up
// out of a stall, and two of the four are within 20 % of the bar. The two
// cold-start ones (skipped-note #0, bleed-loud #1 after #0 is lost) are the
// index-0 collapsed-fundamental story already named above, still unsolved.
//
// THE ENGINE STEP (follower 2026-09-15.14) is two changes, both of which this
// bench is what found:
// * the ACCOMPANIMENT's template now runs to the top of the bank
// (FOLLOWER_DEFAULTS upperWeight, _extWeights). Its six-partial truncation
// was the same bug as the solo one and it bites harder, because the
// accompaniment is an octave above the solo and therefore covers the solo
// note's EVEN partials: what identifies a C3 under a C4 is C3's odd
// partials, and from the 7th up neither template could describe them. In
// bleed-loud seed 1 the bleed explained 0.91-0.97 of every frame with
// index 0's own share at 0.00 and the piece never locked.
// * the future guard (§ 'nothing committed ahead of the newest evidence')
// now measures from the onset the last ACCEPTED commit asserted, not from
// whichever hypothesis at the committed index is heaviest. The pre-start
// state spawns a rival for the committed event on the NEXT note's onset;
// with the rig synth it regularly outweighed the committed state, so the
// guard read "event j is a whole IOI away" and refused the child that
// onset belonged to (bleed seed 1: index 1 at p 0.98, ev 6, held
// 'future' with ahead 984 / span 1000 for a second; indices 1-4 lost).
// The rule is unchanged and `early` is still 0 everywhere it was.
//
// WHAT THE ENGINE STEP DID NOT FIX, stated plainly: the SOLO template is
// still truncated at six partials, so the G2 stall the replay was built to
// measure is still there — the replay reads the same 28 hits it did before
// (at a better latency: p90 339 -> 336 ms, index 0 committed +178 ms instead
// of +371 ms). Every way of extending the solo side was measured and every
// one costs more than it buys; the numbers are in FOLLOWER_DEFAULTS above
// `harmonicWeights`, and 2026-09-15.15 adds three more measured dead ends
// there as off-by-default knobs (soloUpperFrom, xTilt, wrongPartialMax) so the
// seventh attempt is a one-line sweep rather than a rebuild. One of them is
// not quite a dead end and is worth knowing about: `--opt wrongPartialMax=6`
// bars WRONG from the 6th partial of an expected pitch and DOES commit the
// index-12 G2 that no shipped build has committed — it just cannot be scored
// on this recording, for the reason under the `--replay` line below.
// skipped-note and bleed-lag-sweep were the two scenarios
// still red. bleed-lag-sweep is now GREEN (0.91 f1 e1 -> 0.93 f0 e0): its
// `early` turned out NOT to be the collapsed fundamental but one onset
// claiming two events, and two hold-only gates fixed it — see the scenario's
// own comment. skipped-note is still red at f2 (index 43 as well as index 0),
// the bleed tail's cost, declared rather than hidden; index 0 is the
// still-unsolved first-note-in-LISTENING collapsed fundamental. Measured, the
// tail is worth its one false: `--opt upperWeight=0` takes skipped-note to
// 0.95 f1 but bleed-loud seed 1 to 0.00 with 21 false, so removing it from
// the commit path is a net loss and was not taken.
//
// The `--replay` line on the real session: replay bypasses the SYNTH, not the
// ENGINE — it re-runs the recorded frames through the live follower, so hits
// and latency can and did move with the .14 step (see p90 339 -> 336 above).
// What is untouched is what the contract asks for, `hits 28` and `false 0`:
// keys 56 hits 28 recall 0.50 false 0 latency med 188 p90 336 ms.
//
// READ THE SECOND LINE BEFORE THE FIRST. 2026-09-15.15 makes `--replay` print
// the CEILING of its own metric, and on this session it is 30 of 56 keys (35
// under the ±12 rule the scorer actually uses). "recall 0.50" was never 0.50
// of what is achievable: 24 of the 28 unscored keys are G2 presses made where
// the score asks for C3 or B2, and no follower may advance on those. The
// headroom this recording can demonstrate is two keys. Five solo-template
// rewrites were reverted on differences of five and eleven hits inside that
// band — see replayCeiling for what those differences actually are.
//
// WHAT MOVED, and why. Attributed by rebuilding each scenario with ONE part of
// the new synth switched off:
//
// * The UPPER TAIL is what costs recall. Capping partials at 784 Hz (the old
// h8 of a G2) and changing nothing else restores bleed 0.87 → 0.98 and
// bleed-loud 0.00 → 0.91. That is the shipped 6-partial template failing to
// explain rows 74–96 and the WRONG absorber taking them — the exact
// real-session pathology, which no scenario could reproduce before because
// no synthetic note had anything up there. These are NOT regressions of the
// engine; they are the bench finally containing the failure it was built to
// measure, and the engine genuinely cannot do it yet.
// * The FUNDAMENTAL DRAW costs the first note. bleed-lag-sweep's `early 1`
// needs BOTH the tail and the draw: with either switched off it goes back to
// 0. It is index 0's C3 arriving with a collapsed fundamental in LISTENING
// mode, read as index 1's G3 (C3's h3/h6/h9 are G3's h2/h4/h6) and committed
// 585 ms early. Same story for skipped-note (index 0 committed 1.0 s late,
// scored false) and for bleed-loud on seed 1, where index 0 is lost and the
// whole piece then runs ~6 s late; seeds 2 and 3 of bleed-loud give 0.98 and
// 0.91. A press that loses its fundamental is measured, not invented
// (MIDI 43: p25 −18.8 dB re the strongest partial over 16 presses), and no
// scenario produced one before.
// * start-late and sr44 lose their margin, not their behaviour: start-late
// stalls 5.99 s across indices 11–18 (against a 5 s bound) and sr44 misses
// five instead of four. Both are the same thin-margin story.
// * wrong-note's collapse was a BENCH BUG, and it is fixed above rather than
// re-baselined: `wrongEvery` perturbed a pitch by +1/+2 with no guard, so at
// index 9 it played index 10's own written C3. The fundamental-dominant
// synth could not tell B2 from C3 and nothing happened; the measured comb
// can, and the engine's correct recognition scored as 1 early and 12 false
// advances (recall 1.00 → 0.22). With the guard the scenario reads 1.00 f0
// e0 under BOTH synths. `wrongInsert` has always had that guard.
//
// NOT re-baselined, and worth saying plainly: the bench is still 25–45 dB
// cleaner than the rig in the only domain the engine sees. Per-row note-to-floor
// on a synthetic G2 is 40–63 dB against the rig's measured 3–18 dB, so the
// whitening's (sal/2·floor − 1)^0.5 barely bites and the simulated G2's
// WHITENED peak is still its own h3 (row 62) where the rig's is row 89. The RAW
// (post − pre) spectrum now matches the rig to 5.9 dB rmse over h ≥ 2 — the
// same order as the profile's own model rmse of 5.12 dB — and peaks on row 62
// exactly as the rig does, which is what makes a 7..21-partial template
// testable here at all. Lowering --snr does not fix the whitened domain
// (measured at 10, −5 and −17 dB: it flattens the whole spectrum toward the
// noise instead of reshaping it). The cause is that this bench has real silence
// between notes and a dry room, so its min-statistics floor falls back to the
// pink noise in every gap, while the rig's floor rides on a continuously
// sounding speaker: the rig's own session-mean salience is only 3.4 dB above
// its floor at row 43 and 10.3 dB at row 89. That is a FOURTH bench error, one
// the phase-1 profile's "keep pink at the current SNR" did not catch, and it is
// the next thing to fix here.
const SCENARIOS = {
'clean': { mut: { bleed: 0, snr: 40, rubato: 0 }, tgt: { recall: 0.95, false: 0, latMed: 120, latP90: 250 } },
// 2026-09-15: 1.00 → 0.87 under the rig synth, FAILING its 0.90 — capping
// the synth's partials at 784 Hz restored 0.98, so the loss was the engine's
// 6-partial template against the measured tail. Engine work, and it was
// done: follower 2026-09-15.14 reads 0.98 f0 e0 (seed 2: 0.20 f14 → 0.98).
// The target is untouched.
'bleed': { mut: {}, tgt: { recall: 0.90, false: 0, latMed: 150 } },
// 2026-09-15: 0.98 → 0.00 with 21 false on seed 1 under the rig synth, where
// index 0's C3 draws a collapsed fundamental, is never committed, and the
// piece runs ~6 s late from there. Seeds 2 and 3 read 0.98 and 0.91; capping
// partials at 784 Hz gave 0.91 on seed 1. Tail + first-note collapse
// together — and this scenario is what identified the ACCOMPANIMENT's
// truncated template as the larger half of the tail problem (the bleed
// explained 0.91-0.97 of every frame while index 0's own share was 0.00).
// follower 2026-09-15.14 reads 0.91 f0 e0. The target is untouched.
'bleed-loud': { mut: { bleed: 2.0 }, tgt: { recall: 0.85, false: 0 } },
// -------------------------------------------------------------------------
// phone-bleed / phone-bleed-lag-wrong — THE MOBILE CASE (added 2026-09-16).
// BOTH ARE RED TODAY, ON PURPOSE. They are the acceptance test for the
// mobile bleed work, not a picture of something that already passes.
//
// WHY THEY EXIST. Every bleed scenario above models a LAPTOP: the
// accompaniment leaves a laptop speaker, crosses a room, and arrives at a
// laptop mic 58–130 ms later at a level BELOW the solo. On a phone or tablet
// the speaker and the microphone are inches apart in one body, so the
// accompaniment arrives LOUDER than the soloist and almost immediately, and
// partly through the case rather than only through the air. The product
// direction is webapp first, phone second, and nothing here measured that.
//
// EVERY NUMBER, AND WHERE IT COMES FROM.
//
// bleed 3.0 -> MEASURED SER −6.4 dB (SIM_DEBUG_MIX=1, over the 94 % of hops
// in which both sources sound). The field's mixing parameter is the
// signal-to-echo ratio over DOUBLE-TALK frames, and the ICASSP 2021/2022
// AEC Challenges sample it "uniformly ... from -10 dB to 10 dB" over
// 10 000+ real devices (Cutler et al., arXiv:2009.04972, arXiv:2202.13290).
// −6.4 dB is inside that range and on its adverse half, which is where the
// 2022 challenge puts mobile: it reports mobile scoring 0.75 MOS BELOW
// desktop in the double-talk degradation category — its largest gap of any
// category — "because in mobile devices, the loudspeaker is closer to the
// microphone, so the signal-to-echo ratio in these devices is lower on
// average". For calibration the existing scenarios measure SER +3.7 dB
// (`bleed`) and −2.3 dB (`bleed-loud`); +3.7 dB matches the rig's own
// "median −5.0 dB re the solo note it covers" to within 1.3 dB, which is
// what validates the metric. phone-bleed is therefore 10.1 dB louder than
// `bleed` and 4.1 dB louder than `bleed-loud`.
//
// bleedLag 6 ms -> the direct + structure-borne path, NOT a room path. A
// phone's speaker-to-mic spacing is 1–15 cm = 0.03–0.44 ms of air, and the
// case-borne path is faster still; what is left is the platform's own
// output buffer, which the traces bound at meta.latency.measuredOutputMs
// 18.3–33.3 ms. 6 ms is the coupling alone (the app's fire report already
// buffer-compensated); the buffer-inclusive case is covered by the 30 ms
// row of the ladder below. Do NOT confuse this with the challenge's "RT60
// ranges from 200 ms to 1200 ms" — that is the reverb TAIL, a different
// quantity from the direct-path delay the scalar `bleedLagMs` models.
//
// bleedClip 2 -> MEASURED distortion residual −19.4 dB re the linear
// accompaniment (10.7 % RMS): "mild", and applied to the accompaniment
// only, since the solo reaches the mic from a real piano and never passes
// through our loudspeaker. Small loudspeakers are strongly nonlinear
// (Hofmann et al., EURASIP JASP 2016:113) and the AEC Challenge applies a
// nonlinearity to 80 % of its far-end signals, naming "clipping the maximum
// amplitude, using a sigmoidal function ... or applying learned distortion
// functions"; the sigmoid is the one used here. --bleed-clip 3 and 5 give
// −15.1 and −10.9 dB. The shaping is RMS-preserving, so `bleed` and
// `bleedClip` are orthogonal (SER moves 1.0 dB from clip 0 to clip 5).
//
// WHAT THE SHIPPED ENGINE ACTUALLY DOES, MEASURED (engine 2026-09-16.10).
//
// 1. THE NONLINEARITY IS FREE. At a level the engine survives (bleed 1.0,
// lag 6, engine seeded 6) clip 0 / 2 / 5 all read 0.98 (45/46) f0 e0 —
// identical hits, only p90 latency moves (128/115/107 ms). That is the
// predicted result and not a lucky one: a memoryless nonlinearity driven
// by a harmonic tone produces mostly that tone's OWN harmonics, and the
// bleed template is already a harmonic comb on those rows. What a comb
// cannot cover is intermodulation between two simultaneous accompaniment
// pitches, which this scenario does contain and which is evidently small.
// Nonlinear-AEC work is therefore NOT where the mobile effort should go.
//
// 2. THE LEVEL IS THE KILLER, AND IT IS A CLIFF, NOT A SLOPE. Level ladder
// at the room lag 58 with the engine seeded truthfully, seed 1:
// bleed 2.00 SER −2.3 dB recall 0.91 (42/46) f0 e0
// bleed 2.05 SER −2.5 dB recall 0.00 ( 0/46) f10 stall 9.80s
// bleed 2.25 SER −3.3 dB recall 0.00 ( 0/46) f18 stall 7.29s
// bleed 3.00 SER −5.8 dB recall 0.00 ( 0/46) f11 stall 12.08s
// ONE DECIBEL between 0.91 and 0.00. Seed 2 puts the cliff further out
// (bleed 2.25 -> 1.00 f0; bleed 3.00 -> 0.89 f1), so the cliff's position
// is the first-note draw and its existence is not. Read the seed-1 0.00
// correctly: `early` is 0 in EVERY cell, so the engine never advances
// ahead of the pianist. Index 0 is lost, the piece then runs 5.7–13.0 s
// late, and the bench scores those late commits as `false`. This is the
// cold-start cascade the `bleed`/`wrong-note` comments above already
// describe, now reachable 10 dB earlier.
//
// 3. THE SHORT DELAY IS GENUINELY HARDER THAN THE ROOM DELAY AT THE SAME
// LEVEL. Phone mix, seed 2, engine seeded 58, estimator live:
// true lag 110 ms (room) recall 1.00 (46/46) f0 estimate 131 ms
// true lag 30 ms recall 0.87 (40/46) f0 estimate 46 ms
// true lag 6 ms (phone) recall 0.85 (39/46) f1 estimate 25 ms
// Same SER, 0.15 recall and the first false bought purely by collapsing
// the loop delay. Two reasons, both structural: the estimate can never
// reach 6 ms because follower.js:4880 folds samples in as
// clamp(sample, 20, 250) — 20 ms is a FLOOR, and 25 ms is where the EMA
// settles from any seed — and at a 6 ms loop the accompaniment's onset and
// the soloist's onset land in the SAME frame, so the
// bleedOnsetWindowMs [−30, 90] suppression around atMs + 25 ms covers
// −5…+115 ms after the fire, which is exactly where the soloist's own
// attack lives when both are on the beat.
//
// 4. ON A PHONE THE LAG SEED STOPS MATTERING, AND THAT IS NOT GOOD NEWS.
// phone-bleed with opt bleedLagMs seeded 6 / 58 / 106 (a 0, +52 and
// +100 ms error) reads 0.00 f16 on seed 1 and 0.85 f1 on seed 2 in ALL
// THREE cases, latency 93 vs 94 ms — because the bleed is now loud enough
// that _calibrateBleed's 2× band test trips on nearly every fire and the
// 0.3 EMA reaches 25 ms within a few events whatever it started from.
// Freezing the estimator outright (--opt bleedMinBand=1e9) confirms it:
// 0.85 / 0.83 / 0.85 across the same 100 ms swing.
//
// 5. THE ESTIMATOR IS BLIND AT LAPTOP LEVEL — the trace pathology, reproduced
// on the bench for the first time. bleed 1.0, clip 2, engine seeded 58:
// true lag 6 ms -> estimate 25 ms true lag 58 ms -> estimate 36 ms
// true lag 15 ms -> estimate 35 ms true lag 110 ms -> estimate 37 ms
// true lag 30 ms -> estimate 36 ms
// A 104 ms range of truths maps to a 12 ms range of estimates. The 2×
// band-energy test at follower.js:4878 is a Geigel detector with no
// reference correlation, so at this level it trips on the SOLOIST's own
// energy, not on the echo's arrival — which is the near-end sample cluster
// under 20 ms that holds 7–65 % of samples per session in the real traces,
// and the reason the recorded assumptions read 58 vs a measured 112, 132
// vs 236, 236 vs 99.
//
// 6. AND THE BLINDNESS ONLY COSTS SOMETHING WHEN THE BLEED SITS ON THE
// SOLO'S OWN ROWS. Over a 100 ms swing in a FROZEN assumed lag, Twinkle
// moves by at most one event (0.93 -> 0.96 at true lag 110; 0.98 flat at
// true lag 6) with false 0 everywhere — because Twinkle's accompaniment is
// written an octave above its solo (see the score comment above) and the
// two barely share rows. On the `lownote` score, where the accompaniment
// strikes the very partials the solo's fundamentals are missing, the same
// swing is the difference between working and not. See
// phone-bleed-lag-wrong.
//
// phone-bleed gives the engine the TRUE lag (opt bleedLagMs 6) BY DESIGN: it
// isolates the shipped bleed MODEL from the shipped bleed ESTIMATOR, so
// "how far does the model get when the delay is right" can be told apart from
// "what the delay estimator costs". The estimator's cost is item 6's job.
// MEASURED: seed 1 recall 0.00 (0/46) f16 e0 stall 6.64s, missing all 46,
// six LOST states, drift 15 events / 44.9 s, estimate 25 ms;
// seed 2 recall 0.85 (39/46) f1 e0 stall 3.05s, missing
// [0,1,2,3,4,5,12] — the first six events and then a recovery.
// The target is the GOAL, taken from `bleed-loud`'s 0.85 because that is the
// nearest scenario in kind and there is no reason a phone should be allowed
// to be worse than a loud laptop. false 0 is not negotiable on any scenario.
'phone-bleed': { mut: { bleed: 3.0, bleedLag: 6, bleedClip: 2, opt: { bleedLagMs: 6 } }, tgt: { recall: 0.85, false: 0 } },
// phone-bleed-lag-wrong — THE DELAY, ISOLATED, WHERE IT ACTUALLY BITES.
// The `lownote` score (whose accompaniment strikes the solo's own partial
// rows) under the phone mix, with the estimator FROZEN at a wrong value.
//
// WHY FROZEN. With the estimator live this cannot be tested at all: item 4
// above measures 0.85 / 0.85 / 0.85 across seeds of 6, 58 and 106 ms, because
// the estimator overwrites the seed within a few fires. Freezing is done from
// the outside with `bleedMinBand: 1e9` — follower.js:4878 accepts a sample
// only when `e >= 2 * a.ref && e >= o.bleedMinBand`, so an unreachable
// bleedMinBand admits none and `_bleedLagMs` stays at the seed. No engine
// change, and `bleedLag med` prints `-` to prove no sample was folded in.
// This is the same fault injection `low-note-lag-over` already does with
// bleedLagMs 130, taken one step further so the injection actually holds.
//
// WHY IT IS A LEGITIMATE FAULT AND NOT A STRAW MAN: item 5 shows the shipped
// estimator reading 25–37 ms for every true lag from 6 to 110 ms at laptop
// level, i.e. a frozen-wrong lag is what a blind estimator PRODUCES. The
// traces agree — assumed 236 ms against a measured median of 99, assumed 132
// against 236 — and the report prints 78–100 ms misses routinely.
//
// MEASURED, seed 1, phone mix (bleed 3.0 / lag 6 / clip 2) on `lownote`:
// reference: low-note-weak as it ships recall 0.89 (25/28) f0 e0 stall 1.18s
// frozen lag 6 ms (correct) recall 0.61 (17/28) f0 e0 stall 4.71s
// frozen lag 58 ms (+52, shipped seed) recall 0.61 (17/28) f0 e0 stall 4.72s
// frozen lag 106 ms (+100) recall 0.00 ( 0/28) f6 e0 stall 9.16s
// estimator LIVE from either seed recall 0.61 (17/28) f0 e0, estimate 25 ms
// So the phone MIX alone costs 0.89 -> 0.61, a +52 ms lag error costs
// nothing further, and a +100 ms error costs everything that is left — the
// template is switched on 100 ms after the energy it is supposed to explain
// has already been attributed to the expected note. `early` stays 0 in every
// cell: the six falses at +100 ms are late commits, not advances ahead of
// the pianist. The sweep's worst point is the +100 ms one, which is what the
// scenario reports.
// The stall bound is the one from low-note-weak (4 s) for the same reason it
// is there: a low note the engine cannot identify costs one advance, a low
// note that takes the wrong absorber with it costs the rest of the piece.
'phone-bleed-lag-wrong': { sweep: [
{ score: 'lownote', speakerHpf: 180, speakerOrder: 4, jitter: 30, bleed: 3.0, bleedLag: 6, bleedClip: 2, opt: { bleedLagMs: 58, bleedMinBand: 1e9 } },
{ score: 'lownote', speakerHpf: 180, speakerOrder: 4, jitter: 30, bleed: 3.0, bleedLag: 6, bleedClip: 2, opt: { bleedLagMs: 106, bleedMinBand: 1e9 } },
], tgt: { recall: 0.85, false: 0, stall: 4 } },
'noisy': { mut: { snr: 10 }, tgt: { recall: 0.85, false: 0 } },
'no-fundamental': { mut: { hpf: 180 }, tgt: { recall: 0.90, false: 0, latMed: 180 } },
'double-strike': { mut: { doubleStrike: true }, tgt: { recall: 0.90, false: 0 } },
'wrong-note': { mut: { wrongEvery: 5 }, tgt: { recall: 0.90, false: 0, pWrong: 0.5 } },
// 2026-09-15: 1.00 f0 → 0.95 f1 under the rig synth. The one false is index
// 0 committed at 2.05 s against a 1.0 s onset — the first note again, not
// the skips (every deliberately skipped event is still correctly withheld).
// STILL RED at follower 2026-09-15.14, and one commit worse: 0.92 f2, index
// 43 joining index 0. Both are LATE commits landing after the next onset
// (`early` 0); the extra one is the price of the accompaniment tail, and it
// is declared rather than papered over. Seed 2 reads 1.00 f0 e0. The
// unsolved case is the first note in LISTENING with a collapsed
// fundamental; widening the fundamental/overtone split to every pitch the
// rig loses (< 180 Hz, RIG.fundamentalSpreadBelowHz) was tried and fixes
// bleed-lag-sweep's `early` but takes bleed-loud back to 0.00, because the
// same freedom lets index 0's template fit the loud octave bleed.
// Dropping the accompaniment tail out of the commit path was measured too
// and is the same trade: `--opt upperWeight=0` gives 0.95 f1 here (index 43
// clears, index 0 does not) and 0.00 f21 on bleed-loud seed 1. So index 43
// is the tail's price and it is worth paying; index 0 is the open case, and
// it is NOT the f0/overtone split — at 48 kHz semitoneBins(48) = 0.332, so
// a C3 is above f0UnresolvedBins 0.3 and never takes the lowSplit path.
'skipped-note': { mut: { skipEvery: 6 }, tgt: { recall: 0.90, false: 0, resync: true } },
'repeated-notes': { mut: { score: 'repeated' }, tgt: { recall: 0.85, false: 1, latP90: 300 } },
'no-play': { mut: { noPlay: true }, tgt: { false: 0 } },
'fast': { mut: { tempoScale: 1.8 }, tgt: { recall: 0.85, false: 1 } },
'rubato-heavy': { mut: { jitter: 60, rubato: 20 }, tgt: { recall: 0.85, false: 1 } },
// 2026-09-15: 0.98 f0 e0 → 0.91 f1 e1. The early is index 0's C3 read as
// index 1's G3 585 ms ahead in LISTENING (C3's h3/h6/h9 ARE G3's h2/h4/h6);
// it needs both the tail and the collapsed fundamental — switch either off
// and early goes back to 0.
// Was STILL RED at follower 2026-09-15.14 (0.91 f1 e1 at bleedLag 120; the
// other two lags are clean, and seed 2 is clean at all three) — the one
// place in the suite where `early` was not 0.
// NOW GREEN, 0.93 f0 e0, and NOT by a target change: the early was not the
// collapsed fundamental after all. Index 1 was committed at t 1387 with
// aheadMs 649 in LISTENING while index 0's OWN child, born on the same
// 1044 ms onset (the C3's), was still live and audible — one onset claiming
// two events, which gate 12 (`future`) cannot catch in LISTENING because
// _spanMs is Infinity for a pre-start anchor. Two spawn/commit gates were
// added for it, both hold-only: the `same-onset` commit reason (a skip may
// not share its onset with the event it skips) and an onset-driven child's
// own 60 ms spawn refractory from birth (an index-0 child born at t 1050
// fathered an index-1 child 48 ms later off the same attack, against
// ioiMinMs 150). 0.91 f1 e1 -> 0.93 f0 e0 here; bleed-loud, the counterweight
// named above, is unchanged at 0.91 f0 e0.
'bleed-lag-sweep': { sweep: [20, 58, 120].map((lag) => ({ bleedLag: lag })), tgt: { recall: 0.85, false: 0 } },
// 2026-09-15: 0.91 → 0.89 under the rig synth, five misses instead of four —
// margin, not behaviour (seed 2 was the sharp one: 0.02 with 14 false).
// follower 2026-09-15.14 reads 0.91 f0 e0 on seed 1 and 0.98 on seed 2.
'sr44': { mut: { sr: 44100 }, tgt: { recall: 0.90, false: 0, latMed: 150 } },
// Start at a later beat; the first note follows the seek by 0.2–0.4 × IOI.
// 2026-09-15: 0.97 → 0.87 under the rig synth, with a 5.99 s stall over
// indices 11–18 (bound 5 s). follower 2026-09-15.14 reads 0.93 f0 e0 stall
// 1.15 s on seed 1; seed 2 keeps the f1 it had under .13.
'start-late': { sweep: [{ startAt: 1, startLead: 0.2 }, { startAt: 1, startLead: 0.4 }, { startAt: 6, startLead: 0.3 }], tgt: { recall: 0.90, false: 0, latMed: 150 } },
// The two scenarios below reproduce .accompy_traces/20260915-081347, in which
// the engine advanced 12 times in 16.7 s and then stalled dead at index 12
// (G2) while the pianist played 28 more notes over 45 s — precision 100 %,
// recall 29 %. Neither failure mode had a scenario before.
//
// low-note-weak: the lowest solo notes through a laptop speaker (−21 dB on
// G2's fundamental) with the accompaniment striking the very pitches its
// partials land on. The stall bound is the point: a G2 the engine cannot
// identify costs one advance, and that is what `recall` is for — a G2 that
// takes the WRONG absorber with it costs the rest of the piece.
//
// 2026-09-15: under the rig synth this is now explicitly the `speaker-dead-low`
// variant. R(f) already takes 13.6 dB off 98 Hz, so speakerHpf 180 / order 4
// adds another 21.2 on top — a laptop deader at the bottom than the one that
// was measured, which is exactly the case this scenario is for, but it is no
// longer a picture of THIS rig. Re-baselined 0.93 f0 → 0.86 f1; the one false
// was index 12 (G2) committed 1.2 s late, and 0/8/12/27 were missed.
// follower 2026-09-15.14 reads 0.89 f0 e0 (seed 2: 0.96 f0 e0).
'low-note-weak': { mut: { score: 'lownote', speakerHpf: 180, speakerOrder: 4, jitter: 30 }, tgt: { recall: 0.85, false: 0, stall: 4 } },
// low-note-lag-over: the same rig with the engine's ASSUMED speaker→mic loop
// 72 ms LONGER than the real one (130 ms against BASE's 58). That is the
// sign the app gets wrong in practice — 20260915-081347 started from
// measuredOutputMs + 25 = 58 ms against a measured median of 112 ms, and
// 20260915-081347's own fundamental-starved trim believed 145.7 ms against
// 101 ms — and it opens a window in which accompaniment energy is already in
// the mic while _envA still reports zero. Nothing covered it: on
// low-note-weak even --opt bleedLagMs=250 stays green, because the trim read
// _bleedLive, which by construction is empty for exactly those frames.
// Measured at 130 ms over seeds 1-6: worst-seed recall 0.93 reading
// _bleedLive against 0.89 reading _maskAcc — the four extra misses are G2s
// the engine now declines to claim on rows a D4/G4 bleed owns, and false
// stays 0, which is the trade this scenario exists to hold in place.
'low-note-lag-over': { mut: { score: 'lownote', speakerHpf: 180, speakerOrder: 4, jitter: 30, opt: { bleedLagMs: 130 } }, tgt: { recall: 0.85, false: 0, stall: 4 } },
// wrong-lock-recovery: eight notes in a row that are in the score nowhere —
// long enough for the absorber to saturate, since the longest LEGITIMATE run
// of pWrong ≥ 0.5 in the real session was 854 ms and the failure was
// 49 523 ms — and then the pianist resumes the written part on the very event
// the engine was waiting for. Three targets, each pulling a different way:
// `pWrong` says the absorber must really take the mass (otherwise the
// scenario is not testing recovery at all), `pWrongRun` says it must then
// give it back — measured 7.0 s of sound with the wrongDwellMs watchdog
// disabled against 2.5 s with it, so this is the target that holds that
// watchdog in place — and `stall` measures the re-lock itself, because the
// intruders are excluded from it.
//
// The intruders are inserted at index 12 rather than at index 8, and that is
// a statement about what is knowable, not a choice of an easy spot. Twinkle
// writes G2 at index 8 and again at index 12, four beats apart. Insert eight
// beats in front of index 8 and the pianist's next G2 is BOTH the note the
// engine is waiting for and exactly where its tempo clock now points (index
// 12) — pitch and clock agree on the wrong answer, and no evidence in the
// audio separates "the pianist played eight unwritten beats" from "the engine
// missed indices 8–11". Measured: 18 advances up to 8.7 s ahead of the
// pianist. At index 12 the eight-beat skip lands on index 20 (C3), the pitch
// gate refuses it, and the question reduces to the one being asked.
'wrong-lock-recovery': { mut: { wrongInsert: [12, 8] }, tgt: { recall: 0.80, false: 0, pWrong: 0.5, pWrongRun: 4.0, stall: 4 } },
// dense-drift: the failure of
// .accompy_traces/20260916-113517_cello-concerto-no1-in-c-major-hobviib1-joseph-ha
// (143 solo notes over 120 beats, recall 7 %, the accompaniment 40 s adrift)
// reproduced synthetically. 200 events of that excerpt's OWN score at its own
// spacing — median 250 ms, fastest decile 125 ms, 66 of 199 gaps under
// 0.25 beat — with the long tutti rests clamped out, so the pianist never
// stops and the engine never gets a free re-lock. See DENSE_RH for the
// provenance of every number and for the one deliberate departure (register).
//
// WHAT IT REPRODUCES. The drift is monotone and it is the density that
// produces it, not the noise: `snr` is BASE's 30 dB and the solo is in
// Twinkle's own register. Measured on the shipped engine (2026-09-16.9),
// `--trace /dev/null`, one seed per command:
// seed 1 recall 0.39 (78/200) false 14 early 0 drift 47 / 31.7 s stall 11.2 s
// seed 2 recall 0.55 (109/200) false 9 early 0 drift 25 / 6.2 s stall 7.9 s
// against Twinkle's 0.93-0.98 on the same synth. THE SCENARIO IS RED AND IS
// DECLARED RED (see TARGETS below); these numbers are what it does, not what
// it has to do. Latency where the engine IS tracking is 76 ms median / 155 ms
// p90 — under the 125 ms fastest decile at the median, i.e. the engine CAN
// claim a 125 ms note — and it dies in the 125 ms passages: seed 1 loses
// events 14-88 outright and again 92-117, seed 2 the runs at #168 on. Nothing
// else in the suite contains a 125 ms passage; both Twinkles' shortest IOI is
// 985 ms.
//
// WHERE THE `false` COUNT COMES FROM, measured rather than assumed: every one
// of the 14 (seed 1) and 9 (seed 2) is the RIGHT event committed late — 410 ms
// to 24.2 s late, see `lateMs` in the false list. Zero are mis-indexed, zero
// land on an unplayed event, and `early` is 0 on both seeds. On this scenario
// `false` is `drift` seen from the other end, and the precision that matters
// is intact.
//
// THE RESYNC IS CORRECT HERE AND IT IS NOT ENOUGH. 8 armed wide scans per
// seed, 3 fired and 5 refused `ambiguous` on both; `resyncBad` is 0 on both,
// i.e. no jump landed on an event the pianist had not begun. The `ahead 1` in
// the scans line is the ALIGNMENT's own winner, not the landing: the search
// picks one event past the pianist (`top-1 err med 1`, `|err|<=1 1.00`) and
// `resyncLandBack` is what turns that into a landing one event short. Read
// the two together — `ahead` says what the search chose, `resyncBad` says
// where the accompaniment went.
//
// But a jump needs `resyncContext` 16 onsets plus two LOST episodes to arm,
// which is ~2-4 s of music, while a 16-note 125 ms run puts the engine 16
// events behind in 2 s. The mechanism closes a gap it cannot prevent.
//
// WHAT WAS MEASURED AND NOT SHIPPED, so it is not re-tried blind:
// - Loosening the dense-passage accumulation gate (`denseMinEvNats` 2.5->1.5,
// `denseMinEvNatsSoft` 5->3, `denseMinCommitGapMs` 70->40, all of which
// apply ONLY where the score's rolling 5-event median IOI is under
// 250 ms) makes this scenario WORSE, not better: 0.45 -> 0.20, false
// 16 -> 27, and `early` 0 -> 1. Accumulation speed is not the binding gate
// at 125 ms and buying it costs the one thing that may not be traded. Same
// conclusion the engine owner reached on the Haydn replay, now with a
// bench that can see it.
// - `resyncArmLostEpisodes` 2 -> 1 (arm on the FIRST lost episode rather
// than the second) is the single constant that moves this scenario:
// recall 0.45 -> 0.56, false 16 -> 8, drift 28 -> 23, drift run
// 10.3 -> 7.7 s, with all three fires still exact and none ahead. Both
// reference Twinkle replays stay bit-identical (39/42 and 38/42, false 0,
// resync 0) because `resyncArmOnsets` 8 alone excludes them — they never
// exceed 3 onsets between commits. It is NOT shipped here for one measured
// reason: the Haydn replay's false count goes 3 -> 4 (a third jump fires
// at t 83.0 s, 118 -> 131), and `2` is the value whose justification is
// written into the engine as "the measured populations do not overlap".
// Trading a real-trace precision point for a synthetic recall point on the
// strength of a scenario written the same afternoon is the wrong way round.
// It belongs to whoever owns the engine, with the rig run to check it.
// - Removing the jump prior altogether (`resyncMarginNats` 0,
// `resyncPenaltyNats` 0, `resyncMinNewShare` 0, arm on everything, no rate
// limit) DOES reach the recall target — 0.60 (120/200), drift 28 -> 15,
// false 16 -> 5 — and pays for it exactly where Nakamura's s_j sweep says
// it will: `early` 0 -> 1 and one jump landing two events past the pianist
// (#120 while he was on #118). That is the whole argument for the shipped
// 1.5-nat bar in one command, and it doubles as the self-test that the
// `resyncBad` assertion below actually binds: it is the only configuration
// found that trips it.
//
// TARGETS. These are the brief's, and they are a statement of what the tool
// has to do rather than a record of what it does — this scenario is RED and
// is expected to stay red until the detection side of the resync can arm
// without waiting for two LOST episodes. `skipped-note` carries the same
// kind of declared-red target (see its note). None of them may be lowered.
// - recall 0.60. Justified from this bench's own measurements, not chosen to
// pass: the engine's commit latency in the stretches where it IS tracking
// here is 76 ms median / 155 ms p90, at or below the 125 ms fastest decile
// at the median, so there is no structural reason a dense note cannot be
// claimed; and seed 2 already holds 109/200 (0.55) as shipped and 113/200
// (0.56) with the one arming change described above. 0.60 is the nearest
// round number above what a single arming change already reaches.
// - drift 4 events and drift run 4 s are the LOAD-BEARING targets, not
// recall. The product point is that the accompaniment is in the right
// place: an engine that misses half the notes but stays within four
// events is doing its job, and one that catches every third note sixty
// events late is what the user heard as "completely disorganized". 4
// events is ~1 s at this spacing; perfect tracking reads 1 (see DRIFT_OK).
// - false 0 and early 0 are the safety pair and are never relaxed. `early`
// is 0 on both seeds as shipped and must stay there. It has been 1 once:
// before `resyncLandBack`, seed 1 read `early 1` (event 64 committed
// 235 ms BEFORE its onset, 448 ms after a jump that landed on event 62
// while the pianist's last begun event was 61), and `--opt
// resyncMaxJump=0` took it back to 0 — i.e. the jump was what enabled it.
// That is why the landing is now one event short and why `resyncBad` is
// one-sided; if either is ever loosened, re-run this seed first.
'dense-drift': { mut: { score: 'dense' }, tgt: { recall: 0.60, false: 0, drift: 4, driftRun: 4.0 } },
};
// Every scenario, not only the two that name it: the pianist may never be left
// playing for this long with the engine standing still. 5 s is ~3.5 beats at
// the rig's 60 bpm and 1/9 of the real session's stall.
const STALL_MAX_SEC = 5;
function evaluate(m, tgt) {
const fails = [];
if (tgt.recall !== undefined && m.recall < tgt.recall) fails.push(`recall ${m.recall.toFixed(2)} < ${tgt.recall}`);
if (tgt.false !== undefined && m.false > tgt.false) fails.push(`false ${m.false} > ${tgt.false}`);
// Every scenario: no advance ahead of the pianist, ever (see EARLY_MS).
if (m.early > 0) fails.push(`early ${m.early} > 0`);
if (tgt.latMed !== undefined && (m.latencyMedian === null || m.latencyMedian > tgt.latMed)) fails.push(`lat med ${fmt(m.latencyMedian)} > ${tgt.latMed}`);
if (tgt.latP90 !== undefined && (m.latencyP90 === null || m.latencyP90 > tgt.latP90)) fails.push(`lat p90 ${fmt(m.latencyP90)} > ${tgt.latP90}`);
if (tgt.pWrong !== undefined && (m.pWrongMedian === null || m.pWrongMedian < tgt.pWrong)) fails.push(`pWrong ${fmt(m.pWrongMedian, 2)} < ${tgt.pWrong}`);
if (tgt.resync && m.resyncFails > 0) fails.push(`resync failed ${m.resyncFails}×`);
// Every scenario: no jump the engine should not have made (see resyncBad).
if (m.resyncBad > 0) fails.push(`resync landed wrong ${m.resyncBad}×`);
if (tgt.drift !== undefined && m.driftMax > tgt.drift) fails.push(`drift ${m.driftMax} > ${tgt.drift} events`);
if (tgt.driftRun !== undefined && m.driftRunSec > tgt.driftRun) fails.push(`drift run ${fmt(m.driftRunSec, 1)}s > ${tgt.driftRun}s`);
const stallMax = tgt.stall ?? STALL_MAX_SEC;
if (m.stall > stallMax) fails.push(`stall ${fmt(m.stall, 2)}s > ${stallMax}s`);
if (tgt.pWrongRun !== undefined && m.pWrongRunMs / 1000 > tgt.pWrongRun) fails.push(`pWrong run ${fmt(m.pWrongRunMs / 1000, 1)}s > ${tgt.pWrongRun}s`);
return fails;
}
const fmt = (v, d = 0) => (v === null || v === undefined || Number.isNaN(v) ? '-' : Number(v).toFixed(d));
function buildCfg(args, mut) {
const cfg = { ...BASE, ...mut, sr: mut.sr ?? args.sr, opt: { ...(mut.opt || {}), ...args.opt }, trackerDebounce: args.trackerDebounce, reverb: args.reverb, dumpSal: args.dumpSal };
// Explicit CLI flags override the scenario mutation (they are the user's experiment).
if (args.bleed !== undefined) cfg.bleed = args.bleed;
if (args.snr !== undefined) cfg.snr = args.snr;
if (args.hpf !== undefined) cfg.hpf = args.hpf;
if (args.jitter !== undefined) cfg.jitter = args.jitter;
if (args.rubato !== undefined) cfg.rubato = args.rubato;
if (args.bleedLag !== undefined) cfg.bleedLag = args.bleedLag;
if (args.bleedClip !== undefined) cfg.bleedClip = args.bleedClip;
if (args.tempoScale !== undefined) cfg.tempoScale = args.tempoScale;
if (args.synth !== undefined) cfg.synth = args.synth;
return cfg;
}
function runOne(args, scenarioName, mut, seed, hooks) {
const cfg = buildCfg(args, mut);
const score = loadScore({ ...args, score: cfg.score || args.score });
cfg.bps = args.bpm ? args.bpm / 60 : score.bps;
const rng = makeRng(seed * 7919 + 17);
const perf = synthesise(score, cfg, rng);
const res = runEngine(perf, score, cfg, hooks);
const metrics = scoreRun(res, perf);
return { cfg, score, perf, res, metrics };
}
// Worst run over seeds: the metric that would fail hardest wins.
function worstOf(runs, tgt) {
let worst = runs[0];
for (const r of runs) {
const f = evaluate(r.metrics, tgt), fw = evaluate(worst.metrics, tgt);
if (f.length > fw.length) worst = r;
else if (f.length === fw.length && (r.metrics.early > worst.metrics.early || r.metrics.recall < worst.metrics.recall || r.metrics.false > worst.metrics.false || r.metrics.stall > worst.metrics.stall || r.metrics.driftMax > worst.metrics.driftMax)) worst = r;
}
return worst;
}
// Per-event timeline: what the engine did around each true onset.
function printTimeline(run) {
const { perf, res } = run;
const acc = res.advances.filter((a) => a.accepted);
for (const e of perf.events) {
const win = acc.filter((a) => a.toIndex === e.k);
const t0 = e.T * 1000;
const holds = {};
for (const h of res.holds) if (h.tMs - SIM_TRANSPORT_MS >= t0 - 50 && h.tMs - SIM_TRANSPORT_MS <= t0 + 450 && h.index === e.k) holds[h.reason] = (holds[h.reason] || 0) + 1;
const hit = win.map((a) => `${Math.round(a.audioMs - t0)}ms${a.repeatLike ? ' rep' : ''} p${a.p.toFixed(2)} ev${a.ev.toFixed(1)}`).join('; ');
const pitches = e.pitches.map(pitchName).join('+');
console.log(` #${String(e.k).padStart(2)} ${pitches.padEnd(6)} beat ${String(e.beat).padStart(3)} T ${(e.T).toFixed(2)}s ${e.kind.padEnd(7)} ${hit ? 'commit +' + hit : 'MISS'} holds ${JSON.stringify(holds)}`);
}
}
// ---------------------------------------------------------------------------
// Debug outputs (§8.5)
// ---------------------------------------------------------------------------
function writeWav(file, mix, sr) {
const n = mix.length, buf = Buffer.alloc(44 + n * 2);
buf.write('RIFF', 0); buf.writeUInt32LE(36 + n * 2, 4); buf.write('WAVE', 8);
buf.write('fmt ', 12); buf.writeUInt32LE(16, 16); buf.writeUInt16LE(1, 20); buf.writeUInt16LE(1, 22);
buf.writeUInt32LE(sr, 24); buf.writeUInt32LE(sr * 2, 28); buf.writeUInt16LE(2, 32); buf.writeUInt16LE(16, 34);
buf.write('data', 36); buf.writeUInt32LE(n * 2, 40);
for (let i = 0; i < n; i++) buf.writeInt16LE(Math.max(-32768, Math.min(32767, Math.round(mix[i] * 32767))), 44 + i * 2);
fs.writeFileSync(file, buf);
}
const r1 = (v) => (v === null || v === undefined ? null : Math.round(v * 10) / 10);
const r2 = (v) => (v === null || v === undefined ? null : Math.round(v * 100) / 100);
const r3 = (v) => (v === null || v === undefined ? null : Math.round(v * 1000) / 1000);
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const pitchName = (m) => (Number.isFinite(m) ? NOTE_NAMES[((m % 12) + 12) % 12] + (Math.floor(m / 12) - 1) : '?');
// NPTrace-shaped file so scripts/trace_report.py renders sim runs unchanged (§8.5).
function buildTrace(run, args) {
const { cfg, score, perf, res } = run;
const t0 = perf.preRoll * 1000; // trace t = ms from piece start
const events = [];
const log = (t, type, data) => events.push({ t: Math.round((t - t0) * 10) / 10, type, ...data });
const solo = normaliseEvents(score.rh);
// One key per EVENT with its lead pitch (the rig types one key per event and
// the follower emits one mic.note per event; a key per chord pitch made the
// report's ground truth steal the next event's note as a wrong pitch).
for (const e of perf.events) if (e.kind !== 'skipped' && e.kind !== 'silent') {
const lead = solo[e.k]?.lead ?? e.pitches[e.pitches.length - 1];
log(e.T * 1000, 'input.key', { midi: lead, name: pitchName(lead), pitches: e.pitches, src: 'sim', index: e.k, kind: e.kind });
}
// The intruders are keys the pianist really pressed: they belong in the
// ground truth even though no advance should ever name them.
for (const e of perf.intruders || []) log(e.T * 1000, 'input.key', { midi: e.pitches[e.pitches.length - 1], name: pitchName(e.pitches[e.pitches.length - 1]), pitches: e.pitches, src: 'sim', index: null, kind: 'intruder' });
const hookFires = run.fires || [];
for (const f of hookFires) log(f.audioMs, 'accomp.fire', { pitches: f.pitches, beat: f.beat });
// atMs on the trace clock (same as the app): --replay passes it straight to accompanimentFired.
for (const f of hookFires) log(f.audioMs, 'follow.accomp', { pitches: f.pitches, beat: f.beat, atMs: Math.round(f.atMs - SIM_TRANSPORT_MS - t0), durMs: Math.round(f.durMs) });
// The sim's own seek (runEngine), recorded the way app.js:6522 records the
// app's. Without it --replay finds no follow.seek, falls back to seekPos 0
// and replays a DIFFERENT session: start-late seed 1 reports recall 0.97 live
// but 0.38 (keys 40, hits 15) replayed from its own --out trace; with the
// event it replays back to 0.97 / false 0.
if (res.seekAudioMs !== null && res.seekAudioMs !== undefined) {
log(res.seekAudioMs, 'follow.seek', { position: perf.startAt, atMs: Math.round(res.seekAudioMs - t0) });
}
let lastBest = null, lastHoldKey = new Map();
for (const { frame, out } of res.frames) {
const audioMs = out.tMs - SIM_TRANSPORT_MS;
const bestChanged = (out.best?.i ?? null) !== lastBest; lastBest = out.best?.i ?? null;
// Every frame, verbose (quantised `sal` + `pk`), the app's FOLLOWER_TRACE 'frames'
// format: that is what `--replay` consumes, so a sim trace replays into itself.
{
const P = new Map(out.posterior.map((p) => [p.i, p.p]));
log(audioMs, 'follow.frame', {
engine: 'follower', k: out.seq, at: frame.at, mode: out.mode, pos: out.position, best: out.best?.i ?? null, p: r3(out.best?.p),
pcom: r3(P.get(out.committed) || 0), pcur: r3(P.get(out.committed + 1) || 0), pnext: r3(P.get(out.committed + 2) || 0), pw: r3(out.pWrong),
ev: r2(out.advance?.ev ?? out.hold?.ev ?? null), db: r1(frame.db), on: r2(out.onset.ratio), peak: frame.peak, inf: out.informative,
sal: Array.from(salience.quantiseSal(frame.sal)), pk: r1(10 * Math.log10(Math.max(...frame.sal) + 1e-20)),
g: frame.gated ? 1 : 0, a: frame.attack ? 1 : 0, fl: r1(frame.floorDb),
ur: out.maskRows || 0, gr: r2(out.bleedRef || 0),
});
}
if (out.state) log(audioMs, 'follow.state', { engine: 'follower', mode: out.state.mode, reason: out.state.reason, pos: out.position, p: r3(out.best?.p) });
if (out.recovered) log(audioMs, 'follow.recover', { engine: 'follower', mode: out.recovered.mode, reason: out.recovered.reason, pos: out.position, pw: r3(out.pWrong) });
if (out.bleed) log(audioMs, 'follow.bleed', { engine: 'follower', pitches: out.bleed.pitches, atMs: Math.round(out.bleed.atMs), seenMs: Math.round(out.bleed.seenMs), lagMs: Math.round(out.bleed.lagMs), strength: r2(out.bleed.strength) });
// THE WIDE JUMP, in its own event types, beside the advance and never
// inside it (see trace_report.py's follow.resync renderer: an advance is
// "the player played this note", a resync is "the player is not here at
// all", and precision may only ever be computed from the first). Without
// these two arms a --out trace showed the tracker teleporting with nothing
// to explain it: `--scenario dense-drift --seed 1 --out <path>` wrote a
// type histogram of follow.advance 84 / tracker 84 and ZERO follow.resync
// for a run that accepted three jumps (13->62, 91->111, 119->144).
// Field names follow app.js traceFollowerResync / the resyncScan log, so
// one report renders a sim trace and a session trace the same way.
if (out.resyncScan) {
const sc = out.resyncScan;
log(audioMs, 'follow.resyncScan', {
engine: 'follower', fromIndex: sc.fromIndex, toIndex: sc.toIndex, distance: sc.distance, refused: sc.refused ?? null,
contextOnsets: sc.contextOnsets, contextMs: sc.contextMs, windowSize: sc.windowSize, reach: sc.reach,
marginNats: r2(sc.marginNats), leadNats: r2(sc.leadNats), meanNewShare: r2(sc.meanNewShare),
mode: sc.mode, lostEpisodes: sc.lostEpisodes, onsetsSinceAccept: sc.onsetsSinceAccept,
});
}
if (out.resync) {
const rs = out.resync, rec = res.resyncs.find((r) => r.id === rs.id);
const accepted = !!rec?.accepted;
log(audioMs, 'follow.resync', {
engine: 'follower', id: rs.id, accepted, reason: accepted ? null : 'tracker refused',
// As in app.js: `fromIndex` is the tracker's convention (the next event
// expected), `engineFrom` the engine's committed one.
fromIndex: rs.fromIndex + 1, toIndex: rs.toIndex, engineFrom: rs.fromIndex,
beat: rs.beat, jump: rs.toIndex - (rs.fromIndex + 1),
onsetsConfirmed: rs.onsetsConfirmed, contextMs: rs.contextMs,
marginNats: r2(rs.marginNats), leadNats: r2(rs.leadNats), meanNewShare: r2(rs.meanNewShare),
windowSize: rs.windowSize, reach: rs.reach,
sinceOnsetEvidenceMs: Math.round(rs.sinceOnsetEvidenceMs), mode: rs.mode,
nInSession: res.resyncs.filter((r) => r.id <= rs.id && r.accepted).length,
expected: rs.pitches,
});
// The tracker moved, so the tracker event has to say so — `resync: true`
// is what tells the report this row is not an ordinary advance.
const rev = solo[rs.toIndex], expEv = solo[rs.fromIndex + 1] || null;
log(audioMs, 'tracker', {
src: 'follower', resync: true, input: rev.pitches, inputName: rev.pitches.map(pitchName).join(' '),
posBefore: rs.fromIndex + 1, posAfter: accepted ? rs.toIndex + 1 : rs.fromIndex + 1,
expected: expEv ? expEv.pitches : [], expectedName: expEv ? expEv.pitches.map(pitchName).join(' ') : '', expectedBeat: expEv ? expEv.beat : null,
result: accepted ? 'match' : 'reject', beat: accepted ? rs.beat : null,
engine: 'follower', ev: null, sinceOnsetMs: null, lagMs: Math.round(rs.lagMs), bps: r3(rs.bps), mode: out.mode,
});
}
if (out.advance) {
const adv = out.advance, rec = res.advances.find((a) => a.id === adv.id);
const ev = solo[adv.toIndex], lead = ev.lead;
log(audioMs, 'mic.note', { midi: lead, name: pitchName(lead), engine: 'follower', reason: 'follower', matched: false, clarity: null, freq: null, calibrating: false,
heardMidi: adv.heardMidi, heardName: pitchName(adv.heardMidi), p: r3(adv.p), margin: r3(adv.margin), ev: r2(adv.ev),
sinceOnsetMs: Math.round(adv.sinceOnsetMs), lagMs: Math.round(adv.lagMs), onsetDriven: adv.onsetDriven, repeatLike: adv.repeatLike });
// Same bag as app.js traceTrackerResult (the report reads `result`, `inputName`, `expectedName`, `posAfter`).
const accepted = !!rec?.accepted, expEv = solo[adv.fromIndex + 1] || null;
log(audioMs, 'tracker', {
src: 'follower', input: ev.pitches, inputName: ev.pitches.map(pitchName).join(' '),
posBefore: adv.fromIndex + 1, posAfter: accepted ? adv.toIndex + 1 : adv.fromIndex + 1,
expected: expEv ? expEv.pitches : [], expectedName: expEv ? expEv.pitches.map(pitchName).join(' ') : '', expectedBeat: expEv ? expEv.beat : null,
result: accepted ? 'match' : 'reject', beat: accepted ? adv.beat : null,
engine: 'follower', p: r3(adv.p), margin: r3(adv.margin), ev: r2(adv.ev), sinceOnsetMs: Math.round(adv.sinceOnsetMs), lagMs: Math.round(adv.lagMs), bps: r3(adv.bps), mode: out.mode, repeatLike: adv.repeatLike,
});
log(audioMs, 'follow.advance', { engine: 'follower', id: adv.id, fromIndex: adv.fromIndex, toIndex: adv.toIndex, beat: adv.beat, accepted: !!rec?.accepted, p: r3(adv.p), margin: r3(adv.margin), ev: r2(adv.ev),
sinceOnsetMs: Math.round(adv.sinceOnsetMs), onsetAtMs: Math.round(adv.onsetAtMs), lagMs: Math.round(adv.lagMs), bps: r3(adv.bps),
onsetDriven: adv.onsetDriven, repeatLike: adv.repeatLike, onsetScore: adv.onsetScore, heardMidi: adv.heardMidi,
explained: { bleed: r2(adv.explained.bleed), prev: r2(adv.explained.prev), new: r2(adv.explained.new), other: r2(adv.explained.other) },
hyps: adv.hyps.map((h) => ({ i: h.i, tOn: Math.round(h.tOnMs), bps: r3(h.bps), w: r3(h.w), ev: r2(h.ev) })) });
} else if (out.hold) {
const key = out.hold.reason + ':' + out.hold.index, last = lastHoldKey.get(key);
if (last === undefined || audioMs - last >= 250) {
lastHoldKey.set(key, audioMs);
const h = out.hold;
log(audioMs, 'follow.hold', { engine: 'follower', reason: h.reason, index: h.index, pos: out.position, p: r3(h.p), margin: r3(h.margin), ev: r2(h.ev), onsetSeen: h.onsetSeen, dwellMs: Math.round(h.dwellMs) });
if (h.onsetSeen) {
const exp = solo[h.index];
log(audioMs, 'mic.drop', { engine: 'follower', reason: 'held:' + h.reason, expMidi: exp?.lead ?? null, expName: exp ? pitchName(exp.lead) : null, index: h.index, p: r3(h.p), margin: r3(h.margin), ev: r2(h.ev) });
}
}
}
}
events.sort((a, b) => a.t - b.t);
const rhDur = normaliseEvents(score.rh).map((e, i, arr) => e.durBeats ?? (i + 1 < arr.length ? arr[i + 1].beat - e.beat : 1));
return {
version: 1, app: 'notepilot', startedAt: new Date().toISOString(),
meta: { inputMode: 'mic', micEngine: 'follower', followMode: 'leader', score: score.name, initialBps: cfg.bps, rh: score.rh, lh: score.lh, rhDur,
sim: { scenario: run.scenario, seed: run.seed, cfg: { ...cfg, opt: cfg.opt } },
// No `tag`: that is the app's ?v= cache-buster for /follower.js, and the
// sim imports the module directly, so any value here is a guess that goes
// stale (it was two bumps behind). version is the real identity.
follower: { version: FOLLOWER_VERSION, sr: cfg.sr, win: cfg.opt.win ?? 2048, hop: cfg.opt.hop ?? 512, minMidi: 33, maxMidi: 96, options: res.follower.options } },
events,
};
}
// --replay: feed a verbose session's follow.frame events through a fresh engine (§8.5).
function replay(args) {
const tr = JSON.parse(fs.readFileSync(args.replay, 'utf8'));
const meta = tr.meta || {};
const fmeta = meta.follower || {};
const sr = fmeta.sr || 48000, win = fmeta.win || 2048, hop = fmeta.hop || 512;
const frames = tr.events.filter((e) => e.type === 'follow.frame' && Array.isArray(e.sal));
if (!frames.length) { console.error('replay: no verbose follow.frame events (sal) in ' + args.replay); process.exit(2); }
const engineOpts = { ...(fmeta.options || {}), ...args.opt }; delete engineOpts.win; delete engineOpts.hop;
const follower = new ScoreFollower({ solo: meta.rh, accompaniment: meta.lh || [], bps: meta.initialBps || 1, sampleRate: sr, win, hop, position: 0, options: { ...engineOpts, accompFallback: false } });
const solo = normaliseEvents(meta.rh);
const tracker = new FakeTracker(solo, { bps: meta.initialBps || 1 });
const keys = tr.events.filter((e) => e.type === 'input.key');
const accomp = tr.events.filter((e) => e.type === 'follow.accomp');
let accIdx = 0;
// Auto-skips are part of the session, not noise: skipCurrentNote (app.js:6008)
// moves the tracker AND calls _scoreFollower.seek(state.tracker.position),
// which rebuilds the beam at the new index. A replay that drops them keeps
// arguing for the event the live engine stepped over, so the two engines are
// no longer running the same session. Measured on 20260916-154809 (7 skips,
// all `auto`): without this the replay reads 212 advances / last index 241 /
// 1 spurious resync and diverges at advance #1; with it, 230 / 252 / 0 and
// the first divergence is at advance #153, against the live 237 / 254 / 0.
// `skip.toIndex` IS the `state.tracker.position` app.js passes to seek().
// reason 'out-of-bank' (app.js:7788) is the one skip path that does NOT seek
// the follower — it only walks the tracker — so it must not seek here either.
const skips = tr.events.filter((e) => e.type === 'skip' && Number.isFinite(e.toIndex));
let skIdx = 0;
// The app does TWO things between `new ScoreFollower` and the first frame
// (resetScoreFollower: seek() then seedFollowerAccompaniment()), and a replay
// that skips them is not replaying the same engine. Without the seek the
// committed state is unstamped, so it looks like it has been sounding since
// the page loaded; without the seed the notes already ringing when the engine
// started are missing from the bleed model. Measured on 20260915-083040:
// unpatched, index 0 committed at t=191ms — 1141ms before the first key and
// 1523ms before the live engine's own commit — on frames peaking at MIDI 60,
// i.e. the seeded C4; the replay reported recall 0.45 with `false` 2 for a
// session whose recorded precision is 100 %. With both steps it commits at
// t=1628ms (86ms from the live 1714ms) and reports recall 0.46 / false 1 —
// the live figures — and 0.48 / false 0 once `fl` is used as well.
const seekEv = tr.events.find((e) => e.type === 'follow.seek');
const seedEv = tr.events.find((e) => e.type === 'follow.accomp.seed');
const t0 = frames[0].t;
const seekPos = seekEv && Number.isFinite(seekEv.position) ? seekEv.position : 0;
follower.seek(seekPos, seekEv && Number.isFinite(seekEv.atMs) ? seekEv.atMs : t0);
tracker.position = seekPos;
// Pre-`follow.accomp.seed` traces (before follower 2026-09-15.10) still carry
// the information: an accomp.fire with no follow.accomp beside it is a note
// that sounded before the engine existed, which is exactly what the seed is.
const seedList = seedEv ? (seedEv.list || []) : (() => {
const firstAcc = accomp.length ? accomp[0].t : Infinity;
const durMs = accomp.length ? quantile(accomp.map((a) => a.durMs).filter(Number.isFinite), 0.5) : 1000 / (meta.initialBps || 1);
// "No follow.accomp beside it", not merely "before the first one": the fire
// and the engine's notification are logged microseconds apart (2422.7 vs
// 2422.9 ms in 20260915-083040), so a plain t < firstAcc test seeds the
// first reported fire a second time and doubles its bleed.
const reported = (e) => accomp.some((a) => Math.abs(a.t - e.t) <= 100 && String(a.pitches) === String(e.pitches));
return tr.events.filter((e) => e.type === 'accomp.fire' && e.t < firstAcc && !reported(e))
.map((e) => ({ pitches: e.pitches, atMs: e.t, durMs, beat: e.beat }));
})();
if (seedList.length) follower.seedAccompaniment(seedList);
const hopMs = hop / sr * 1000;
let Lprev = null; const fluxHist = [];
let floorDb = null;
// The level floor comes from the frame's own `fl` (app.js records it for
// exactly this). Only pre-`fl` traces fall back to recomputing it from the
// logged `db` with the analyzer's own rise rate: floorDb drives
// lambda = clamp((db - floorDb - gateDb) / rangeDb, 0, 1), the weight behind
// `informative`, the onset bar and every evidence term, and on
// 20260915-083040 the reconstruction was off by 0.39 dB mean / 1.78 dB max
// over 5750 frames — up to 15 % of rangeDb = 12. Not cosmetic: on that trace
// it moves the replay from recall 0.46 / false 1 / p90 313 ms to 0.48 / 0 /
// 361 ms.
const floorRise = new FrameAnalyzer(sr, { win, hop }).floorRiseDbPerSec * hop / sr;
const advances = [], resyncs = [];
for (const fe of frames) {
while (skIdx < skips.length && skips[skIdx].t <= fe.t) {
const sk = skips[skIdx++];
if (sk.reason !== 'out-of-bank') follower.seek(sk.toIndex, sk.t);
tracker.position = sk.toIndex;
}
while (accIdx < accomp.length && accomp[accIdx].t <= fe.t) { const a = accomp[accIdx++]; follower.accompanimentFired({ pitches: a.pitches, atMs: a.atMs, durMs: a.durMs, beat: a.beat }); }
const sal = salience.dequantiseSal(Uint8Array.from(fe.sal), fe.pk ?? 0); // pk = peak salience power in dB
const db = fe.db ?? -60;
floorDb = Number.isFinite(fe.fl) ? fe.fl : (floorDb === null ? db : Math.min(floorDb + floorRise, db));
let flux = 0; const L = new Float32Array(sal.length);
for (let b = 0; b < sal.length; b++) { L[b] = Math.log(sal[b] + 1e-10); if (Lprev) { const d = L[b] - Lprev[b]; if (d > 0) flux += d; } }
Lprev = L;
let onset = 0;
if (fluxHist.length >= 8) { const s = fluxHist.slice().sort((a, b) => a - b); const med = s.length & 1 ? s[s.length >> 1] : 0.5 * (s[(s.length >> 1) - 1] + s[s.length >> 1]); onset = flux / (med + 1e-6); }
fluxHist.push(flux); if (fluxHist.length > 64) fluxHist.shift();
// The onset ratio comes from the frame's own `on` (app.js records it, like
// `fl` above, for exactly this); the reconstruction beside it is only for
// traces that predate the field. `on` is the ONLY frame field observe()
// reads besides sal/db/floorDb/gated/attack, and reconstructing it from
// the recorded salience cannot be exact: `sal` is quantised to half-dB
// steps against a 0.1 dB frame peak (quantiseSal), and the ratio is a
// sum of 64 LOG differences over a 64-frame median, so the quantisation
// does not average out. Measured over both sessions the error is tiny —
// mean |d| 0.024, p95 0.06 — and that is exactly why it was not caught:
// only 19 frames of 15604 on 20260915-204851 cross the level-scaled flux
// bar at all (onsetThreshRepeat + (onsetThresh - onsetThreshRepeat)*λ),
// and ONE of them disagrees. That one frame is k10074 at 4919 ms, live
// ratio 3.39 against a bar of 3.375 at λ 0.35, reconstructed 3.31: it is
// the onset evidence that confirms index 0, so without it the replay held
// `no-onset-evidence` through the whole first phrase and committed index 0
// at 54.9 s instead of the live engine's 4.99 s. The rest of the session
// followed from there: hits 6 either way, but every posterior after the
// first minute belonged to a session the user never played. With `on` the
// replay reproduces the live commits to the frame (i0 4994 vs 4995 ms,
// i1 8994 vs 8996, i2 10178 vs 10179), which is what a replay is for.
if (Number.isFinite(fe.on)) onset = fe.on;
const rms = 10 ** (db / 20);
// `g`/`a` are the worklet's gate and attack flags as recorded (older verbose traces lack them: treated as false).
const frame = { type: 'frame', seq: fe.k, at: fe.at ?? Math.round(fe.t / 1000 * sr), sr, win, hop, rms, db, floorDb, flux, onset, hfc: 0, sal, peak: 33 + sal.indexOf(Math.max(...sal)), noiseFloor: null, gated: !!fe.g, attack: !!fe.a };
const out = follower.observe(frame, fe.t);
// See the resync block in runEngine: its own emission, its own explicit
// maxJump, never scored as a detection.
if (out.resync) {
const beat = tracker.advanceTo(out.resync.toIndex, { maxJump: out.resync.maxJump, inputPitch: 0 }, fe.t / 1000);
follower.ack(out.resync.id, beat !== null);
if (beat !== null) resyncs.push({ ...out.resync, tMs: out.tMs });
}
if (out.advance) {
const beat = tracker.advanceTo(out.advance.toIndex, { maxJump: out.advance.maxJump, inputPitch: 0 }, fe.t / 1000);
follower.ack(out.advance.id, beat !== null);
if (beat !== null) advances.push({ ...out.advance, tMs: out.tMs });
}
}
// Score against typed keys: same hit rule as the sim (−50..+400 ms), match on midi or ±12.
let hits = 0; const used = new Set(); const lat = [];
for (const k of keys) {
const cand = advances.find((a) => !used.has(a.id) && a.tMs >= k.t - 50 && a.tMs <= k.t + 400 && a.pitches.some((p) => p === k.midi || Math.abs(p - k.midi) === 12));
if (cand) { used.add(cand.id); hits++; lat.push(cand.tMs - k.t); }
}
// GROUND TRUTH WITHOUT A KEYBOARD. `hits`/`falses` above are scored against
// typed keys, and a MICROPHONE session has none: 20260916-154809 carries 0
// `input.key` events, so the unguarded `advances.length - used.size` read
// `false 212` for a session whose recorded precision is 237 advances with
// ZERO false ones — a phantom precision collapse, and the exact reading this
// file's header warns about. When there are no keys, score the replay against
// what the LIVE engine did instead: the trace's own accepted `follow.advance`
// records. Same index, within ±2 s of the recorded commit, each record used
// once and in order; recall is then "how much of the live session did this
// engine reproduce" and `false` is "advances the live engine never made".
let falses = keys.length ? advances.length - used.size : null;
let recall = keys.length ? hits / keys.length : null;
let truth = 'keys';
if (!keys.length) {
const live = tr.events.filter((e) => e.type === 'follow.advance' && e.accepted !== false);
if (live.length) {
const usedA = new Set();
let matched = 0;
for (const rec of live) {
const cand = advances.find((a) => !usedA.has(a.id) && a.toIndex === rec.toIndex && Math.abs(a.tMs - rec.t) <= 2000);
if (cand) { usedA.add(cand.id); matched++; lat.push(cand.tMs - rec.t); }
}
hits = matched;
falses = advances.length - usedA.size;
recall = matched / live.length;
truth = 'follow.advance x' + live.length;
}
}
const ceil = replayCeiling(keys, normaliseEvents(meta.rh));
// SINGLE NOTES AND CHORDS, SCORED SEPARATELY. A two-note event is a different
// problem from a one-note event — the fit has a voice BALANCE to get right as
// well as a pitch (see chordVoices in follower.js) — and one recall number
// over a score that is 95 % single notes hides it completely: on
// 20260916-154809 the session that reads 95 % overall is 95 % on its 241
// single notes and 64 % on its 14 chords. "Detected" is "some advance landed
// on that index", over the events up to the furthest one reached, which is
// the same population the engine had a chance at.
const det = new Set(advances.map((a) => a.toIndex));
const reached = advances.length ? Math.max(...advances.map((a) => a.toIndex)) : -1;
let nS = 0, hS = 0, nC = 0, hC = 0; const missC = [];
for (let i = 0; i <= reached; i++) {
const ps = solo[i] ? solo[i].pitches : [];
if (ps.length > 1) { nC++; if (det.has(i)) hC++; else missC.push(`${i}[${ps.join(',')}]`); }
else if (ps.length === 1) { nS++; if (det.has(i)) hS++; }
}
// PRECISION WITHOUT A KEYBOARD. `false` above counts advances the live engine
// did not make at the same time, and most of those are the SAME event
// committed earlier or later than the live session managed — not a note the
// player never played. The failure that actually matters is the engine
// getting AHEAD of the player, so this reports the worst the replay's
// committed index ever ran over the live session's, that session's own
// precision being the reference (20260916-154809: 237 advances, zero false).
let lead = null;
if (truth !== 'keys') {
const liveAdv = tr.events.filter((e) => e.type === 'follow.advance' && e.accepted !== false).slice().sort((a, b) => a.t - b.t);
let li = 0, lc = -1, ri = 0, rc = -1;
lead = -Infinity;
for (const t of [...new Set(liveAdv.map((a) => a.t).concat(advances.map((a) => a.tMs)))].sort((a, b) => a - b)) {
while (li < liveAdv.length && liveAdv[li].t <= t) lc = liveAdv[li++].toIndex;
while (ri < advances.length && advances[ri].tMs <= t) rc = advances[ri++].toIndex;
if (rc - lc > lead) lead = rc - lc;
}
}
const m = { keys: keys.length, hits, recall, false: falses, truth, advances: advances.length, latencyMedian: quantile(lat, 0.5), latencyP90: quantile(lat, 0.9), ceiling: ceil.exact, ceilingOctave: ceil.octave, resyncs: resyncs.length,
reached, singles: `${hS}/${nS}`, chords: `${hC}/${nC}`, chordRate: nC ? hC / nC : null, leadOverLive: Number.isFinite(lead) ? lead : null };
if (args.json) console.log(JSON.stringify(m));
else {
console.log(`replay ${path.basename(args.replay)}: keys ${m.keys} hits ${hits} recall ${fmt(m.recall, 2)} false ${fmt(falses, 0)} adv ${advances.length} latency med ${fmt(m.latencyMedian)} p90 ${fmt(m.latencyP90)} ms resync ${resyncs.length}`);
if (truth !== 'keys') console.log(` no keyboard ground truth in this trace (0 input.key); scored against the session's own ${truth} records, so recall = share of the live commits this engine reproduced`);
console.log(` reached event ${reached} singles ${m.singles} (${fmt(100 * hS / Math.max(1, nS))}%) CHORDS ${m.chords} (${nC ? fmt(100 * hC / nC) : '-'}%)${missC.length ? ' missed: ' + missC.join(' ') : ''}`);
if (m.leadOverLive !== null) console.log(` worst lead over the live engine's committed index: ${m.leadOverLive >= 0 ? '+' : ''}${m.leadOverLive} events`);
for (const r of resyncs) console.log(` resync t ${(r.tMs / 1000).toFixed(1)}s ${r.fromIndex} -> ${r.toIndex} (+${r.distance}) margin ${fmt(r.marginNats, 2)} lead ${fmt(r.leadNats, 2)} new ${fmt(r.meanNewShare, 2)} ctx ${r.contextOnsets}/${r.contextMs}ms ${r.mode}`);
// The ceiling is a statement about TYPED KEYS (see the note below); with no
// keys there is nothing to take a ceiling of, and printing 0/0 reads as a
// failure rather than an absence.
if (keys.length) console.log(` ceiling ${ceil.exact}/${keys.length} keys are reachable in score order (${ceil.octave} under the scorer's own ±12 rule); hits ${hits} = ${fmt(100 * hits / Math.max(1, ceil.octave))}% of it`);
}
}
// THE CEILING, and why the line above exists. `recall` here is hits over TYPED
// KEYS, and a typed key is not a thing a score follower can always answer: it
// may be a note the score does not contain at that point, and the engine is
// RIGHT to refuse it. On 20260915-083040 that is not a detail — 24 of the 28
// keys the shipped engine "misses" are G2 presses made while the score asks
// for C3 or B2 (the player held G2 down 15.7-21.5 s and again 38.5-53.7 s,
// testing the very note this workflow is about), so they are unreachable by
// anything that does not advance on notes the player did not play.
//
// The bound below is generous on purpose: the longest subsequence of keys that
// can be matched to score events in increasing order, one event per key. It
// ignores timing, ignores the onset gates and lets the engine skip ahead for
// free, so nothing real can beat it. On 20260915-083040 it is 30 of 56 keys
// (35 if octave answers are allowed, which is what the ±12 hit rule does) —
// i.e. "recall 0.50" against a reachable 0.54/0.63, not against 1.00, and the
// headroom this session can show is TWO keys, not twenty-eight. Five template
// rewrites were judged on the difference between 28 and 23; that difference is
// within the alignment noise of a session where the scored hits include a jump
// of five events credited to a G2 because the event landed on was its octave.
function replayCeiling(keys, events) {
const K = keys.map((k) => k.midi), E = events.map((e) => e.pitches);
const lcs = (match) => {
const n = K.length, m = E.length;
let next = new Int32Array(m + 1), cur = new Int32Array(m + 1);
for (let i = n - 1; i >= 0; i--) {
cur = new Int32Array(m + 1);
for (let j = m - 1; j >= 0; j--) {
let best = Math.max(next[j], cur[j + 1]);
if (E[j].some((p) => match(K[i], p))) best = Math.max(best, 1 + next[j + 1]);
cur[j] = best;
}
next = cur;
}
return cur[0];
};
return { exact: lcs((k, p) => k === p), octave: lcs((k, p) => k === p || Math.abs(k - p) === 12) };
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
const args = parseArgs(process.argv.slice(2));
if (args.replay) { replay(args); return; }
const names = args.all ? Object.keys(SCENARIOS) : [args.scenario || 'bleed'];
for (const n of names) if (!SCENARIOS[n]) { console.error('unknown scenario ' + n + '; known: ' + Object.keys(SCENARIOS).join(', ')); process.exit(2); }
const seeds = []; for (let s = 0; s < Math.max(1, args.seeds); s++) seeds.push(args.seed + s);
const summary = {};
const rows = [];
let anyFail = false;
for (const name of names) {
const sc = SCENARIOS[name];
const variants = sc.sweep ? sc.sweep : [sc.mut];
const runs = [];
for (const mut of variants) for (const seed of seeds) {
const fires = [];
// SIM_DEBUG=t0,t1 (seconds of audio) prints one line per frame in that window.
const dbg = process.env.SIM_DEBUG ? process.env.SIM_DEBUG.split(',').map(Number) : null;
const onFrame = dbg ? (frame, out, follower) => {
const t = (out.tMs - SIM_TRANSPORT_MS) / 1000;
if (t < dbg[0] || t > dbg[1]) return;
const post = out.posterior.map((p) => `${p.i}:${p.p.toFixed(2)}`).join(' ');
const ex = out.explained ? `b${out.explained.bleed.toFixed(2)} p${out.explained.prev.toFixed(2)} n${out.explained.new.toFixed(2)} o${out.explained.other.toFixed(2)}` : '-';
let hj = '';
if (process.env.SIM_DEBUG_H && (out.hold || out.advance)) {
const idx = out.hold ? out.hold.index : out.advance.toIndex;
const h = follower._hyps.filter((q) => q.i === idx).sort((a, b) => b.logW - a.logW)[0];
const par = follower._hyps.filter((q) => q.i === follower._committed).sort((a, b) => b.logW - a.logW)[0];
if (h) hj = ` H[dist${(h.distinct || 0).toFixed(3)} mask${(() => { try { const m = follower._distinctMask(h.i); const rows = []; for (let b = 0; b < m.length; b++) if (m[b]) rows.push(b + 33); return rows.join('/'); } catch (e) { return '?'; } })()} cap${h.pvCap === Infinity ? 'inf' : h.pvCap.toFixed(0)} gPv${h.gPv.toFixed(0)} gNw${h.gNw.toFixed(0)} born${((h.born - SIM_TRANSPORT_MS) / 1000).toFixed(2)} tOn${((h.tOn - SIM_TRANSPORT_MS) / 1000).toFixed(2)} ${h.onsetDriven ? 'on' : 'soft'}]`;
if (h && par && process.env.SIM_DEBUG_ELL) {
let X2 = 0; for (const v of follower._x) X2 += v * v;
const Bs = follower._bleedVectors(out.tMs);
const rh = follower._hypLikelihood(h, follower._x, X2, Bs), rp = follower._hypLikelihood(par, follower._x, X2, Bs);
hj += ` acc[${follower._accomp.map((a) => a.pitches.join('+') + '@' + ((a.atMs - SIM_TRANSPORT_MS) / 1000).toFixed(2) + (follower._envA(a, out.tMs) > 0.05 ? '*' : '')).join(' ')}] nB${Bs.length} gH[${Array.from(rh.gains || []).map((g) => g.toFixed(0)).join(',')}] gP[${Array.from(rp.gains || []).map((g) => g.toFixed(0)).join(',')}]`;
hj += ` ell H${rh.ell.toFixed(2)} P${rp.ell.toFixed(2)} rssH${(rh.rss / X2).toFixed(3)} rssP${(rp.rss / X2).toFixed(3)} exH n${rh.explained.new.toFixed(2)} p${rh.explained.prev.toFixed(2)} logW H${h.logW.toFixed(1)} P${par.logW.toFixed(1)}`;
}
if (process.env.SIM_DEBUG_RES && follower._residual) {
const res = follower._residual, x = follower._x;
let X2 = 0; for (const v of x) X2 += v * v;
const top = Array.from(res).map((v, b) => [v * v / X2, b + 33]).sort((a, b) => b[0] - a[0]).slice(0, 8);
hj += ` RES[${top.map(([e, r]) => r + ':' + (e * 100).toFixed(1) + '%').join(' ')}]`;
const hc = follower._hyps.filter((q) => q.i === follower._committed).sort((a, b) => b.logW - a.logW)[0];
if (hc && hc.res) hj += ` C[${hc.res.comps.map((c, k) => c.name + '=' + hc.res.gains[k].toFixed(0)).join(' ')} caps f0${hc.nwCapF0 === Infinity ? 'inf' : hc.nwCapF0.toFixed(0)} ov${hc.nwCapOv === Infinity ? 'inf' : hc.nwCapOv.toFixed(0)}]`;
const xt = Array.from(x).map((v, b) => [v * v / X2, b + 33]).sort((a, b) => b[0] - a[0]).slice(0, 8);
hj += ` X[${xt.map(([e, r]) => r + ':' + (e * 100).toFixed(1) + '%').join(' ')}]`;
}
if (h && h.res) { let X2 = 0; for (const v of follower._x) X2 += v * v; hj += ` pitchΔ${follower._pitchCheck(h, follower._x, X2, follower._bleedNow).toFixed(2)}`; }
// The leading alternatives of the pitch gate (q:accumulated nats), so a 'pitch' hold names its rival.
if (h && h.alt && h.alt.size) hj += ` alt[${[...h.alt.entries()].sort((a, b) => b[1].acc - a[1].acc).slice(0, 3).map(([q, v]) => pitchName(q) + ':' + v.acc.toFixed(2) + '/n' + (h.altN - v.n0)).join(' ')} n${h.altN}]`;
hj += ` os${follower._committed + 1 < follower._N ? follower._onsetScore(follower._committed + 1, out.tMs).toFixed(2) : '-'}`;
}
// The committed state's caps on every frame (not only on holds): a cap that
// never armed shows up as 'inf' long before the note it fails to bound.
if (process.env.SIM_DEBUG_H) {
const par = follower._hyps.filter((q) => q.i === follower._committed).sort((a, b) => b.logW - a.logW)[0];
if (par) hj += ` P[cap${par.pvCap === Infinity ? 'inf' : par.pvCap.toFixed(0)} L${par.pvL.toFixed(0)} gPv${par.gPv.toFixed(0)} gNw${par.gNw.toFixed(0)} capF0${par.nwCapF0 === Infinity ? 'inf' : par.nwCapF0.toFixed(0)} ex n${par.lastExplained ? par.lastExplained.new.toFixed(2) : '-'} p${par.lastExplained ? par.lastExplained.prev.toFixed(2) : '-'} o${par.lastExplained ? par.lastExplained.other.toFixed(2) : '-'}]`;
}
// SIM_DEBUG_BEAM=1: every live hypothesis (index, weight, evidence, birth, parent) per frame.
if (process.env.SIM_DEBUG_BEAM) {
const beam = follower._hyps.slice().sort((a, b) => b.logW - a.logW).map((h) => `${h.i}${h.repeatLike ? 'r' : ''}${h.onsetDriven ? '' : 's'}<${h.from ?? '-'} w${h.logW.toFixed(1)} ev${h.ev.toFixed(1)} n${h.lastExplained ? h.lastExplained.new.toFixed(2) : '-'} b${((h.born - SIM_TRANSPORT_MS) / 1000).toFixed(2)}`);
hj += ` BEAM[${beam.join(' | ')}] W${follower._wrong.logW.toFixed(1)}`;
}
if (process.env.SIM_DEBUG_X) {
const x = follower._x; const b = follower._best();
const tpl = b && b.i >= 0 && b.i < follower._N ? follower._ev[b.i].template : null;
const row = (v) => Array.from(v).map((q, i) => q > 0.05 ? `${i + 33}:${q.toFixed(2)}` : null).filter(Boolean).join(' ');
console.log(' x ', row(x)); if (tpl) console.log(' Nw ', row(tpl)); if (b && follower._pv[b.i]) console.log(' Pv ', row(follower._pv[b.i]));
}
console.log(`${t.toFixed(3)} ${out.informative ? 'I' : '.'} λ${out.lambda.toFixed(2)} db${frame.db.toFixed(0)} on${out.onset.ratio.toFixed(1)}${out.onset.hit ? '*' : ' '} r${out.onset.rise.toFixed(1)}${out.onset.evidence ? '!' : ' '} pk${frame.peak} best${out.best ? out.best.i : '-'} [${post}] pW${out.pWrong.toFixed(3)} ex ${ex}${out.hold ? ' HOLD ' + out.hold.reason + '@' + out.hold.index + ' p' + out.hold.p.toFixed(2) + ' m' + out.hold.margin.toFixed(2) + ' ev' + out.hold.ev.toFixed(1) + (out.hold.aheadMs !== null ? ' ahead' + Math.round(out.hold.aheadMs) + '/' + Math.round(out.hold.spanMs) + ' bps' + out.hold.bps.toFixed(2) : '') : ''}${out.advance ? ' ADV->' + out.advance.toIndex + ' ev' + out.advance.ev.toFixed(1) + ' new' + out.advance.explained.new.toFixed(2) : ''}${out.state ? ' STATE ' + out.state.mode : ''}${hj}`);
} : null;
const run = runOne(args, name, mut, seed, { onFire: (f) => fires.push(f), onFrame });
run.fires = fires; run.scenario = name; run.seed = seed; run.mut = mut;
runs.push(run);
}
const worst = worstOf(runs, sc.tgt);
const fails = evaluate(worst.metrics, sc.tgt);
if (fails.length) anyFail = true;
const m = worst.metrics;
summary[name] = { recall: r3(m.recall), false: m.false, early: m.early, missed: m.missed, latencyMedian: r1(m.latencyMedian), latencyP90: r1(m.latencyP90), onsetErrMedian: r1(m.onsetErrMedian), tempoRmse: r3(m.tempoRmse), pass: fails.length === 0, seed: worst.seed, variant: worst.mut };
rows.push({ name, m, fails, worst });
if (!args.quiet && !args.json) {
const label = sc.sweep ? `${name} (${Object.entries(worst.mut).map(([k, v]) => k + ' ' + (v && typeof v === 'object' ? JSON.stringify(v) : v)).join(' ')})` : name;
console.log(`${(fails.length ? 'FAIL' : 'ok ')} ${label.padEnd(24)} recall ${fmt(m.recall, 2)} (${m.hits}/${m.played}) false ${m.false} early ${m.early} miss ${m.missed} lat ${fmt(m.latencyMedian)}/${fmt(m.latencyP90)} ms onset ${fmt(m.onsetErrMedian)}/${fmt(m.onsetErrP90)} tempo ${fmt(m.tempoRmse, 3)} rej ${m.rejects} stall ${fmt(m.stall, 2)}s drift ${m.driftMax}/${fmt(m.driftRunSec, 1)}s resync ${m.resyncs}${m.resyncBad ? '!' + m.resyncBad : ''} pW ${fmt(m.pWrongMedian, 2)}/${fmt(m.pWrongRunMs / 1000, 1)}s seed ${worst.seed}${fails.length ? ' <- ' + fails.join('; ') : ''}`);
if (fails.length || process.env.SIM_VERBOSE) {
if (m.falseList.length) console.log(' false:', JSON.stringify(m.falseList));
if (m.earlyList.length) console.log(' early:', JSON.stringify(m.earlyList));
if (m.resyncBadList.length) console.log(' resync landed wrong:', JSON.stringify(m.resyncBadList));
if (m.missedList.length) console.log(' missed:', JSON.stringify(m.missedList));
console.log(' holds:', JSON.stringify(m.holdsByReason), ' states:', m.states.join(' '), ' bleedLag med', fmt(m.bleedLagMedian));
// Every armed wide scan and why it did or did not fire — the resync's
// own audit line (follower.js out.resyncScan), so a scenario that
// drifts can be told from one whose search was refused.
console.log(' wide scans:', m.scans, JSON.stringify(m.scansByReason), ' margin med/p90', fmt(m.scanMarginMed, 2), '/', fmt(m.scanMarginP90, 2), ' lead med', fmt(m.scanLeadMed, 2), ' dist med', fmt(m.scanDistMed),
' top-1 err med', fmt(m.scanErrMed), ' |err|<=1', fmt(m.scanErrAbs1, 2), ' |err|<=2', fmt(m.scanErrAbs2, 2), ' ahead', m.scanErrAhead);
}
}
if (args.timeline) printTimeline(worst);
if (args.wav && names.length === 1) writeWav(args.wav, worst.perf.mix, worst.cfg.sr);
if (args.out && names.length === 1) fs.writeFileSync(args.out, JSON.stringify(buildTrace(worst, args)));
}
if (args.json) console.log(JSON.stringify(summary, null, 1));
else if (!args.quiet) {
const passed = rows.filter((r) => !r.fails.length).length;
console.log(`${passed}/${rows.length} scenarios pass (seeds ${seeds.join(',')}; engine ${FOLLOWER_VERSION})`);
}
process.exit(anyFail ? 1 : 0);
}
main();