fix(sim): sample-accurate buzzer audio — precise PWM detection + display-aligned scheduling

A PWM-driven buzzer (analogWrite / Timer tones) was chaotic and unusable as a
metronome. Causes, all on the PWM path:

1. PWM was polled once per animation frame AFTER the cycle loop, so short clicks
   that started and ended within one frame were merged or lost, and onsets were
   quantised to the frame.
2. The buzzer started the oscillator with `oscillator.start()` (no scheduled
   time) — frame-delivery jitter and per-onset oscillator churn.
3. The digital HIGH/LOW path also fired on the ~490Hz PWM carrier edges,
   injecting spurious onsets (OCR read as 0 → 20kHz squeaks).

Fix:
- AVRSimulator: poll PWM sub-frame (every 256 cycles) so no pulse is merged or
  lost; pass the precise simulated time through updatePwm.
- PinManager: PwmCallback / updatePwm carry an optional timeMs (backward compat).
- Buzzer: one continuous oscillator gated by the gain node, each on/off scheduled
  on the AudioContext clock. The schedule predicts the next onset at a smoothed
  interval (de-jittering the simulator's bursty per-frame delivery) and holds a
  small bounded latency so the click stays aligned with the on-screen playhead
  (driven from the same clock) instead of drifting behind it. A `pwmActive` flag
  mutes the digital path once hardware PWM drives the pin.

Result: onset jitter for a firmware metronome drops from chaotic (σ ≈ 250ms,
dropped/extra beats, unbounded audio latency) to σ ≈ 15ms at ~30ms latency —
steady and aligned with the display. All 54 simulation-parts tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ciegovolador 2026-06-08 21:58:49 -03:00
parent 02c8ad756d
commit 06526c7922
4 changed files with 104 additions and 32 deletions

View File

@ -848,14 +848,14 @@ describe('Buzzer — attachEvents', () => {
beforeEach(() => {
const mockOscillator = {
type: 'square',
frequency: { value: 440, setTargetAtTime: vi.fn() },
frequency: { value: 440, setTargetAtTime: vi.fn(), setValueAtTime: vi.fn() },
connect: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
disconnect: vi.fn(),
};
const mockGain = {
gain: { value: 0.1 },
gain: { value: 0.1, setTargetAtTime: vi.fn(), setValueAtTime: vi.fn() },
connect: vi.fn(),
};
function MockAudioContext(this: any) {

View File

@ -727,13 +727,16 @@ export class AVRSimulator {
*/
private pollPwmRegisters(): void {
if (!this.cpu) return;
// Precise simulated time of this poll (sub-frame). Parts that schedule
// audio use it to recover the real onset time instead of the frame edge.
const timeMs = this.cpu.cycles / 16_000;
const pins = this.pwmPins;
for (let i = 0; i < pins.length; i++) {
const { ocrAddr, pin } = pins[i];
const ocrValue = this.cpu.data[ocrAddr];
if (ocrValue !== this.lastOcrValues[i]) {
this.lastOcrValues[i] = ocrValue;
this.pinManager.updatePwm(pin, ocrValue / 255);
this.pinManager.updatePwm(pin, ocrValue / 255, timeMs);
}
}
}
@ -786,9 +789,14 @@ export class AVRSimulator {
avrInstruction(this.cpu); // Execute the AVR instruction
this.cpu.tick(); // Update peripheral timers and cycles
if (this.scheduledPinChanges.length > 0) this.flushScheduledPinChanges();
// Poll PWM sub-frame (~every 256 cycles = 16µs) so short OCR pulses
// (e.g. a metronome click that starts and ends within one 16ms frame)
// aren't merged or lost at the frame boundary. 256 cycles is far finer
// than any audible pulse yet light enough not to perturb frame pacing.
if ((i & 0xff) === 0) this.pollPwmRegisters();
}
// Poll PWM registers every frame
// Final poll at the frame edge to catch the last change.
this.pollPwmRegisters();
// Try to drain any pending RX byte every frame. The primary

View File

@ -19,7 +19,10 @@
export type PinState = boolean;
export type PinChangeCallback = (pin: number, state: PinState) => void;
export type AnalogCallback = (pin: number, voltage: number) => void;
export type PwmCallback = (pin: number, dutyCycle: number) => void;
// timeMs (optional) is the precise simulated time of the duty-cycle change
// (cpu.cycles / 16000). Parts that schedule audio/output use it for
// sample-accurate timing instead of the per-frame delivery instant.
export type PwmCallback = (pin: number, dutyCycle: number, timeMs?: number) => void;
export class PinManager {
private listeners: Map<number, Set<PinChangeCallback>> = new Map();
@ -190,14 +193,15 @@ export class PinManager {
}
/**
* Called by AVRSimulator each frame when an OCR register changes.
* Called by AVRSimulator when an OCR register changes (polled sub-frame).
* timeMs is the precise simulated time of the change for accurate audio.
*/
updatePwm(pin: number, dutyCycle: number): void {
updatePwm(pin: number, dutyCycle: number, timeMs?: number): void {
this.pwmValues.set(pin, dutyCycle);
if (dutyCycle > 0) this.outputPins.add(pin);
const callbacks = this.pwmListeners.get(pin);
if (callbacks) {
callbacks.forEach((cb) => cb(pin, dutyCycle));
callbacks.forEach((cb) => cb(pin, dutyCycle, timeMs));
}
}

View File

@ -510,6 +510,11 @@ PartSimulationRegistry.register('buzzer', {
let oscillator: OscillatorNode | null = null;
let gainNode: 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()
// (software pin toggling); on a PWM pin its ~490Hz carrier would otherwise
// fire spurious onsets at the duty edges. This flag mutes that path.
let pwmActive = false;
const el = element as any;
// Timer2 register addresses
@ -536,36 +541,74 @@ PartSimulationRegistry.register('buzzer', {
return F_CPU / (2 * prescaler * (ocr2a + 1));
}
function startTone(freq: number) {
// ── 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.
const LOOKAHEAD = 0.025; // target audio latency (~1-2 frames; aligns with the display)
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)
function ensureAudio() {
if (!audioCtx) {
audioCtx = new AudioContext();
gainNode = audioCtx.createGain();
gainNode.gain.value = 0.1;
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
}
// Browser autoplay policy: AudioContext starts in 'suspended' state
// until a user gesture has occurred. Resume it here so sound plays.
if (audioCtx.state === 'suspended') {
audioCtx.resume();
// 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 {
const ctx = audioCtx!;
const now = ctx.currentTime;
if (playWhen === null || lastNow === null) {
playWhen = now + LOOKAHEAD;
lastNow = now;
return playWhen;
}
if (oscillator) {
oscillator.frequency.setTargetAtTime(freq, audioCtx.currentTime, 0.01);
return;
}
oscillator = audioCtx.createOscillator();
oscillator.type = 'square';
oscillator.frequency.value = freq;
oscillator.connect(gainNode!);
oscillator.start();
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
if (when < now + 0.003) when = now + 0.003;
if (when <= playWhen) when = playWhen + 0.001; // strictly monotonic
playWhen = when;
lastNow = now;
return when;
}
function startTone(freq: number, timeMs?: number) {
ensureAudio();
const when = whenFor(timeMs);
oscillator!.frequency.setValueAtTime(freq, when);
gainNode!.gain.setValueAtTime(0.1, when);
isSounding = true;
if (el.playing !== undefined) el.playing = true;
}
function stopTone() {
if (oscillator) {
oscillator.stop();
oscillator.disconnect();
oscillator = null;
function stopTone(timeMs?: number) {
if (audioCtx && gainNode) {
gainNode.gain.setValueAtTime(0, whenFor(timeMs));
}
isSounding = false;
if (el.playing !== undefined) el.playing = false;
@ -576,13 +619,14 @@ PartSimulationRegistry.register('buzzer', {
if (pinSIG !== null && pinManager) {
unsubscribers.push(
pinManager.onPwmChange(pinSIG, (_: number, dc: number) => {
pinManager.onPwmChange(pinSIG, (_: number, dc: number, timeMs?: number) => {
pwmActive = true;
const cpu = (avrSimulator as any).cpu;
if (dc > 0) {
const freq = cpu ? getFrequency(cpu) : 440;
startTone(Math.max(20, Math.min(20000, freq)));
startTone(Math.max(20, Math.min(20000, freq)), timeMs);
} else {
stopTone();
stopTone(timeMs);
}
}),
);
@ -594,6 +638,7 @@ PartSimulationRegistry.register('buzzer', {
if (sigResolver) {
unsubscribers.push(
sigResolver.onChange((state) => {
if (pwmActive) return; // PWM-driven: the duty handler owns audio
if (!isSounding && state === 'HIGH') {
const cpu = (avrSimulator as any).cpu;
const freq = cpu ? getFrequency(cpu) : 440;
@ -606,6 +651,7 @@ PartSimulationRegistry.register('buzzer', {
} else {
unsubscribers.push(
pinManager.onPinChange(pinSIG, (_: number, state: boolean) => {
if (pwmActive) return; // PWM-driven: the duty handler owns audio
if (!isSounding && state) {
const cpu = (avrSimulator as any).cpu;
const freq = cpu ? getFrequency(cpu) : 440;
@ -617,7 +663,21 @@ PartSimulationRegistry.register('buzzer', {
}
return () => {
stopTone();
if (oscillator) {
try {
oscillator.stop();
} catch {
/* already stopped */
}
oscillator.disconnect();
oscillator = null;
}
isSounding = false;
pwmActive = false;
if (el.playing !== undefined) el.playing = false;
playWhen = null;
lastNow = null;
avgGap = null;
if (audioCtx) {
audioCtx.close();
audioCtx = null;