diff --git a/frontend/src/__tests__/buzzer-metronome.test.ts b/frontend/src/__tests__/buzzer-metronome.test.ts new file mode 100644 index 00000000..699ff911 --- /dev/null +++ b/frontend/src/__tests__/buzzer-metronome.test.ts @@ -0,0 +1,199 @@ +/** + * Buzzer — metronome quality + * + * A real use case for the buzzer: an Arduino sketch driving it as a metronome + * (analogWrite/Timer PWM, an accent on the down-beat). This exercises the audio + * SCHEDULING that makes a metronome usable — and guards it against regressions. + * + * It drives the buzzer's PWM handler with a metronome sequence (onset → note-off + * pairs carrying their simulated timestamps) against a CONTROLLABLE audio clock, + * and records the resulting Web-Audio schedule (one oscillator per click). Then + * it asserts the qualities a metronome needs: + * - even onset spacing (steady tempo) + * - one oscillator per click, no overlap, strictly monotonic + * - correct pitch per metric level (accent vs beat) + * - bursty per-frame delivery is absorbed (still even) + * - a tempo change re-locks immediately and cleanly + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { PartSimulationRegistry } from '../simulation/parts/PartSimulationRegistry'; +import '../simulation/parts/ComplexParts'; + +// ── Controllable audio clock + schedule recorder ───────────────────────────── +let clock = 0; // seconds; we advance it to emulate real time passing +type Ev = { kind: 'start' | 'stop'; when: number; freq?: number }; +let sched: Ev[] = []; + +function mockOscillator() { + const o: any = { + type: '', + frequency: { value: 440, setValueAtTime: vi.fn(), linearRampToValueAtTime: vi.fn() }, + connect: vi.fn(), + disconnect: vi.fn(), + onended: null, + start: (w: number) => sched.push({ kind: 'start', when: w, freq: Math.round(o.frequency.value) }), + stop: (w: number) => sched.push({ kind: 'stop', when: w }), + }; + return o; +} +function mockGain() { + return { + gain: { value: 0, setValueAtTime: vi.fn(), linearRampToValueAtTime: vi.fn() }, + connect: vi.fn(), + disconnect: vi.fn(), + }; +} +class MockAudioContext { + state = 'running'; + destination = {}; + resume = vi.fn(); + close = vi.fn(); + get currentTime() { + return clock; + } + createOscillator() { + return mockOscillator(); + } + createGain() { + return mockGain(); + } +} + +beforeEach(() => { + clock = 0; + sched = []; + vi.stubGlobal('AudioContext', MockAudioContext as unknown as typeof AudioContext); +}); +afterEach(() => { + vi.unstubAllGlobals(); +}); + +// ── Metronome driver ───────────────────────────────────────────────────────── +const OCR2A = 0xb3; +const TCCR2B = 0xb1; +// OCR2A value for a target frequency (CTC, prescaler 64): f = 16e6/(2*64*(OCR+1)). +// Integer division, matching the firmware's `F_CPU/(2*64*freq) - 1`. +const ocrFor = (freq: number) => Math.floor(125000 / freq) - 1; +const ACCENT = ocrFor(1500); // 82 -> 1506 Hz +const BEAT = ocrFor(1100); // 112 -> 1106 Hz + +function setupBuzzer() { + const sim: any = { + cpu: { data: new Uint8Array(512).fill(0), cycles: 0 }, + pinManager: { + onPinChange: vi.fn().mockReturnValue(() => {}), + }, + }; + let pwm: ((pin: number, dc: number, timeMs?: number) => void) | null = null; + sim.pinManager.onPwmChange = vi.fn().mockImplementation((_pin: number, cb: any) => { + pwm = cb; + return () => {}; + }); + const cleanup = PartSimulationRegistry.get('buzzer')!.attachEvents!( + { addEventListener: vi.fn(), removeEventListener: vi.fn(), playing: false } as any, + sim, + (name: string) => (name === '1' ? 11 : null), + ); + return { sim, cleanup, hit: (ocr: number, simMs: number, clickMs = 25) => { + // onset: OCR set, PWM duty > 0 at simMs + sim.cpu.data[OCR2A] = ocr; + sim.cpu.data[TCCR2B] = 0x04; // CS22 -> prescaler 64 + clock = simMs / 1000; + pwm!(11, ocr / 255, simMs); + // note-off one click later + clock = (simMs + clickMs) / 1000; + pwm!(11, 0, simMs + clickMs); + } }; +} + +// Pull the recorded notes (a start paired with its following stop). +function notes() { + const out: { on: number; off: number; freq: number }[] = []; + for (let i = 0; i + 1 < sched.length; i++) { + if (sched[i].kind === 'start' && sched[i + 1].kind === 'stop') { + out.push({ on: sched[i].when, off: sched[i + 1].when, freq: sched[i].freq! }); + } + } + return out; +} + +describe('Buzzer — metronome quality', () => { + it('plays an even metronome with an accent on the down-beat', () => { + const { hit } = setupBuzzer(); + const beat = 500; // ms — quarter notes @ 120 BPM + for (let i = 0; i < 8; i++) hit(i % 4 === 0 ? ACCENT : BEAT, i * beat); + + const ns = notes(); + expect(ns.length).toBe(8); + // even spacing (steady tempo) + for (let i = 1; i < ns.length; i++) { + const gap = (ns[i].on - ns[i - 1].on) * 1000; + expect(Math.abs(gap - beat)).toBeLessThan(5); + } + // one oscillator per click, no overlap, monotonic + for (let i = 0; i < ns.length; i++) { + expect(ns[i].off).toBeGreaterThan(ns[i].on); + if (i > 0) { + expect(ns[i].on).toBeGreaterThan(ns[i - 1].on); + expect(ns[i - 1].off).toBeLessThanOrEqual(ns[i].on + 1e-6); + } + } + // correct pitch per metric level + expect(Math.abs(ns[0].freq - 1506)).toBeLessThanOrEqual(2); // accent + expect(Math.abs(ns[1].freq - 1106)).toBeLessThanOrEqual(2); // beat + }); + + it('absorbs bursty per-frame delivery (onsets even in sim time, jittery in wall time)', () => { + const { sim, cleanup } = setupBuzzer(); + void cleanup; + let pwm!: (p: number, dc: number, t?: number) => void; + sim.pinManager.onPwmChange.mock.calls; // noop ref + // re-grab the callback captured during setup + pwm = sim.pinManager.onPwmChange.mock.calls[0][1]; + + const beat = 250; // even sim spacing + // wall-clock delivery jitters by ~one 60fps frame each onset (a real burst) + const wobble = [0, -15, 12, -10, 14, -13, 10, -8, 0, 11]; + for (let i = 0; i < 10; i++) { + const simMs = i * beat; + sim.cpu.data[OCR2A] = i % 4 === 0 ? ACCENT : BEAT; + sim.cpu.data[TCCR2B] = 0x04; + clock = (simMs + wobble[i]) / 1000; // jittery wall time + pwm(11, 0.3, simMs); + clock = (simMs + 25 + wobble[i]) / 1000; + pwm(11, 0, simMs + 25); + } + const ns = notes(); + expect(ns.length).toBeGreaterThanOrEqual(8); + // despite the wall-clock jitter, scheduled onsets stay even (de-jittered) + for (let i = 2; i < ns.length; i++) { + const gap = (ns[i].on - ns[i - 1].on) * 1000; + expect(Math.abs(gap - beat)).toBeLessThan(20); + expect(ns[i - 1].off).toBeLessThanOrEqual(ns[i].on + 1e-6); // no overlap + } + }); + + it('re-locks immediately and cleanly when the tempo changes', () => { + const { hit } = setupBuzzer(); + let t = 0; + for (let i = 0; i < 5; i++) { + hit(BEAT, t); + t += 500; + } // 120 BPM + for (let i = 0; i < 6; i++) { + hit(BEAT, t); + t += 250; + } // jump to 240 BPM + + const ns = notes(); + expect(ns.length).toBe(11); + // no overlap / no backward note across the change + for (let i = 1; i < ns.length; i++) { + expect(ns[i].on).toBeGreaterThan(ns[i - 1].on); + expect(ns[i - 1].off).toBeLessThanOrEqual(ns[i].on + 1e-6); + } + // settled to the new rate within a beat of the change (no drift/creep) + const lastGap = (ns[ns.length - 1].on - ns[ns.length - 2].on) * 1000; + expect(Math.abs(lastGap - 250)).toBeLessThan(10); + }); +}); diff --git a/frontend/src/__tests__/simulation-parts.test.ts b/frontend/src/__tests__/simulation-parts.test.ts index 7952fea2..32a86bd1 100644 --- a/frontend/src/__tests__/simulation-parts.test.ts +++ b/frontend/src/__tests__/simulation-parts.test.ts @@ -848,15 +848,27 @@ describe('Buzzer — attachEvents', () => { beforeEach(() => { const mockOscillator = { type: 'square', - frequency: { value: 440, setTargetAtTime: vi.fn(), setValueAtTime: vi.fn() }, + frequency: { + value: 440, + setTargetAtTime: vi.fn(), + setValueAtTime: vi.fn(), + linearRampToValueAtTime: vi.fn(), + }, connect: vi.fn(), start: vi.fn(), stop: vi.fn(), disconnect: vi.fn(), + onended: null, }; const mockGain = { - gain: { value: 0.1, setTargetAtTime: vi.fn(), setValueAtTime: vi.fn() }, + gain: { + value: 0.1, + setTargetAtTime: vi.fn(), + setValueAtTime: vi.fn(), + linearRampToValueAtTime: vi.fn(), + }, connect: vi.fn(), + disconnect: vi.fn(), }; function MockAudioContext(this: any) { this.createOscillator = vi.fn().mockReturnValue(mockOscillator); diff --git a/frontend/src/simulation/parts/ComplexParts.ts b/frontend/src/simulation/parts/ComplexParts.ts index 4ee59f72..478a86ac 100644 --- a/frontend/src/simulation/parts/ComplexParts.ts +++ b/frontend/src/simulation/parts/ComplexParts.ts @@ -507,8 +507,8 @@ PartSimulationRegistry.register('buzzer', { : null; let audioCtx: AudioContext | null = null; - let oscillator: OscillatorNode | null = null; - let gainNode: GainNode | null = null; + let activeOsc: OscillatorNode | null = null; // the note currently sounding (one per click) + let activeGain: GainNode | null = null; let isSounding = false; // Once the pin is driven by hardware PWM (analogWrite/Timer), the PWM // handler owns the audio. The digital HIGH/LOW path is only for tone() @@ -542,73 +542,105 @@ PartSimulationRegistry.register('buzzer', { } // ── Sample-accurate audio ──────────────────────────────────────────── - // PWM duty events arrive in per-frame batches (~16ms), so starting the - // oscillator "now" quantises every onset to the animation frame and a - // metronome wobbles / turns chaotic. Instead we keep ONE oscillator running - // and gate it with the gain node, scheduling each on/off at the precise - // time the event happened in the simulation (timeMs = cpu.cycles / 16000) - // mapped onto the AudioContext clock with a small look-ahead. Onsets then - // land on the beat regardless of frame jitter. + // PWM duty events arrive in per-frame batches (~16ms), so starting a note + // "now" quantises every onset to the animation frame and a metronome + // wobbles. We instead schedule each note on the AudioContext clock at the + // time it happened in the simulation, with a small look-ahead. ONE + // oscillator PER NOTE (created on the onset, stopped on the note-off) with a + // short attack/release ramp: each note has a fresh fixed frequency and we + // never automate gain/frequency on a long-lived node — Firefox in particular + // clicks/pops on abrupt gain steps and glitches on live frequency changes. const LOOKAHEAD = 0.025; // target audio latency (~1-2 frames; aligns with the display) + const ATTACK = 0.002; // 2 ms fade-in — removes the start click/pop + const RELEASE = 0.003; // 3 ms fade-out — removes the end click/pop let playWhen: number | null = null; // next scheduled audio time (monotonic) - let lastNow: number | null = null; // audio time at the previous onset - let avgGap: number | null = null; // smoothed onset interval (de-jitters bursts) + let lastSimMs: number | null = null; // simulated time of the previous onset + let onWhen: number | null = null; // scheduled audio time of the current note's onset + let onSimMs: number | null = null; // simulated time of the current note's onset - function ensureAudio() { - if (!audioCtx) { - audioCtx = new AudioContext(); - gainNode = audioCtx.createGain(); - gainNode.gain.value = 0; - gainNode.connect(audioCtx.destination); - oscillator = audioCtx.createOscillator(); - oscillator.type = 'square'; - oscillator.frequency.value = 440; - oscillator.connect(gainNode); - oscillator.start(); // runs forever; the gain envelope is the gate - } + function ensureCtx() { + if (!audioCtx) audioCtx = new AudioContext(); // Autoplay policy: the context starts 'suspended' until a user gesture. if (audioCtx.state === 'suspended') audioCtx.resume(); } - // The simulation delivers onsets in per-frame catch-up bursts (uneven - // wall-clock gaps), so scheduling them "now" reproduces that jitter, while - // locking to the simulated timestamps makes the audio drift away from the - // display (which is driven from the same clock). We split the difference: - // predict the next onset at a SMOOTHED interval (de-jitters the bursts) and - // pull the scheduling latency toward a small LOOKAHEAD so the click stays - // aligned with the on-screen playhead. The sub-frame PWM polling (see - // AVRSimulator) is what removes the dropped/merged clicks underneath. - function whenFor(_timeMs: number | undefined): number { + // Schedule onsets by their SIMULATED inter-onset spacing — exact and even, + // because the firmware's clock is precise — advancing playWhen by the sim + // gap (timeMs delta). A light pull holds the scheduling latency near + // LOOKAHEAD, which bounds the slow sim↔audio clock drift and keeps the click + // aligned with the on-screen playhead. Because the spacing comes straight + // from the simulation (not a wall-clock average), turning a control (BPM, + // K…) re-locks immediately and the rhythm stays even — no bursts, no + // overlaps. whenFor sees ONLY onsets; note-offs are placed relative to their + // own onset in stopTone. + function whenFor(timeMs: number | undefined): number { const ctx = audioCtx!; const now = ctx.currentTime; - if (playWhen === null || lastNow === null) { - playWhen = now + LOOKAHEAD; - lastNow = now; + if (timeMs === undefined || playWhen === null || lastSimMs === null) { + playWhen = Math.max(now + LOOKAHEAD, (playWhen ?? 0) + 0.001); + if (timeMs !== undefined) lastSimMs = timeMs; return playWhen; } - const gap = now - lastNow; - avgGap = avgGap === null ? gap : avgGap + (gap - avgGap) * 0.08; - let when = playWhen + avgGap; // even prediction from the smoothed interval - when -= (when - now - LOOKAHEAD) * 0.12; // hold latency near LOOKAHEAD + const dSim = Math.max(0, (timeMs - lastSimMs) / 1000); // exact, even sim spacing + let when = playWhen + dSim; + when -= (when - now - LOOKAHEAD) * 0.2; // hold latency / absorb clock drift if (when < now + 0.003) when = now + 0.003; if (when <= playWhen) when = playWhen + 0.001; // strictly monotonic playWhen = when; - lastNow = now; + lastSimMs = timeMs; return when; } function startTone(freq: number, timeMs?: number) { - ensureAudio(); - const when = whenFor(timeMs); - oscillator!.frequency.setValueAtTime(freq, when); - gainNode!.gain.setValueAtTime(0.1, when); + ensureCtx(); + const ctx = audioCtx!; + const when = whenFor(timeMs); // the scheduler tracks ONSETS only (clean rhythm) + onWhen = when; + onSimMs = timeMs ?? null; + const osc = ctx.createOscillator(); + osc.type = 'square'; + osc.frequency.value = freq; // fixed for the life of this note (no live change) + const g = ctx.createGain(); + g.gain.setValueAtTime(0, when); + g.gain.linearRampToValueAtTime(0.1, when + ATTACK); + osc.connect(g); + g.connect(ctx.destination); + osc.start(when); + osc.onended = () => { + try { + osc.disconnect(); + g.disconnect(); + } catch { + /* already torn down */ + } + }; + activeOsc = osc; + activeGain = g; isSounding = true; if (el.playing !== undefined) el.playing = true; } function stopTone(timeMs?: number) { - if (audioCtx && gainNode) { - gainNode.gain.setValueAtTime(0, whenFor(timeMs)); + const ctx = audioCtx; + if (ctx && activeOsc && activeGain) { + // Note-off relative to its own onset, preserving the exact click length + // from the simulation (not via the onset scheduler, which would smear + // the short on→off and long off→on gaps together). + let off = + onWhen !== null && onSimMs !== null && timeMs !== undefined + ? onWhen + Math.max(0.004, (timeMs - onSimMs) / 1000) + : ctx.currentTime + 0.02; + if (onWhen !== null && off < onWhen + ATTACK + 0.002) off = onWhen + ATTACK + 0.002; + if (off < ctx.currentTime + 0.003) off = ctx.currentTime + 0.003; + try { + activeGain.gain.setValueAtTime(0.1, off); + activeGain.gain.linearRampToValueAtTime(0, off + RELEASE); + activeOsc.stop(off + RELEASE + 0.001); + } catch { + /* already scheduled */ + } + activeOsc = null; + activeGain = null; } isSounding = false; if (el.playing !== undefined) el.playing = false; @@ -663,21 +695,24 @@ PartSimulationRegistry.register('buzzer', { } return () => { - if (oscillator) { + if (activeOsc) { try { - oscillator.stop(); + activeOsc.stop(); + activeOsc.disconnect(); + activeGain?.disconnect(); } catch { /* already stopped */ } - oscillator.disconnect(); - oscillator = null; + activeOsc = null; + activeGain = null; } isSounding = false; pwmActive = false; if (el.playing !== undefined) el.playing = false; playWhen = null; - lastNow = null; - avgGap = null; + lastSimMs = null; + onWhen = null; + onSimMs = null; if (audioCtx) { audioCtx.close(); audioCtx = null;