// The mic scope is a VIEW, so its failure mode is silent: one NaN coordinate // or a zero-height pitch axis produces a blank canvas and no error. This drives // the shipped drawing code against a recording 2D context and asserts every // coordinate it emits is finite and on-canvas, for the edge cases that actually // occur -- an empty buffer, a buffer of nothing but "no candidate", a single // repeated pitch, and pitches at the very ends of the keyboard. import assert from 'node:assert/strict'; import fs from 'node:fs'; import vm from 'node:vm'; const app = fs.readFileSync(new URL('../static/app.js', import.meta.url), 'utf8'); const source = app.slice(app.indexOf('const SCOPE_MAX_MS'), app.indexOf('function publishBlindPitch')); assert(source.includes('function _drawMicScope'), 'scope source not found'); const W = 600, H = 132; function rig({ expected = [], light = false } = {}) { const calls = []; const ctx = new Proxy({}, { get(_, k) { if (k === 'canvas') return null; return (...args) => { calls.push({ op: String(k), args }); }; }, set(_, k, v) { calls.push({ op: 'SET:' + String(k), args: [v] }); return true; }, }); const canvas = { clientWidth: W, clientHeight: H, width: 0, height: 0, getContext: () => ctx }; const els = { 'mic-scope': canvas, 'mic-scope-freeze': { classList: { toggle() {} }, textContent: '' } }; const context = vm.createContext({ document: { hidden: false, getElementById: (id) => els[id] || null, body: { classList: { contains: () => light } } }, window: { devicePixelRatio: 2 }, performance: { now: () => 0 }, FOLLOWER_OPTIONS: { win: 2048 }, FOLLOWER_VERSION_TAG: 'test', Blob: class { constructor(p) { this.size = String(p[0]).length; } }, URL: { createObjectURL: () => 'blob:x', revokeObjectURL() {} }, Date, state: { tracker: { position: 0, score: [[expected, 0]] } }, eventPitches: (ev) => ev[0], }); vm.runInContext(source + ` this.push = _scopePush; this.mark = _scopeMarkAdvance; this.draw = _drawMicScope; this.pushSal = (now, sal, frame) => { // a session starts in a quiet room: prime the per-row floor once if (!_scopeNoiseMean) _scopeRemoveRoom(new Float32Array(64).fill(1e-9), now - 1, false); return _scopePushSal(now, sal, frame); }; this.span = setMicScopeSpan; this.freeze = toggleMicScopeFreeze; this.axis = () => [_scopeLo, _scopeHi]; this.useTemplates = (t, w) => { _scopeTemplates = t; _scopeTemplateEvidence = w || null; _scopeTemplatesTried = true; }; this.dump = micScopeData;`, context); return { calls, ...context }; } /** Every numeric argument the drawing code emits must be finite and on-canvas. */ function assertSane(calls, label) { let drew = 0; for (const { op, args } of calls) { for (const a of args) { if (typeof a !== 'number') continue; assert.ok(Number.isFinite(a), `${label}: ${op} got a non-finite argument (${a})`); } if (op === 'fillRect' || op === 'clearRect') { const [x, y, w, h] = args; assert.ok(w >= 0 && h >= 0, `${label}: ${op} has negative size ${w}x${h}`); assert.ok(x >= -1 && x <= W + 1, `${label}: ${op} x=${x} is off-canvas`); assert.ok(y >= -1 && y <= H + 1, `${label}: ${op} y=${y} is off-canvas`); if (op === 'fillRect') drew++; } if (op === 'arc') { const [cx, cy, r] = args; assert.ok(r > 0, `${label}: arc radius ${r}`); assert.ok(cx >= -1 && cx <= W + 1 && cy >= -1 && cy <= H + 1, `${label}: arc off-canvas`); } } return drew; } const sample = (t, midiFloat, extra = {}) => ({ t, midiFloat, valid: true, ...extra }); // ── 1. The degenerate buffers, which are what a blank canvas comes from ─── { const r = rig(); r.draw(0, true); assertSane(r.calls, 'empty buffer'); const [lo, hi] = r.axis(); assert.ok(hi - lo >= 1, `empty buffer collapsed the pitch axis to ${hi - lo} semitones`); } { const r = rig(); // 200 frames in which the observer had no candidate at all -- the F2 case. for (let i = 0; i < 200; i++) r.push(i * 10, { midiFloat: null, valid: false }); r.draw(2000, true); assertSane(r.calls, 'all-null buffer'); assert.ok(r.axis()[1] - r.axis()[0] >= 1, 'all-null buffer collapsed the axis'); } { // A single pitch repeated: the axis must not collapse to zero height, which // would make every y() a division by zero. const r = rig(); for (let i = 0; i < 100; i++) r.push(i * 10, { midiFloat: 41, valid: true }); for (let i = 0; i < 40; i++) r.draw(1000, true); // let the smoothing settle assertSane(r.calls, 'single pitch'); const [lo, hi] = r.axis(); assert.ok(hi - lo >= 17, `single pitch gave only ${(hi - lo).toFixed(1)} semitones of axis`); assert.ok(lo < 41 && hi > 41, 'the played pitch must be inside the axis'); } // ── 2. Keyboard extremes must stay on-canvas ────────────────────────────── for (const m of [21, 24, 33, 41, 60, 96, 105, 108]) { const r = rig(); for (let i = 0; i < 60; i++) r.push(i * 10, { midiFloat: m, valid: true }); for (let i = 0; i < 40; i++) r.draw(600, true); assertSane(r.calls, `pitch ${m}`); const [lo, hi] = r.axis(); assert.ok(hi - lo >= 1, `pitch ${m} collapsed the axis`); } // ── 3. A realistic intermittent low note actually puts marks on screen ──── // This is the user's F2: resolved on some windows, blank on others. { const r = rig({ expected: [41] }); for (let i = 0; i < 400; i++) { const heard = i % 7 < 2; // fires on 2 of every 7 windows r.push(i * 10, heard ? { midiFloat: 41.1, valid: true, method: 'yin-4096-lowband' } : { midiFloat: null, valid: false }); } r.mark(3200, 41); r.draw(4000, true); const drew = assertSane(r.calls, 'intermittent F2'); assert.ok(drew > 50, `expected the heard windows to be drawn, got ${drew} fillRects`); assert.ok(r.calls.some((c) => c.op === 'arc'), 'the committed advance was not marked'); assert.ok(r.calls.some((c) => c.op === 'stroke'), 'no gridlines or advance lines drawn'); } // ── 4. Freeze holds the window still; Live resumes ──────────────────────── { const r = rig(); for (let i = 0; i < 100; i++) r.push(i * 10, { midiFloat: 60, valid: true }); r.freeze(); const before = r.calls.length; r.draw(50_000, true); // far past every sample assertSane(r.calls.slice(before), 'frozen'); r.freeze(); // back to live r.draw(1000, true); assertSane(r.calls, 'after unfreeze'); } // ── 5. Every offered span works ─────────────────────────────────────────── { for (const ms of [4000, 8000, 15000, 30000]) { const r = rig({ expected: [43, 60] }); for (let i = 0; i < 600; i++) r.push(i * 50, { midiFloat: 43 + (i % 20), valid: i % 3 !== 0 }); r.span(String(ms)); r.draw(30_000, true); assertSane(r.calls, `span ${ms}`); } } // ── 6. The ring buffer stays bounded ────────────────────────────────────── { const r = rig(); for (let i = 0; i < 20000; i++) r.push(i * 10, { midiFloat: 60, valid: true }); r.draw(200_000, true); assertSane(r.calls, 'long session'); } // ── 7. Real strikes, through the harmonic projection the scope actually uses ─ // The heat layer plots a PITCH salience, not the analyser bank: the bank's rows // are a semitone spectrum, so drawn raw a note's partials are as bright as the // note (on the chord below the brightest raw row was F4, which nobody played). { const sal = await import('file://' + new URL('../static/salience.js', import.meta.url).pathname); const fb = sal.buildFilterbank(48000, 2048, 33, 96); const templates = [], evidence = new Float32Array(64); for (let m = 33; m <= 96; m++) { templates[m - 33] = sal.harmonicTemplate(fb, m); evidence[m - 33] = Math.min(1, sal.pitchInBank(fb, m) / 3); } const trace = new URL('../.accompy_traces/20260921-184144_twinkle-twinkle-little-star.json', import.meta.url); const d = JSON.parse(fs.readFileSync(trace, 'utf8')); const at = (e) => e.sessionTimeMs ?? e.t ?? 0; const frames = d.events.filter((e) => e.type === 'follow.frame' && e.sal).sort((a, b) => at(a) - at(b)); /** Peak opacity the heat layer paints per pitch row, for one strike. */ function heatOf(t0) { const seg = frames.filter((e) => at(e) >= t0 + 180 && at(e) < t0 + 650); assert.ok(seg.length > 8, `not enough sustain frames at ${t0}`); const r = rig(); r.useTemplates(templates, evidence); for (const e of seg) { r.pushSal(at(e) - t0, sal.dequantiseSal(Uint8Array.from(e.sal)), { sampleRate: 48000, rms: 0.05, noiseFloor: 0.001 }); r.push(at(e) - t0, { midiFloat: null, valid: false }); } for (let i = 0; i < 39; i++) r.draw(650, true); r.calls.length = 0; r.draw(650, true); // measure one settled frame const [lo, hi] = r.axis(), HP = H - 4, TOP = 2; const bright = new Map(); let fill = null; for (const { op, args } of r.calls) { if (op === 'SET:fillStyle') { fill = String(args[0]); continue; } if (op !== 'fillRect' || args[2] !== 3) continue; const m = Math.round(lo + (1 - (args[1] - TOP) / HP) * (hi - lo)); const a = Number((fill.match(/,([\d.]+)\)$/) || [])[1] || 0); bright.set(m, Math.max(bright.get(m) || 0, a)); } assertSane(r.calls, `heat ${t0}`); return bright; } const maxOf = (h) => Math.max(...h.values()); const ownOf = (h, m) => Math.max(...[m - 1, m, m + 1].map((x) => h.get(x) || 0)); // A solo note must be as bright as anything on the strip. All 46 solo strikes // in this recording project as clean local maxima at 0.77-1.00 of the frame // peak; these four span the register, and include C3 -- the note the chord // case below loses -- so "C3 is just weak on this rig" is ruled out here. for (const [t0, midi] of [[3590, 48], [7700, 52], [14800, 43], [16020, 47]]) { const h = heatOf(t0); assert.ok(ownOf(h, midi) >= 0.9 * maxOf(h), `solo MIDI ${midi} painted at ${ownOf(h, midi).toFixed(3)} against a max of ${maxOf(h).toFixed(3)}`); } // The chord: two bands, which is what the layer exists for. The upper note is // full brightness and the lower one about half -- NOT equal, and the test says // so rather than pretending otherwise. Projecting each template independently // lets the upper note claim energy the two share, and at win=2048 this // register resolves to roughly three semitones. Both limits are in CLAUDE.md. for (const t0 of [21540, 60860]) { const h = heatOf(t0), mx = maxOf(h); assert.ok(ownOf(h, 52) >= 0.9 * mx, `chord ${t0}: E3 at ${ownOf(h, 52).toFixed(3)}`); assert.ok(ownOf(h, 48) >= 0.35 * mx, `chord ${t0}: C3 at ${ownOf(h, 48).toFixed(3)} is too dim to read as a second bar`); assert.ok(ownOf(h, 48) < 0.9 * mx, `chord ${t0}: C3 unexpectedly equals E3 -- if the fit improved, retune this`); } } // ── 7a. Degenerate high templates must not win ─────────────────────────── // Above MIDI 78 a harmonic template keeps fewer than three of its six partials // inside the bank, and from 88 up only one -- three non-zero rows. A band that // narrow correlates with any bright row near it, and the upper partials of the // notes actually played land there with no other template to claim them. On // 20260922-083545, A#6 (MIDI 94) came out the brightest row of five separate // chords played two to three octaves below it. { const salm = await import('file://' + new URL('../static/salience.js', import.meta.url).pathname); const fbx = salm.buildFilterbank(48000, 2048, 33, 96); const tpl = [], ev = new Float32Array(64); for (let m = 33; m <= 96; m++) { tpl[m - 33] = salm.harmonicTemplate(fbx, m); ev[m - 33] = Math.min(1, salm.pitchInBank(fbx, m) / 3); } const trace = new URL('../.accompy_traces/20260922-083545_twinkle-twinkle-little-star.json', import.meta.url); const d2 = JSON.parse(fs.readFileSync(trace, 'utf8')); const at2 = (e) => e.sessionTimeMs ?? e.t ?? 0; const frames2 = d2.events.filter((e) => e.type === 'follow.frame' && e.sal).sort((a, b) => at2(a) - at2(b)); let spurious = 0; for (const [t0, played] of [[4050, [50, 53]], [18490, [43, 53]], [19480, [43, 53]], [23240, [43, 53]], [24470, [43, 53]]]) { const seg = frames2.filter((e) => at2(e) >= t0 + 180 && at2(e) < t0 + 650); assert.ok(seg.length > 8, `no sustain frames at ${t0}`); const r = rig(); r.useTemplates(tpl, ev); for (const e of seg) { r.pushSal(at2(e) - t0, salm.dequantiseSal(Uint8Array.from(e.sal)), { sampleRate: 48000, rms: e.rms, noiseFloor: e.noiseFloorRms, gated: e.g === true }); r.push(at2(e) - t0, { midiFloat: null, valid: false }); } for (let i = 0; i < 39; i++) r.draw(650, true); r.calls.length = 0; r.draw(650, true); const [lo, hi] = r.axis(), HP = H - 4, TOP = 2; const bright = new Map(); let fill = null; for (const { op, args } of r.calls) { if (op === 'SET:fillStyle') { fill = String(args[0]); continue; } if (op !== 'fillRect' || args[2] !== 3) continue; const m = Math.round(lo + (1 - (args[1] - TOP) / HP) * (hi - lo)); const a = Number((fill.match(/,([\d.]+)\)$/) || [])[1] || 0); bright.set(m, Math.max(bright.get(m) || 0, a)); } const top = [...bright.entries()].sort((a, b) => b[1] - a[1])[0][0]; if (top > 85 && !played.some((p) => Math.abs(p - top) <= 2)) spurious++; } assert.equal(spurious, 0, `${spurious} of 5 chords were topped by a degenerate high row nobody played`); } // ── 7b. NOISE MUST NOT PAINT AS NOTES ──────────────────────────────────── // Each column is normalised to its own peak, so without a level check a frame // of pure room noise paints exactly as brightly as a struck chord -- every // frame has a peak. That is what made a laptop fan read as a solid band across // the bass, and the noise-gate slider did nothing about it because the heat // never consulted the gate at all. { const noisy = () => Float32Array.from({ length: 64 }, (_, b) => 0.4 + 0.6 * Math.abs(Math.sin(b * 2.3))); const heatRects = (r) => r.calls.filter((c) => c.op === 'fillRect' && c.args[2] === 3).length; // A gated frame paints nothing, however loud the bank looks. { const r = rig(); for (let i = 0; i < 60; i++) { r.pushSal(i * 10, noisy(), { rms: 0.9, noiseFloor: 0.001, gated: true }); r.push(i * 10, { midiFloat: null, valid: false }); } r.draw(600, true); assert.equal(heatRects(r), 0, 'a gated frame must paint no heat'); } // A frame sitting at the room floor paints nothing. { const r = rig(); for (let i = 0; i < 60; i++) { r.pushSal(i * 10, noisy(), { rms: 0.00102, noiseFloor: 0.001, gated: false }); r.push(i * 10, { midiFloat: null, valid: false }); } r.draw(600, true); assert.equal(heatRects(r), 0, 'a frame at the noise floor must paint no heat'); } // A real note well over the floor still paints. { const r = rig(); for (let i = 0; i < 60; i++) { r.pushSal(i * 10, noisy(), { rms: 0.05, noiseFloor: 0.001, gated: false }); r.push(i * 10, { midiFloat: null, valid: false }); } r.draw(600, true); assert.ok(heatRects(r) > 50, 'a frame well over the floor must still paint'); } // ...and it fades in between rather than snapping on. { const dim = (db) => { const r = rig(); for (let i = 0; i < 60; i++) { r.pushSal(i * 10, noisy(), { rms: 0.001 * 10 ** (db / 20), noiseFloor: 0.001, gated: false }); r.push(i * 10, { midiFloat: null, valid: false }); } r.draw(600, true); let fill = null, peak = 0; for (const { op, args } of r.calls) { if (op === 'SET:fillStyle') { fill = String(args[0]); continue; } if (op !== 'fillRect' || args[2] !== 3) continue; peak = Math.max(peak, Number((fill.match(/,([\d.]+)\)$/) || [])[1] || 0)); } return peak; }; const quiet = dim(5), loud = dim(20); assert.ok(quiet > 0 && quiet < loud, `5 dB over the floor (${quiet}) must be dimmer than 20 dB (${loud})`); } } // ── 7c. A FAN IS NOT A CHORD ─────────────────────────────────────────────── // Steady rumble in the bottom rows must be learned as noise, and then neither // paint nor be decomposed into notes, while a real note on top of it still // paints. The rig seeds the estimator with digital SILENCE, which is both the // worst case and a real one -- the first frames of a getUserMedia stream are // often silent -- so this also covers the escape hatch: seeded that low, every // frame looks loud and the estimator would never learn without it. { const rumble = () => Float32Array.from({ length: 64 }, (_, b) => (b < 8 ? 0.6 - 0.05 * b : 0.02) * (0.85 + 0.3 * Math.abs(Math.sin(b * 7.1)))); const r = rig(); const feed = (from, to, sal, frame) => { for (let i = from; i < to; i++) r.pushSal(i * 10, sal(), frame); }; const quiet = { rms: 0.004, noiseFloor: 0.001, gated: false, blindPitch: { valid: false } }; // The columns already drawn keep whatever they held, so each stage is judged // on a 4 s window containing only the columns that stage produced. r.span('4000'); // before the escape fires the rumble is still painted: the estimator is stranded feed(0, 300, rumble, quiet); r.calls.length = 0; r.draw(3000, true); assert.ok(r.calls.filter((c) => c.op === 'fillRect' && c.args[2] === 3).length > 0, 'a stranded estimator should still be painting the rumble before the escape fires'); // past SCOPE_NOISE_STUCK_MS it adopts the room; a window entirely after that // adoption must be empty feed(300, 900, rumble, quiet); r.calls.length = 0; r.draw(9000, true); const heat = r.calls.filter((c) => c.op === 'fillRect' && c.args[2] === 3).length; assert.equal(heat, 0, `learned rumble must paint no heat (painted ${heat})`); const dump = r.dump(); assert.equal(dump.detected.frames.filter((f) => f.t > 5000).length, 0, 'learned rumble must yield no detected notes'); // MUSIC MUST NOT BE ADOPTED AS THE ROOM. Notes that keep arriving hold the // level high for far longer than the escape's window, but they do not hold it // STEADY, so the escape must not fire and the notes must keep painting. { const m = rig(); m.span('4000'); for (let i = 0; i < 1200; i++) { const phase = i % 120; // a note every 1.2 s, decaying const amp = Math.exp(-phase / 30); const f = rumble(); f[27] += 40 * amp; f[39] += 12 * amp; m.pushSal(i * 10, f, { rms: 0.004 + 0.05 * amp, noiseFloor: 0.001, gated: false, blindPitch: { valid: false } }); } m.calls.length = 0; m.draw(12000, true); assert.ok(m.calls.filter((c) => c.op === 'fillRect' && c.args[2] === 3).length > 20, 'repeated notes must not be adopted as the room and subtracted away'); } // a note on top of the rumble still paints const note = rumble(); note[27] += 40; note[39] += 12; // C4 and its 2nd partial feed(900, 960, () => note, { rms: 0.05, noiseFloor: 0.001, gated: false, blindPitch: { valid: true, midi: 60 } }); r.calls.length = 0; r.draw(9600, true); assert.ok(r.calls.filter((c) => c.op === 'fillRect' && c.args[2] === 3).length > 20, 'a note over the rumble must still paint'); } // ── 8. The export carries the whole screen ──────────────────────────────── { const r = rig({ expected: [48, 52] }); for (let i = 0; i < 50; i++) { r.pushSal(i * 10, Float32Array.from({ length: 64 }, (_, b) => (b === 15 ? 1 : 0.01)), { sampleRate: 48000, rms: 0.05, noiseFloor: 0.001 }); r.push(i * 10, { midiFloat: i % 2 ? 48.2 : null, valid: true, method: 'yin-4096-lowband' }); } r.mark(400, 48); const dump = r.dump(); assert.equal(dump.kind, 'notepilot-mic-scope'); assert.equal(dump.heat.frames.length, 50, 'every heat frame must be exported'); assert.equal(dump.heat.minMidi, 33); assert.equal(dump.blind.length, 25, 'every blind candidate must be exported'); assert.ok(dump.blind[0].lowBand === true, 'the low-band flag must survive export'); assert.equal(dump.advances.length, 1); assert.deepEqual(dump.expected, [48, 52]); assert.ok(JSON.stringify(dump).length > 500, 'export looks empty'); } console.log('PASS: mic scope draws finite, on-canvas geometry for empty, all-null, ' + 'single-pitch, keyboard-extreme, intermittent, frozen and long-session buffers; ' + 'real solo strikes are the brightest row; a real C3+E3 lights both notes; export is complete');