#!/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 ` 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();