From f61d6832a78046108dff90be0b88b30c7c4791ae Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:59:26 +0200 Subject: [PATCH] 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= 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 --- examples/companion_radio/ui-new/UITask.cpp | 17 +++++ examples/companion_radio/ui-new/UITask.h | 12 ++++ src/helpers/ui/buzzer.cpp | 84 ++++++++++++++++++++-- src/helpers/ui/buzzer.h | 53 ++++++++++---- variants/sim/build_wasm.sh | 4 ++ variants/sim/platformio.ini | 7 ++ variants/sim/web/index.html | 50 +++++++++++++ variants/sim/web/mesh.html | 45 ++++++++++++ 8 files changed, 254 insertions(+), 18 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index dc03769e..16b1a305 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -2157,6 +2157,23 @@ extern "C" EMSCRIPTEN_KEEPALIVE void sim_enqueue_key(char c) { extern "C" EMSCRIPTEN_KEEPALIVE void sim_enqueue_key_longpress(char c) { if (g_sim_ui_task_for_js) g_sim_ui_task_for_js->injectSimKeyLongPress(c); } + +#ifdef PIN_BUZZER +// Polled by the host page every ~20ms (see web/index.html/mesh.html) to +// drive a Web Audio oscillator standing in for the real piezo buzzer -- +// genericBuzzer's own #ifdef SIM_PLATFORM branch (src/helpers/ui/buzzer.cpp) +// tracks (is a note sounding, at what frequency) instead of touching real +// PWM/timer hardware; these two exports are just the read side of that. +extern "C" EMSCRIPTEN_KEEPALIVE int sim_buzzer_is_playing() { + return (g_sim_ui_task_for_js && g_sim_ui_task_for_js->isBuzzerPlaying()) ? 1 : 0; +} +extern "C" EMSCRIPTEN_KEEPALIVE int sim_buzzer_freq_hz() { + return g_sim_ui_task_for_js ? (int)g_sim_ui_task_for_js->buzzerFreqHz() : 0; +} +extern "C" EMSCRIPTEN_KEEPALIVE int sim_buzzer_get_volume() { + return g_sim_ui_task_for_js ? (int)g_sim_ui_task_for_js->buzzerVolume() : 0; +} +#endif #endif bool UITask::dequeueKey(char& c) { diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 59c4e49c..762f23e5 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -227,6 +227,18 @@ public: // never open the context menu at all -- injectSimKey() alone has no way // to signal "this press was held". void injectSimKeyLongPress(char c); +#ifdef PIN_BUZZER + // Lets a host page poll the buzzer's current state every frame to drive a + // Web Audio oscillator (see sim_buzzer_is_playing()/sim_buzzer_freq_hz() + // in main.cpp) -- `buzzer` itself is a private member, so a free function + // outside this class needs these to reach it, same reason injectSimKey() + // above is public. + // Not const: genericBuzzer::isPlaying() itself isn't const-qualified (its + // NRF52/non-NRF52 siblings don't need to be, so it wasn't worth widening). + bool isBuzzerPlaying() { return buzzer.isPlaying(); } + uint16_t buzzerFreqHz() const { return buzzer.currentFreqHz(); } + uint8_t buzzerVolume() const { return buzzer.getVolume(); } +#endif private: #endif diff --git a/src/helpers/ui/buzzer.cpp b/src/helpers/ui/buzzer.cpp index 46e79de0..a93e802e 100644 --- a/src/helpers/ui/buzzer.cpp +++ b/src/helpers/ui/buzzer.cpp @@ -3,12 +3,19 @@ #include "buzzer.h" void genericBuzzer::begin() { + // No real GPIO pin to configure in the sim -- PIN_BUZZER there is just a + // dummy sentinel value so the #ifdef PIN_BUZZER guards elsewhere (this + // file included) activate at all; variants/sim/arduino/Arduino.h + // deliberately has no pinMode()/digitalWrite() shim since nothing else + // ever needed one before this. +#ifndef SIM_PLATFORM #ifdef PIN_BUZZER_EN pinMode(PIN_BUZZER_EN, OUTPUT); digitalWrite(PIN_BUZZER_EN, HIGH); #endif pinMode(PIN_BUZZER, OUTPUT); digitalWrite(PIN_BUZZER, LOW); // need to pull low by default to avoid extreme power draw +#endif #if defined(NRF52_PLATFORM) _isr_instance = this; NRF_TIMER1->TASKS_STOP = 1; @@ -39,9 +46,11 @@ void genericBuzzer::startup() { play(startup_song); } void genericBuzzer::shutdown() { play(shutdown_song); } // --------------------------------------------------------------------------- -// nRF52 path — direct NRF_PWM2 control, bypasses tone() +// Shared RTTTL parser -- pure string/arithmetic, no hardware access, so both +// the NRF52 direct-PWM player and the sim's poll-only player (below) reuse +// it verbatim instead of each carrying their own copy. // --------------------------------------------------------------------------- -#if defined(NRF52_PLATFORM) +#if defined(NRF52_PLATFORM) || defined(SIM_PLATFORM) // Chromatic frequencies for octave 4 (Hz): C C# D D# E F F# G G# A A# B static const uint16_t CHROM4[12] = { 262, 277, 294, 311, 330, 349, 370, 392, 415, 440, 466, 494 }; @@ -100,6 +109,13 @@ bool genericBuzzer::_parseNext(const char*& p, uint8_t def_dur, uint8_t def_oct, return true; } +#endif // NRF52_PLATFORM || SIM_PLATFORM + +// --------------------------------------------------------------------------- +// nRF52 path — direct NRF_PWM2 control, bypasses tone() +// --------------------------------------------------------------------------- +#if defined(NRF52_PLATFORM) + uint8_t genericBuzzer::_dutyPct() const { // Inverted polarity (0x8000 bit): duty_HIGH = 100% - PCT. // Values chosen for ~6-8 dB perceptual steps: -24/-16/-9/-3/0 dB. @@ -261,9 +277,67 @@ void genericBuzzer::setVolume(uint8_t level) { } // --------------------------------------------------------------------------- -// Non-nRF52 path — NonBlockingRtttl + analogWrite for volume +// Sim path — no real PWM/timer hardware; just track (freq, note-end-time) +// and let loop() poll millis() to advance, same non-blocking shape as the +// NonBlockingRtttl path below minus the library. A host page polls +// currentFreqHz()/isPlaying() every frame to drive a Web Audio oscillator +// (see variants/sim/web/index.html) instead of sounding real hardware. +// --------------------------------------------------------------------------- +#elif defined(SIM_PLATFORM) + +void genericBuzzer::_advance() { + uint16_t freq; uint32_t dur_ms; + if (_parseNext(_rtttl_pos, _def_dur, _def_oct, _def_bpm, freq, dur_ms)) { + _cur_freq = freq; + _note_end_ms = millis() + dur_ms; + } else { + _cur_freq = 0; + _rtttl_done = true; + } +} + +void genericBuzzer::applyVolume() { + // No hardware duty cycle to touch -- the host page maps getVolume() + // (0-4) to a Web Audio gain value itself. +} + +void genericBuzzer::play(const char* melody) { + if (_is_quiet) return; + playForced(melody); +} + +void genericBuzzer::playForced(const char* melody) { + _rtttl_done = true; + _cur_freq = 0; + if (!melody || !*melody) return; + const char* notes; + _parseHeader(melody, _def_dur, _def_oct, _def_bpm, notes); + _rtttl_pos = notes; + _rtttl_done = false; + _advance(); +} + +bool genericBuzzer::isPlaying() { return !_rtttl_done; } + +void genericBuzzer::stop() { + _rtttl_done = true; + _cur_freq = 0; +} + +void genericBuzzer::loop() { + if (_rtttl_done) return; + if ((int32_t)(millis() - _note_end_ms) >= 0) _advance(); +} + +void genericBuzzer::setVolume(uint8_t level) { + _volume_level = level < 5 ? level : 4; +} + +#else // NRF52_PLATFORM / SIM_PLATFORM + +// --------------------------------------------------------------------------- +// Non-nRF52, non-sim path — NonBlockingRtttl + analogWrite for volume // --------------------------------------------------------------------------- -#else void genericBuzzer::applyVolume() { // After tone() sets 50% duty, analogWrite overrides duty on the same PWM channel. @@ -299,6 +373,6 @@ void genericBuzzer::setVolume(uint8_t level) { if (isPlaying()) applyVolume(); } -#endif // NRF52_PLATFORM +#endif // NRF52_PLATFORM / SIM_PLATFORM #endif // PIN_BUZZER diff --git a/src/helpers/ui/buzzer.h b/src/helpers/ui/buzzer.h index 80583de5..429a7f16 100644 --- a/src/helpers/ui/buzzer.h +++ b/src/helpers/ui/buzzer.h @@ -2,9 +2,10 @@ #include -// NRF52 uses a custom non-blocking RTTTL player (see buzzer.cpp); only the -// other platforms pull in the NonBlockingRtttl library here. -#if !defined(NRF52_PLATFORM) +// NRF52 (and the sim, see buzzer.cpp) use a custom non-blocking RTTTL +// player; only the remaining platforms pull in the NonBlockingRtttl library +// here. +#if !defined(NRF52_PLATFORM) && !defined(SIM_PLATFORM) #include #endif @@ -44,6 +45,25 @@ class genericBuzzer const char *shutdown_song = "Shutdown:d=4,o=5,b=100:8g5,16e5,16c5"; bool _is_quiet = true; +#if defined(NRF52_PLATFORM) || defined(SIM_PLATFORM) + // Shared RTTTL cursor state + parser, reused by both the NRF52 + // direct-PWM player below and the sim's poll-only player (buzzer.cpp, + // #elif defined(SIM_PLATFORM)) -- the parser itself never touches + // hardware, only _nrfStartPwm/_nrfStopPwm/the TIMER1 IRQ do, so it's + // free to share between the two. + const char* _rtttl_pos = nullptr; + bool _rtttl_done = true; + uint8_t _def_dur = 4; + uint8_t _def_oct = 5; + uint16_t _def_bpm = 120; + + static uint16_t _noteFreq(char letter, bool sharp, uint8_t octave); + static bool _parseNext(const char*& pos, uint8_t def_dur, uint8_t def_oct, + uint16_t bpm, uint16_t& freq_hz, uint32_t& dur_ms); + static void _parseHeader(const char* melody, uint8_t& def_dur, uint8_t& def_oct, + uint16_t& bpm, const char*& notes_start); +#endif + #if defined(NRF52_PLATFORM) // Own RTTTL player — bypasses tone() to allow volume control from note start. // tone() pre-computes seq_refresh so the DMA repeats 50% duty for ~30ms before @@ -51,23 +71,13 @@ class genericBuzzer // and setting REFRESH=0, DMA re-reads _duty_buf every period so duty takes effect // immediately at SEQSTART. volatile uint16_t _duty_buf = 0; // DMA source — must stay in RAM - const char* _rtttl_pos = nullptr; - bool _rtttl_done = true; bool _pwm_on = false; - uint8_t _def_dur = 4; - uint8_t _def_oct = 5; - uint16_t _def_bpm = 120; void _nrfBegin(const char* melody); void _nrfAdvance(); void _nrfStartPwm(uint16_t freq); void _nrfStopPwm(); uint8_t _dutyPct() const; - static uint16_t _noteFreq(char letter, bool sharp, uint8_t octave); - static bool _parseNext(const char*& pos, uint8_t def_dur, uint8_t def_oct, - uint16_t bpm, uint16_t& freq_hz, uint32_t& dur_ms); - static void _parseHeader(const char* melody, uint8_t& def_dur, uint8_t& def_oct, - uint16_t& bpm, const char*& notes_start); // TIMER1-driven note advance: a hardware compare interrupt calls // _nrfAdvance() at the exact moment the current note's duration ends, @@ -84,5 +94,22 @@ class genericBuzzer // a free function — the vector table requires that exact symbol — so // it needs access from outside the class to dispatch into it. static void _timer1ISR(); + private: +#elif defined(SIM_PLATFORM) + // No real PWM/timer hardware to drive -- just track which frequency + // (0 = silent) should be sounding right now and when the current + // note ends, advanced by plain millis()-polling from loop() (same + // non-blocking shape as the NonBlockingRtttl-driven platforms, not + // the NRF52 IRQ path). A host page polls currentFreqHz()/isPlaying() + // every frame to drive a Web Audio oscillator -- see + // variants/sim/web/index.html. + uint32_t _note_end_ms = 0; + + void _advance(); + + public: + uint16_t currentFreqHz() const { return _cur_freq; } + private: + uint16_t _cur_freq = 0; #endif }; diff --git a/variants/sim/build_wasm.sh b/variants/sim/build_wasm.sh index f6ab255c..e8391702 100755 --- a/variants/sim/build_wasm.sh +++ b/variants/sim/build_wasm.sh @@ -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 ) diff --git a/variants/sim/platformio.ini b/variants/sim/platformio.ini index 95e44966..fb1e917f 100644 --- a/variants/sim/platformio.ini +++ b/variants/sim/platformio.ini @@ -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> diff --git a/variants/sim/web/index.html b/variants/sim/web/index.html index def79093..2a42fdf2 100644 --- a/variants/sim/web/index.html +++ b/variants/sim/web/index.html @@ -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); diff --git a/variants/sim/web/mesh.html b/variants/sim/web/mesh.html index ec37c410..007648da 100644 --- a/variants/sim/web/mesh.html +++ b/variants/sim/web/mesh.html @@ -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);