notepilot / scripts /test_mic_scope.mjs
jasonmdong's picture
Sync from GitHub via hub-sync
626e298 verified
Raw History Blame Contribute Delete
21.1 kB
// 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');