Merge pull request #220 from ciegovolador/fix/buzzer-sample-accurate-audio
fix(sim): sample-accurate, glitch-free buzzer audio (+ metronome quality tests)
This commit is contained in:
commit
c01f9d8d75
|
|
@ -153,6 +153,36 @@ describe('PinManager — PWM duty cycle', () => {
|
|||
expect(callbacks[i]).toHaveBeenCalledWith(pin, dc);
|
||||
});
|
||||
});
|
||||
|
||||
// The optional timeMs (precise simulated onset time, used by the buzzer for
|
||||
// sample-accurate audio) must NOT widen the public PwmCallback contract:
|
||||
// listeners that declare only (pin, dutyCycle) keep getting a 2-arg call,
|
||||
// while a listener that declares a 3rd parameter receives timeMs. This guards
|
||||
// the arity-based dispatch the buzzer relies on. Regular functions are used
|
||||
// (not vi.fn) because the dispatch keys off Function.length, and a 3-param
|
||||
// listener must report length 3.
|
||||
it('hands timeMs only to listeners that declare a 3rd parameter', () => {
|
||||
let twoArgCount = -1;
|
||||
let threeArgCount = -1;
|
||||
let threeArgTime: number | undefined;
|
||||
function twoArg(this: unknown, _pin: number, _dc: number) {
|
||||
// eslint-disable-next-line prefer-rest-params
|
||||
twoArgCount = arguments.length;
|
||||
}
|
||||
function threeArg(this: unknown, _pin: number, _dc: number, t?: number) {
|
||||
// eslint-disable-next-line prefer-rest-params
|
||||
threeArgCount = arguments.length;
|
||||
threeArgTime = t;
|
||||
}
|
||||
pm.onPwmChange(7, twoArg);
|
||||
pm.onPwmChange(7, threeArg);
|
||||
|
||||
pm.updatePwm(7, 0.5, 123);
|
||||
|
||||
expect(twoArgCount).toBe(2); // original 2-arg contract preserved — no trailing timeMs
|
||||
expect(threeArgCount).toBe(3);
|
||||
expect(threeArgTime).toBe(123); // 3-arg listener (the buzzer) gets the precise time
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Analog voltage API ──────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -0,0 +1,296 @@
|
|||
/**
|
||||
* 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);
|
||||
});
|
||||
|
||||
// Regression guard for the maintainer's review (PR #220, comment 4671821612):
|
||||
// a melody / continuous tone is consecutive tone(pin, freqN) calls with NO
|
||||
// noTone() between pitches — back-to-back nonzero OCR writes, each firing the
|
||||
// PWM handler with duty>0 and no intervening note-off. The old code overwrote
|
||||
// activeOsc on every pitch change without stopping the previous node, so
|
||||
// oscillators were "started but never stopped" — they stacked and played
|
||||
// forever. The monophonic guard must REPLACE the live note instead.
|
||||
it('replaces rather than stacks oscillators on a melody / continuous tone', () => {
|
||||
const { sim } = setupBuzzer();
|
||||
const pwm = sim.pinManager.onPwmChange.mock.calls[0][1] as (
|
||||
p: number,
|
||||
dc: number,
|
||||
t?: number,
|
||||
) => void;
|
||||
|
||||
// A little tune: pitch changes with no note-off between them (legato).
|
||||
const melody = [523, 587, 659, 698, 784, 659]; // C5 D5 E5 F5 G5 E5
|
||||
const step = 200; // ms per note
|
||||
melody.forEach((freq, i) => {
|
||||
sim.cpu.data[OCR2A] = ocrFor(freq);
|
||||
sim.cpu.data[TCCR2B] = 0x04; // CS22 -> prescaler 64
|
||||
clock = (i * step) / 1000;
|
||||
pwm(11, 0.5, i * step); // square-wave duty>0, pitch change, NO dc=0
|
||||
});
|
||||
// End the tune with a noTone — releases the final note.
|
||||
clock = (melody.length * step) / 1000;
|
||||
pwm(11, 0, melody.length * step);
|
||||
|
||||
const starts = sched.filter((e) => e.kind === 'start');
|
||||
const stops = sched.filter((e) => e.kind === 'stop');
|
||||
|
||||
// Every oscillator that started is stopped — no orphans left ringing. This
|
||||
// is the exact failure the maintainer saw ("started but never stopped: 6").
|
||||
expect(starts.length).toBe(melody.length);
|
||||
expect(stops.length).toBe(starts.length);
|
||||
|
||||
// One start per note, at the right pitch, in order. Compare against the
|
||||
// CTC-reconstructed pitch (what the firmware's integer OCR actually yields),
|
||||
// not the nominal note — same round-trip the buzzer's getFrequency does.
|
||||
const heard = (freq: number) => Math.round(125000 / (ocrFor(freq) + 1));
|
||||
starts.forEach((s, i) => {
|
||||
expect(s.freq).toBe(heard(melody[i]));
|
||||
if (i > 0) expect(s.when).toBeGreaterThan(starts[i - 1].when); // monotonic, no backward
|
||||
});
|
||||
// Pitch is read FRESH per note (not stuck on the first onset): the tune rises
|
||||
// then falls, so the heard sequence is non-constant and tracks the melody.
|
||||
const heardSeq = starts.map((s) => s.freq);
|
||||
expect(new Set(heardSeq).size).toBeGreaterThan(1);
|
||||
expect(heardSeq).toEqual(melody.map(heard));
|
||||
|
||||
// Bounded overlap (replacement, not stacking): each note is released close to
|
||||
// the NEXT note's onset — not smeared to the end of the tune. Pair each start
|
||||
// with its own stop (interleaved start/stop/start/stop… once the guard fires).
|
||||
const notesSeq: { on: number; off: number }[] = [];
|
||||
for (let i = 0; i + 1 < sched.length; i++) {
|
||||
if (sched[i].kind === 'start' && sched[i + 1].kind === 'stop') {
|
||||
notesSeq.push({ on: sched[i].when, off: sched[i + 1].when });
|
||||
}
|
||||
}
|
||||
expect(notesSeq.length).toBe(melody.length);
|
||||
for (let i = 0; i < notesSeq.length - 1; i++) {
|
||||
// old note ends as the next begins (≤ a release tail past the next onset)
|
||||
expect(notesSeq[i].off).toBeLessThanOrEqual(notesSeq[i + 1].on + 0.01);
|
||||
expect(notesSeq[i].off).toBeGreaterThan(notesSeq[i].on); // positive duration
|
||||
}
|
||||
});
|
||||
|
||||
// A melody that ends WITHOUT a noTone() — the real "sketch loops tone() and
|
||||
// never calls noTone()" pattern. Correct Arduino semantics: a tone() plays
|
||||
// until noTone() or the NEXT tone(), so the final note must keep ringing. The
|
||||
// guard must release exactly the SUPERSEDED notes (one stop each) and leave the
|
||||
// last one sounding — not orphan the middle notes, not cut the last one short.
|
||||
it('releases superseded notes but leaves the final note ringing (no trailing noTone)', () => {
|
||||
const { sim } = setupBuzzer();
|
||||
const pwm = sim.pinManager.onPwmChange.mock.calls[0][1] as (
|
||||
p: number,
|
||||
dc: number,
|
||||
t?: number,
|
||||
) => void;
|
||||
|
||||
const melody = [440, 494, 523]; // A4 B4 C5
|
||||
const step = 200;
|
||||
melody.forEach((freq, i) => {
|
||||
sim.cpu.data[OCR2A] = ocrFor(freq);
|
||||
sim.cpu.data[TCCR2B] = 0x04;
|
||||
clock = (i * step) / 1000;
|
||||
pwm(11, 0.5, i * step); // pitch change, NO dc=0 — and no noTone at the end
|
||||
});
|
||||
|
||||
const starts = sched.filter((e) => e.kind === 'start');
|
||||
const stops = sched.filter((e) => e.kind === 'stop');
|
||||
// Every note starts; only the superseded ones stop → exactly one note (the
|
||||
// last) is still live. Pre-guard this was starts=3, stops=0 (all orphaned).
|
||||
expect(starts.length).toBe(melody.length);
|
||||
expect(stops.length).toBe(starts.length - 1);
|
||||
});
|
||||
});
|
||||
|
|
@ -848,15 +848,27 @@ describe('Buzzer — attachEvents', () => {
|
|||
beforeEach(() => {
|
||||
const mockOscillator = {
|
||||
type: 'square',
|
||||
frequency: { value: 440, setTargetAtTime: 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 },
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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,21 @@ 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));
|
||||
// Backward-compatible dispatch: the original PwmCallback contract is
|
||||
// (pin, dutyCycle). Only listeners that actually declare a 3rd parameter
|
||||
// (the buzzer, which needs the precise onset time for sample-accurate
|
||||
// audio) receive timeMs. Plain 2-arg listeners — and the existing tests
|
||||
// that assert toHaveBeenCalledWith(pin, dutyCycle) — see an unchanged
|
||||
// 2-arg call instead of a spurious trailing arg.
|
||||
callbacks.forEach((cb) => (cb.length >= 3 ? cb(pin, dutyCycle, timeMs) : cb(pin, dutyCycle)));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -507,9 +507,14 @@ 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()
|
||||
// (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,133 @@ PartSimulationRegistry.register('buzzer', {
|
|||
return F_CPU / (2 * prescaler * (ocr2a + 1));
|
||||
}
|
||||
|
||||
function startTone(freq: number) {
|
||||
if (!audioCtx) {
|
||||
audioCtx = new AudioContext();
|
||||
gainNode = audioCtx.createGain();
|
||||
gainNode.gain.value = 0.1;
|
||||
gainNode.connect(audioCtx.destination);
|
||||
// ── Sample-accurate audio ────────────────────────────────────────────
|
||||
// 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 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 ensureCtx() {
|
||||
if (!audioCtx) audioCtx = new AudioContext();
|
||||
// Autoplay policy: the context starts 'suspended' until a user gesture.
|
||||
if (audioCtx.state === 'suspended') audioCtx.resume();
|
||||
}
|
||||
|
||||
// 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 (timeMs === undefined || playWhen === null || lastSimMs === null) {
|
||||
playWhen = Math.max(now + LOOKAHEAD, (playWhen ?? 0) + 0.001);
|
||||
if (timeMs !== undefined) lastSimMs = timeMs;
|
||||
return playWhen;
|
||||
}
|
||||
// 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();
|
||||
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;
|
||||
lastSimMs = timeMs;
|
||||
return when;
|
||||
}
|
||||
|
||||
// Ramp the note currently sounding down to silence ending at audio time
|
||||
// `off` and schedule its stop. Shared by stopTone (note-off) and the
|
||||
// monophonic guard in startTone (a pitch change with no note-off). Keeps the
|
||||
// envelope valid: never release before this note's own attack has finished,
|
||||
// nor in the past.
|
||||
//
|
||||
// Bounded-overlap note (guard path): on a normal metronome/melody — onsets
|
||||
// tens-to-hundreds of ms apart — the old note ends ~RELEASE before the next
|
||||
// onset. On a degenerate sub-4 ms onset (a >250-note/s trill, or two tone()
|
||||
// calls at the same simulated timestamp — neither of which a passive buzzer
|
||||
// produces) the `onWhen + ATTACK` floor pushes `off` past the next onset, so
|
||||
// two oscillators overlap for at most ~ATTACK+RELEASE (≈5 ms). That is
|
||||
// inaudible and still leak-free (one stop per note). We deliberately keep the
|
||||
// attack-finished envelope rather than clamp `off` down to the onset, which
|
||||
// would start the down-ramp from a gain that never reached its peak.
|
||||
function releaseActive(off: number) {
|
||||
const ctx = audioCtx;
|
||||
if (!ctx || !activeOsc || !activeGain) return;
|
||||
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 */
|
||||
}
|
||||
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();
|
||||
activeOsc = null;
|
||||
activeGain = null;
|
||||
}
|
||||
|
||||
function startTone(freq: number, timeMs?: number) {
|
||||
ensureCtx();
|
||||
const ctx = audioCtx!;
|
||||
const when = whenFor(timeMs); // the scheduler tracks ONSETS only (clean rhythm)
|
||||
// Monophonic guard: a pitch change with no intervening note-off (a melody —
|
||||
// consecutive tone() calls) must REPLACE the current note, not stack a new
|
||||
// oscillator on top. Release the live note so it ends as the new one begins
|
||||
// (seamless legato) instead of orphaning it to play forever. Reads the
|
||||
// PREVIOUS note's onWhen, so it must run before onWhen is reassigned below.
|
||||
if (activeOsc && activeGain) releaseActive(when);
|
||||
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() {
|
||||
if (oscillator) {
|
||||
oscillator.stop();
|
||||
oscillator.disconnect();
|
||||
oscillator = null;
|
||||
function stopTone(timeMs?: number) {
|
||||
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).
|
||||
const off =
|
||||
onWhen !== null && onSimMs !== null && timeMs !== undefined
|
||||
? onWhen + Math.max(0.004, (timeMs - onSimMs) / 1000)
|
||||
: ctx.currentTime + 0.02;
|
||||
releaseActive(off);
|
||||
}
|
||||
isSounding = false;
|
||||
if (el.playing !== undefined) el.playing = false;
|
||||
|
|
@ -576,13 +678,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 +697,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 +710,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 +722,24 @@ PartSimulationRegistry.register('buzzer', {
|
|||
}
|
||||
|
||||
return () => {
|
||||
stopTone();
|
||||
if (activeOsc) {
|
||||
try {
|
||||
activeOsc.stop();
|
||||
activeOsc.disconnect();
|
||||
activeGain?.disconnect();
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
activeOsc = null;
|
||||
activeGain = null;
|
||||
}
|
||||
isSounding = false;
|
||||
pwmActive = false;
|
||||
if (el.playing !== undefined) el.playing = false;
|
||||
playWhen = null;
|
||||
lastSimMs = null;
|
||||
onWhen = null;
|
||||
onSimMs = null;
|
||||
if (audioCtx) {
|
||||
audioCtx.close();
|
||||
audioCtx = null;
|
||||
|
|
|
|||
Loading…
Reference in New Issue