feat(sim): real buzzer/RTTTL sound via Web Audio

genericBuzzer (src/helpers/ui/buzzer.h/.cpp) gets a third platform branch,
#elif defined(SIM_PLATFORM), alongside the existing NRF52 (direct PWM) and
NonBlockingRtttl paths -- purely additive, no changes to either real-hardware
branch. It reuses the NRF52 branch's already hardware-free RTTTL parser
(_parseHeader/_parseNext/_noteFreq, now shared via a widened guard) but
tracks (current frequency, note-end-time) instead of touching real PWM/timer
registers, advancing on plain millis() polling from loop() -- same
non-blocking shape UITask already drives every tick.

Wired into the sim build the same way every real board sets its buzzer pin
(-D PIN_BUZZER=<n> in build_flags/DEFINES; here it's a dummy sentinel since
there's no real pin, just something to activate the existing #ifdef
PIN_BUZZER guards in UITask.h/.cpp/SoundNotifier.h unchanged), plus two new
small UITask accessors (isBuzzerPlaying/buzzerFreqHz/buzzerVolume) and three
EMSCRIPTEN_KEEPALIVE exports so a host page can poll the buzzer's state.

Browser side: one Web Audio oscillator+gain per companion instance (index.html
single-instance; mesh.html per A/B, not R which is headless), created lazily
on the first real user gesture (AudioContext autoplay policy), polled every
20ms and mapped to the oscillator frequency/gain -- so every notification
sound, ringtone, alarm, and volume-blip that already worked on real hardware
now actually produces audio in the browser, unchanged at the call-site level.

Verified end-to-end with real RTTTL playback traces (not just "no errors"):
the startup jingle's exact note frequencies (C6/E6/G6) and a real DM-received
notification triggering the receiving instance's Web Audio gain node from 0
to its mapped volume and back, matching the actual "MsgRcv3" melody's notes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-03 13:59:26 +02:00
co-authored by Claude Sonnet 5
parent 0cb02ee18f
commit f61d6832a7
8 changed files with 254 additions and 18 deletions
+4
View File
@@ -75,6 +75,7 @@ SRCS=(
src/helpers/StaticPoolPacketManager.cpp
src/helpers/TransportKeyStore.cpp
src/helpers/TxtDataHelpers.cpp
src/helpers/ui/buzzer.cpp
lib/ed25519/add_scalar.c
lib/ed25519/fe.c
lib/ed25519/ge.c
@@ -127,6 +128,9 @@ DEFINES=(
# DisplayDriver (variants/sim/SimDisplayDriver.h's __EMSCRIPTEN__-guarded
# SimDisplayDriverCanvas class) instead of the ASCII/stdout one.
-DDISPLAY_CLASS=SimDisplayDriverCanvas
# Dummy sentinel (no real pin) -- see platformio.ini's own comment on the
# native env's identical flag.
-DPIN_BUZZER=0
-DMAX_CONTACTS=100
-DMAX_GROUP_CHANNELS=8
)
+7
View File
@@ -27,6 +27,12 @@ build_flags =
-D SIM_PLATFORM
-D MESH_DEBUG=0
-D DISPLAY_CLASS=SimDisplayDriver
; No real buzzer pin -- this is a dummy sentinel, purely to activate the
; #ifdef PIN_BUZZER guards in UITask.h/.cpp/SoundNotifier.h/main.cpp
; unchanged (real boards set this to their actual GPIO pin number the same
; way). genericBuzzer's own #ifdef SIM_PLATFORM branch (src/helpers/ui/
; buzzer.cpp) never touches a real pin, so the value itself is unused.
-D PIN_BUZZER=0
-D MAX_CONTACTS=100
-D MAX_GROUP_CHANNELS=8
-I variants/sim/arduino
@@ -56,6 +62,7 @@ build_src_filter =
+<../src/helpers/StaticPoolPacketManager.cpp>
+<../src/helpers/TransportKeyStore.cpp>
+<../src/helpers/TxtDataHelpers.cpp>
+<../src/helpers/ui/buzzer.cpp>
+<../lib/ed25519/*.c>
+<../variants/sim/*.cpp>
+<../variants/sim/thirdparty/crypto/*.cpp>
+50
View File
@@ -213,6 +213,54 @@
if (Module && Module._sim_enqueue_key_longpress) Module._sim_enqueue_key_longpress(code);
}
// Buzzer: a single Web Audio oscillator+gain standing in for the real
// piezo buzzer, polled against sim_buzzer_is_playing()/
// sim_buzzer_freq_hz() (see genericBuzzer's own #ifdef SIM_PLATFORM
// branch, src/helpers/ui/buzzer.cpp) -- those already track exactly
// which frequency (0 = silent) should be sounding right now, driven
// by the real RTTTL playback the notification/ringtone/startup code
// triggers unchanged. AudioContext can't be created before a real
// user gesture (browser autoplay policy), so this is created lazily
// on first pointerdown/keydown -- the boot startup jingle will
// already be over by the time that first gesture happens, same
// unavoidable limitation any embedded page with boot sound has.
let audioCtx = null, buzzOsc = null, buzzGain = null, buzzWasPlaying = false;
function ensureAudio() {
if (audioCtx) return;
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
buzzOsc = audioCtx.createOscillator();
buzzGain = audioCtx.createGain();
buzzGain.gain.value = 0;
buzzOsc.type = 'square'; // closer to a piezo buzzer's timbre than a sine
buzzOsc.frequency.value = 440;
buzzOsc.connect(buzzGain).connect(audioCtx.destination);
buzzOsc.start();
}
// Exposed for console poking / Playwright, same as window.Module above.
window.__buzzAudio = () => (audioCtx
? { state: audioCtx.state, gain: buzzGain.gain.value, freq: buzzOsc.frequency.value }
: null);
// Volume steps roughly mirror genericBuzzer's own real-hardware duty-
// cycle table (buzzer.cpp's PCT[]/duty[] -- ~6-8 dB per step); 0 is
// silence regardless of what's "playing" (mutes without touching the
// sim's own note-advance timing).
const BUZZER_GAIN = [0, 0.02, 0.05, 0.09, 0.15];
function pollBuzzer() {
if (!audioCtx || !Module || !Module._sim_buzzer_is_playing) return;
const playing = Module.ccall('sim_buzzer_is_playing', 'number', [], []) === 1;
const now = audioCtx.currentTime;
if (playing) {
const freq = Module.ccall('sim_buzzer_freq_hz', 'number', [], []);
const vol = Module.ccall('sim_buzzer_get_volume', 'number', [], []);
buzzOsc.frequency.setValueAtTime(freq > 0 ? freq : 440, now);
buzzGain.gain.linearRampToValueAtTime(freq > 0 ? BUZZER_GAIN[vol] || 0 : 0, now + 0.005);
} else if (buzzWasPlaying) {
buzzGain.gain.linearRampToValueAtTime(0, now + 0.01);
}
buzzWasPlaying = playing;
}
setInterval(pollBuzzer, 20);
// Press-and-hold for the on-page buttons: start a timer on press-down,
// fire the long-press call if still held past the threshold, and
// suppress the short click on release if it already fired.
@@ -221,6 +269,7 @@
let timer = null, longFired = false;
btn.addEventListener('pointerdown', (e) => {
e.preventDefault();
ensureAudio();
longFired = false;
timer = setTimeout(() => { longFired = true; sendKeyLongPress(code); }, LONG_PRESS_MS);
});
@@ -297,6 +346,7 @@
const code = resolveKey(e);
if (code === null) return;
e.preventDefault();
ensureAudio();
if (keyHold.has(e.key)) return; // auto-repeat, not a new press
const state = { longFired: false };
state.timer = setTimeout(() => { state.longFired = true; sendKeyLongPress(code); }, LONG_PRESS_MS);
+45
View File
@@ -421,12 +421,56 @@
// current when the listener was attached.
function currentInst(key) { return key === 'A' ? A : key === 'B' ? B : R; }
// Buzzer: one Web Audio oscillator+gain per companion instance (not R
// -- a headless simple_repeater has no UITask/buzzer at all), same
// mechanism as web/index.html (see that file's own comment). Keyed by
// instance tag rather than closing over A/B directly, since Reset
// (resetInstance() above) replaces those bindings with a fresh module
// -- currentInst(key) below always resolves the live one, so a reset
// instance's sound keeps working against the same AudioContext.
const buzzAudio = {};
function ensureAudio(key) {
if (key === 'R' || buzzAudio[key]) return;
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
gain.gain.value = 0;
osc.type = 'square';
osc.frequency.value = 440;
osc.connect(gain).connect(ctx.destination);
osc.start();
buzzAudio[key] = { ctx, osc, gain, wasPlaying: false };
}
const BUZZER_GAIN = [0, 0.02, 0.05, 0.09, 0.15];
function pollBuzzer(key) {
const b = buzzAudio[key];
const inst = currentInst(key);
if (!b || !inst || !inst.isReady() || !inst.mod._sim_buzzer_is_playing) return;
const playing = inst.mod.ccall('sim_buzzer_is_playing', 'number', [], []) === 1;
const now = b.ctx.currentTime;
if (playing) {
const freq = inst.mod.ccall('sim_buzzer_freq_hz', 'number', [], []);
const vol = inst.mod.ccall('sim_buzzer_get_volume', 'number', [], []);
b.osc.frequency.setValueAtTime(freq > 0 ? freq : 440, now);
b.gain.gain.linearRampToValueAtTime(freq > 0 ? (BUZZER_GAIN[vol] || 0) : 0, now + 0.005);
} else if (b.wasPlaying) {
b.gain.gain.linearRampToValueAtTime(0, now + 0.01);
}
b.wasPlaying = playing;
}
setInterval(() => { pollBuzzer('A'); pollBuzzer('B'); }, 20);
// Exposed for console poking / Playwright, same as window.__meshSim below.
window.__buzzAudio = (key) => (buzzAudio[key]
? { state: buzzAudio[key].ctx.state, gain: buzzAudio[key].gain.gain.value, freq: buzzAudio[key].osc.frequency.value }
: null);
document.querySelectorAll('button[data-instance]').forEach((btn) => {
const key = btn.dataset.instance;
const code = Number(btn.dataset.key);
let timer = null, longFired = false;
btn.addEventListener('pointerdown', (e) => {
e.preventDefault();
ensureAudio(key);
longFired = false;
timer = setTimeout(() => { longFired = true; sendKeyLongPress(currentInst(key), code); }, LONG_PRESS_MS);
});
@@ -606,6 +650,7 @@
const code = resolveKey(e);
if (code === null) return;
e.preventDefault();
ensureAudio(focusedKey);
if (keyHold.has(e.key)) return;
const state = { longFired: false };
state.timer = setTimeout(() => { state.longFired = true; sendKeyLongPress(currentInst(focusedKey), code); }, LONG_PRESS_MS);