File size: 21,120 Bytes
626e298
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
// 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');