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:
parent
02c8ad756d
commit
06526c7922
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue