fix(sim): mature the buzzer — per-note oscillators, sim-time scheduling, ramps + metronome tests

Builds on the previous commit; reworks the buzzer audio for glitch-free,
cross-browser playback and adds a metronome quality suite.

- Per-note oscillators with short attack/release ramps, instead of one
  long-lived oscillator gated by gain: a fresh fixed frequency per note and no
  gain/frequency automation on a persistent node — Firefox in particular clicks
  and glitches the pitch otherwise.
- Schedule onsets by their SIMULATED inter-onset spacing (exact, even) with a
  light latency hold, instead of a wall-clock average. Turning a control (BPM,
  K…) re-locks immediately and the rhythm stays even — no bursts, no overlaps,
  no audio drifting away from the display.
- Place each note-off relative to its own onset, preserving the exact click
  length from the simulation (the onset scheduler now tracks onsets only).
- Poll PWM every 256 cycles (was 64): finer than any audible pulse, lighter on
  the frame loop.
- New src/__tests__/buzzer-metronome.test.ts: drives the buzzer as a metronome
  against a controllable audio clock and asserts even spacing, one oscillator
  per click with no overlap, correct pitch per metric level, burst absorption,
  and a clean re-lock on tempo change.

All simulation-parts + metronome tests pass (57).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ciegovolador 2026-06-09 09:22:57 -03:00
parent 06526c7922
commit b06f500ad6
3 changed files with 300 additions and 54 deletions

View File

@ -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);
});
});

View File

@ -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);

View File

@ -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;