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