Spaces:
Sleeping
Sleeping
Download scripts/test_mic_scope.mjs from jasonmdong/notepilot: direct link, hf CLI and curl.
- Browser
- Download file 21.1 kB
-
https://huggingface.co/spaces/jasonmdong/notepilot/resolve/main/scripts/test_mic_scope.mjs
- Command line
-
hf download hf://spaces/jasonmdong/notepilot/scripts/test_mic_scope.mjs
-
curl -L -o test_mic_scope.mjs https://huggingface.co/spaces/jasonmdong/notepilot/resolve/main/scripts/test_mic_scope.mjs
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'); | |