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
+79 -5
View File
@@ -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
+40 -13
View File
@@ -2,9 +2,10 @@
#include <Arduino.h>
// 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 <NonBlockingRtttl.h>
#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
};