From 938dfefba689fae755902d42e50dff29ad23c8db Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:27:52 +0200 Subject: [PATCH] feat(ui-core,ui-lvgl): sound in the Core, L2 speaker via ES8311, Sound settings, melody editor - genericBuzzer I2S path (BUZZER_I2S): an audio task synthesises the notes with eased edges and keeps the DMA queue fed with silence between sounds; ES8311 setup; amp powered through the board only around sounds - ui-core/SoundControl.h (mode, volume, built-in sounds, melody model), SoundNotifier moved to the Core, chanctl::melody, schema Sound page - L2: notifications, alarm / timer ring, locator sounds, Settings > Sound, melody editor with the playing note lit, per-chat sound, mute icon - noteIndex() on every buzzer backend; scrolling popups past screen height Co-Authored-By: Claude Opus 5.5 --- docs/development/l2-hardware-checklist.md | 17 +- docs/development/ui-core.md | 2 +- .../companion_radio/ui-core/ChannelControl.h | 15 + .../companion_radio/ui-core/SettingsSchema.h | 31 +- .../companion_radio/ui-core/SoundControl.h | 173 +++++++ .../{ui-new => ui-core}/SoundNotifier.h | 7 +- examples/companion_radio/ui-core/UiCoreHost.h | 3 + .../companion_radio/ui-lvgl/ChannelScreen.h | 30 +- .../companion_radio/ui-lvgl/ClockScreen.h | 1 + .../ui-lvgl/ConversationScreen.h | 6 + examples/companion_radio/ui-lvgl/NavMap.h | 5 +- .../companion_radio/ui-lvgl/RadioScreen.h | 24 + .../companion_radio/ui-lvgl/SoundScreen.h | 468 ++++++++++++++++++ examples/companion_radio/ui-lvgl/UITask.cpp | 83 +++- examples/companion_radio/ui-lvgl/UITask.h | 41 +- .../companion_radio/ui-new/MessagesScreen.h | 38 +- .../ui-new/RingtoneEditorScreen.h | 67 +-- examples/companion_radio/ui-new/UITask.cpp | 43 +- solo/wio-tracker-l2/platformio.ini | 3 + src/helpers/ui/ES8311.h | 45 ++ src/helpers/ui/buzzer.cpp | 295 ++++++++++- src/helpers/ui/buzzer.h | 38 +- variants/sim/web/lvgl.html | 39 +- variants/wio-tracker-l2/platformio.ini | 8 + variants/wio-tracker-l2/target.cpp | 4 + 25 files changed, 1335 insertions(+), 151 deletions(-) create mode 100644 examples/companion_radio/ui-core/SoundControl.h rename examples/companion_radio/{ui-new => ui-core}/SoundNotifier.h (90%) create mode 100644 examples/companion_radio/ui-lvgl/SoundScreen.h create mode 100644 src/helpers/ui/ES8311.h diff --git a/docs/development/l2-hardware-checklist.md b/docs/development/l2-hardware-checklist.md index 8abfe4b5..62c3dfc4 100644 --- a/docs/development/l2-hardware-checklist.md +++ b/docs/development/l2-hardware-checklist.md @@ -34,7 +34,7 @@ Confirmed: green dot inside the ring, waypoints much better, download works, set ## New: Clock, settings pages, radio (2026-09-25) - [ ] Home has two pages now: swipe left → Clock (dots under the tiles follow; Home remembers the page) - [ ] Clock > Alarm: hour / minute rollers (setting a time arms it), On switch, Repeat (Once / Daily / Weekdays / Weekends); rings at the local time -- [ ] Clock > Timer: H / M / S rollers, Start → big countdown, Stop; when it ends a full-screen "Timer done" card with Dismiss appears on any screen (also wakes the display; the user button dismisses too). Silent: no speaker driver yet +- [ ] Clock > Timer: H / M / S rollers, Start → big countdown, Stop; when it ends a full-screen "Timer done" card with Dismiss appears on any screen (also wakes the display; the user button dismisses too). Rings with a melody since the Sound round - [ ] Clock > Stopwatch: Start / Stop / Reset with tenths - [ ] Settings > Display & power: Screen off after; Wake on message - [ ] Settings > Display & power: Battery shutdown (not while on USB), GPS power saving, Time zone (clock + alarm follow) @@ -66,6 +66,21 @@ Confirmed: green dot inside the ring, waypoints much better, download works, set - [ ] Room server login from the phone app / L1 still works while this exists (only admin logins go to the new session) - [ ] L1 (ui-new): Tools > Admin works as before (login, saved password, typed values, confirm reboot); it now also skips the login for the node you just used +## New: Sound (2026-09-26) +The speaker plays through the ES8311 codec (I2S MCLK 10 / BCK 11 / WS 12 / DOUT 16, amp on expander P12). Pins come from Seeed's Meshtastic port; nothing was heard on the device yet. +- [ ] A short startup chime after boot (sound On); a goodbye sound on Power off +- [ ] Settings > Sound: On / Off / Auto (Auto: silent while the app is connected); Off shows a muted-speaker icon in the status bar +- [ ] Volume slider (5 steps): a beep on release at the new level; the quietest is still audible, the loudest doesn't distort or rattle: ______ +- [x] No knock between sounds, however fast or slow the taps (fixed: notes fade in / out on a raised cosine, a cut note fades over 15 ms, and the DMA queue is kept full of silence between sounds -- a sound starting into a queue that had run dry was played from a half-written buffer) +- [x] Known, kept: one soft knock on the first sound after >3 s of silence -- the class-D amp's own power-up pop (a longer codec settle didn't change it, only delayed the sound). Kept the amp off between sounds for battery / GNSS; alternatives if it bothers: amp on while the screen is on, or always on +- [ ] Direct message, channel message and advert each play their sound (Built-in / Melody 1 / Melody 2 / None under PLAYS FOR); "Advert sound for: Direct only" skips adverts that came through repeaters +- [ ] Hold a chat in Messages: Alerts (Default / Muted / Always) and Sound (Default / Melody 1 / Melody 2) apply to that chat only; Always plays even with sound Off +- [ ] Clock alarm / timer rings with a repeating melody until Dismiss (or the user button); sound Off doesn't silence it +- [ ] Arrival alert plays a rising / falling triple; Proximity beeper ticks faster closer to the target +- [ ] Settings > Sound > Melodies: + adds a note (a copy of the selected one), tap a note to pick it; pitch / octave / length change it with a short preview; trash deletes; BPM; Play / Stop in the header, the note sounding lights up in full colour (the strip scrolls along); saved on Back or when switching Melody 1 / 2 ("Melody 1 saved"); the same melodies play on L1 if the prefs are shared +- [ ] Timing stays even while the map redraws (notes are counted in samples by the audio task, not by the UI loop) +- [ ] GPS keeps its fix while sounds play (the amp is off between sounds to keep its switching noise away from the antenna) + ## New: Repeater mode (2026-09-25) - [ ] Settings > CONNECTIVITY > Repeater (row shows Off / On and what it relays on): switch on → "Repeater on", the loop icon appears in the status bar; off → it goes away - [ ] Relay on Custom: the radio moves to the profile's frequency while on and back to the chat frequency when off (Settings > Radio row / another node on each frequency); Current: stays on the chat frequency diff --git a/docs/development/ui-core.md b/docs/development/ui-core.md index 6bb8cfef..4714c564 100644 --- a/docs/development/ui-core.md +++ b/docs/development/ui-core.md @@ -110,7 +110,7 @@ call the same ones, so e.g. "marking a channel read" has one implementation. ### 5. Declarative settings schema -`ui-core/SettingsSchema.h` holds three pages -- navigation (trail, live share, arrival alert, units), display & power (brightness, screen off, wake on message, lock screen, battery display, battery shutdown, GPS power saving, time zone, 12-hour clock, clock seconds) and messages & contacts (DM resends, contact expiry, favourites first). An entry is a one-byte index by default, or a `values` table over a 1/2/4-byte field (seconds, millivolts, an hour offset, `{1, 0}` for a field stored inverted); side effects run through `UiCore` (`applyGpsInterval()`, `host()->applyDisplayPrefs()`). ui-lvgl renders each page; ui-new still uses its own screens. Radio settings are not schema rows (preset list, typed frequency, radio side effects): `ui-core/RadioControl.h` applies them (`applyParams`, `applyTxPower`, `applyApc`, presets = built-ins + user slots) for both ui-new's Settings > Radio and ui-lvgl's Radio screen. Channel management is `ui-core/ChannelControl.h` (`chanctl::`): add Public / Hashtag / Private (passphrase or 32-hex key), rename, delete, notifications / favourite / scope, and `onRemoved()` -- the cleanup of every channel-keyed pref, run by `UiCore::onChannelRemoved` whoever deleted the channel (this device or the app); contacts get the same through `UiCore::cleanupContactPrefs`. Favourites dial slots: `ui-core/Favourites.h` (`pinContact` / `pinChannel` move an entry that is already on another slot; the slot's name is `contactctl::favName`). The schema's display page also carries the lock screen (`auto_lock`) and the 12-hour clock, which ui-lvgl implements (a slide-to-unlock card after the screen turns off). Remote admin is `ui-core/AdminSession.h` (`UiCore::admin`): login with the saved password (forgotten on a wrong password or a silent timeout), CLI round trips with timeouts, the field table (`admin::`), typed values; results come out one at a time through `take()`. `UiCore` offers login answers and CLI replies to it first; only room logins it wasn't waiting for reach the frontend. ui-new's Tools > Admin and ui-lvgl's Admin screen are views over it. The bot's settings are a table too, `ui-core/BotConfig.h` (`botcfg::`: tabs, rows, the NodePrefs flag / text / hour behind each, reply placeholders); ui-new's Tools > Bot and ui-lvgl's Bot screen render it, the bot itself runs in MyMesh. Room servers: `ui-core/RoomSessions.h` (`UiCore::rooms`) keeps who is logged in this session, the one login in flight (with a timeout) and the saved passwords (saved on success, forgotten on failure); `open(contact)` says whether to go straight in, wait for a background login or ask for a password, and outcomes come out through `take()`. Per-contact helpers are `ui-core/ContactControl.h` (`contactctl::`: DM notification / melody overrides, favourite flag, the `@[name] ` reply prefix, splitting a room post into author and text, naming a message's path hops). ui-new's Messages and ui-lvgl's Chats / thread use both. Diagnostics rows are `ui-core/Diagnostics.h` (`diag::`: live counters, system card, font card, counter reset), shown by ui-new's Tools > Diagnostics and ui-lvgl's Settings > Diagnostics. Auto-advert is a schema row (`advert_auto_interval_sec`, sent by MyMesh); ui-new keeps its Tools > Auto-Advert screen over the same pref. The compass (ui-lvgl Home > Compass, ui-new Tools > Compass) reads `UiCore::course`. Saving / deleting the user's radio presets is `radioctl::saveUserPreset` / `deleteUserPreset` (ui-new's preset picker and ui-lvgl's Radio > My presets); the scope list is edited through MyMesh (`addScope` / `renameScope` / `removeScope` / `setDefaultScope`), which ui-lvgl's Radio > Scopes and ui-new's Settings > Radio > Scope call directly. The battery percentage (LiPo curve, empty at the shutdown voltage) and the display mode are `ui-core/Battery.h`, used by both status bars; the schema carries Battery display and Clock seconds. The companion's own repeater mode is `ui-core/RepeaterControl.h` (`rptctl::`: on / off with its radio, RX power-save and APC side effects, Current / Custom network with the profile seeded from the chat params, the profile's preset, the filters' ranges and labels, the extra scopes mask); ui-new's Tools > Repeater and ui-lvgl's Settings > Repeater use it, MyMesh does the relaying. +`ui-core/SettingsSchema.h` holds four pages -- navigation (trail, live share, arrival alert, units), display & power (brightness, screen off, wake on message, lock screen, battery display, battery shutdown, GPS power saving, time zone, 12-hour clock, clock seconds) messages & contacts (DM resends, contact expiry, favourites first) and sound (volume, the sound for direct messages / channels / adverts, advert sound scope). An entry is a one-byte index by default, or a `values` table over a 1/2/4-byte field (seconds, millivolts, an hour offset, `{1, 0}` for a field stored inverted); side effects run through `UiCore` (`applyGpsInterval()`, `host()->applyDisplayPrefs()`). ui-lvgl renders each page; ui-new still uses its own screens. Radio settings are not schema rows (preset list, typed frequency, radio side effects): `ui-core/RadioControl.h` applies them (`applyParams`, `applyTxPower`, `applyApc`, presets = built-ins + user slots) for both ui-new's Settings > Radio and ui-lvgl's Radio screen. Channel management is `ui-core/ChannelControl.h` (`chanctl::`): add Public / Hashtag / Private (passphrase or 32-hex key), rename, delete, notifications / favourite / scope, and `onRemoved()` -- the cleanup of every channel-keyed pref, run by `UiCore::onChannelRemoved` whoever deleted the channel (this device or the app); contacts get the same through `UiCore::cleanupContactPrefs`. Favourites dial slots: `ui-core/Favourites.h` (`pinContact` / `pinChannel` move an entry that is already on another slot; the slot's name is `contactctl::favName`). The schema's display page also carries the lock screen (`auto_lock`) and the 12-hour clock, which ui-lvgl implements (a slide-to-unlock card after the screen turns off). Remote admin is `ui-core/AdminSession.h` (`UiCore::admin`): login with the saved password (forgotten on a wrong password or a silent timeout), CLI round trips with timeouts, the field table (`admin::`), typed values; results come out one at a time through `take()`. `UiCore` offers login answers and CLI replies to it first; only room logins it wasn't waiting for reach the frontend. ui-new's Tools > Admin and ui-lvgl's Admin screen are views over it. The bot's settings are a table too, `ui-core/BotConfig.h` (`botcfg::`: tabs, rows, the NodePrefs flag / text / hour behind each, reply placeholders); ui-new's Tools > Bot and ui-lvgl's Bot screen render it, the bot itself runs in MyMesh. Room servers: `ui-core/RoomSessions.h` (`UiCore::rooms`) keeps who is logged in this session, the one login in flight (with a timeout) and the saved passwords (saved on success, forgotten on failure); `open(contact)` says whether to go straight in, wait for a background login or ask for a password, and outcomes come out through `take()`. Per-contact helpers are `ui-core/ContactControl.h` (`contactctl::`: DM notification / melody overrides, favourite flag, the `@[name] ` reply prefix, splitting a room post into author and text, naming a message's path hops). ui-new's Messages and ui-lvgl's Chats / thread use both. Diagnostics rows are `ui-core/Diagnostics.h` (`diag::`: live counters, system card, font card, counter reset), shown by ui-new's Tools > Diagnostics and ui-lvgl's Settings > Diagnostics. Auto-advert is a schema row (`advert_auto_interval_sec`, sent by MyMesh); ui-new keeps its Tools > Auto-Advert screen over the same pref. The compass (ui-lvgl Home > Compass, ui-new Tools > Compass) reads `UiCore::course`. Saving / deleting the user's radio presets is `radioctl::saveUserPreset` / `deleteUserPreset` (ui-new's preset picker and ui-lvgl's Radio > My presets); the scope list is edited through MyMesh (`addScope` / `renameScope` / `removeScope` / `setDefaultScope`), which ui-lvgl's Radio > Scopes and ui-new's Settings > Radio > Scope call directly. The battery percentage (LiPo curve, empty at the shutdown voltage) and the display mode are `ui-core/Battery.h`, used by both status bars; the schema carries Battery display and Clock seconds. The companion's own repeater mode is `ui-core/RepeaterControl.h` (`rptctl::`: on / off with its radio, RX power-save and APC side effects, Current / Custom network with the profile seeded from the chat params, the profile's preset, the filters' ranges and labels, the extra scopes mask); ui-new's Tools > Repeater and ui-lvgl's Settings > Repeater use it, MyMesh does the relaying. Sound is `ui-core/SoundControl.h` (`soundctl::`: the On / Off / Auto mode over `buzzer_quiet` + `buzzer_auto`, the volume with its preview, the built-in sounds, the two user melodies as an editable note list) and `ui-core/SoundNotifier.h` (which sound a message / advert plays: global choice, per-contact / per-channel alert and melody overrides); the schema's Sound page holds the volume and the PLAYS FOR choices, the channel melody override is `chanctl::melody`. Both frontends drive a `genericBuzzer`: a PWM piezo on L1, the ES8311 codec on the L2 (`BUZZER_I2S`: an audio task synthesises the notes and counts samples, the board powers the amp through `buzzerAmpPower()`). Replaces the per-item code in `SettingsScreen`: diff --git a/examples/companion_radio/ui-core/ChannelControl.h b/examples/companion_radio/ui-core/ChannelControl.h index cd95d17e..a4ad685d 100644 --- a/examples/companion_radio/ui-core/ChannelControl.h +++ b/examples/companion_radio/ui-core/ChannelControl.h @@ -171,6 +171,21 @@ static void setNotif(NodePrefs* p, uint8_t idx, uint8_t state) { if (state == NOTIF_MUTED) p->ch_notif_muted |= m; else p->ch_notif_muted &= ~m; } +// Melody slot: 0 = the global channel sound, 1 / 2 = the user melodies. +static uint8_t melody(const NodePrefs* p, uint8_t idx) { + uint64_t m = 1ULL << idx; + if (!p || !(p->ch_notif_melody_set & m)) return 0; + return (p->ch_notif_melody_2 & m) ? 2 : 1; +} + +static void setMelody(NodePrefs* p, uint8_t idx, uint8_t slot) { + if (!p) return; + uint64_t m = 1ULL << idx; + if (slot == 0) { p->ch_notif_melody_set &= ~m; p->ch_notif_melody_2 &= ~m; return; } + p->ch_notif_melody_set |= m; + if (slot == 2) p->ch_notif_melody_2 |= m; else p->ch_notif_melody_2 &= ~m; +} + static bool favourite(const NodePrefs* p, uint8_t idx) { return p && (p->ch_fav_bitmask & (1ULL << idx)); } static void setFavourite(NodePrefs* p, uint8_t idx, bool on) { diff --git a/examples/companion_radio/ui-core/SettingsSchema.h b/examples/companion_radio/ui-core/SettingsSchema.h index 0808c3ac..004a5018 100644 --- a/examples/companion_radio/ui-core/SettingsSchema.h +++ b/examples/companion_radio/ui-core/SettingsSchema.h @@ -14,29 +14,33 @@ #include #include "../NodePrefs.h" #include "../Trail.h" +#include "SoundControl.h" namespace settings { // Pages (one screen each) and the sections on them, in display order. -enum Page : uint8_t { PG_NAV, PG_DEVICE, PG_MESSAGES, PG_COUNT }; +enum Page : uint8_t { PG_NAV, PG_DEVICE, PG_MESSAGES, PG_SOUND, PG_COUNT }; enum Section : uint8_t { SEC_TRAIL, SEC_LIVE_SHARE, SEC_LOCATOR, SEC_UNITS, SEC_DISPLAY, SEC_POWER, SEC_TIME, - SEC_MESSAGES, SEC_CONTACTS, SEC_COUNT }; + SEC_MESSAGES, SEC_CONTACTS, + SEC_SOUND, SEC_SOUND_FOR, SEC_COUNT }; static const char* pageTitle(uint8_t p) { - static const char* T[PG_COUNT] = { "Trail, live share, alerts", "Display & power", "Messages & contacts" }; + static const char* T[PG_COUNT] = { "Trail, live share, alerts", "Display & power", "Messages & contacts", "Sound" }; return p < PG_COUNT ? T[p] : ""; } static const char* sectionTitle(uint8_t s) { static const char* T[SEC_COUNT] = { "TRAIL", "LIVE SHARE", "ARRIVAL ALERT", "UNITS", "DISPLAY", "POWER", "TIME", - "MESSAGES", "CONTACTS" }; + "MESSAGES", "CONTACTS", + "SOUND", "PLAYS FOR" }; return s < SEC_COUNT ? T[s] : ""; } static uint8_t sectionPage(uint8_t s) { static const uint8_t P[SEC_COUNT] = { PG_NAV, PG_NAV, PG_NAV, PG_NAV, PG_DEVICE, PG_DEVICE, PG_DEVICE, - PG_MESSAGES, PG_MESSAGES }; + PG_MESSAGES, PG_MESSAGES, + PG_SOUND, PG_SOUND }; return s < SEC_COUNT ? P[s] : PG_NAV; } @@ -126,12 +130,21 @@ static void optBattDisplay(uint8_t v, char* b, int n, const NodePrefs&) { static void optExpiry(uint8_t v, char* b, int n, const NodePrefs&) { snprintf(b, n, "%s", NodePrefs::contactExpiryLabel(v)); } +static void optVolume(uint8_t v, char* b, int n, const NodePrefs&) { + static const char* L[5] = { "Quietest", "Quiet", "Medium", "Loud", "Loudest" }; + snprintf(b, n, "%s", L[v < 5 ? v : 4]); +} +static void optSound(uint8_t v, char* b, int n, const NodePrefs&) { snprintf(b, n, "%s", soundctl::soundLabel(v)); } +static void optAdvertScope(uint8_t v, char* b, int n, const NodePrefs&) { + snprintf(b, n, "%s", v == ADVERT_SOUND_SCOPE_ZERO_HOP ? "Direct only" : "All"); +} // ── Side effects ────────────────────────────────────────────────────────────── static void rearmLocator(UiCore& c) { c.locator.reset(); } static void restartShareClock(UiCore& c) { c.live_share.restartClock(); } // a new length starts over static void applyDisplay(UiCore& c) { c.host()->applyDisplayPrefs(); } static void applyGpsDuty(UiCore& c) { c.applyGpsInterval(); } +static void applySound(UiCore& c) { c.host()->applySoundPrefs(); } #define NP_OFF(f) (uint16_t)offsetof(NodePrefs, f) #define NP_SIZE(f) (uint8_t)sizeof(((NodePrefs*)0)->f) @@ -190,6 +203,14 @@ static const Setting ALL[] = { IDX("Contact expiry", "Inactive this long can be pruned", SEC_CONTACTS, contact_expiry_idx, NodePrefs::CONTACT_EXPIRY_COUNT, optExpiry, nullptr), MAP("Favourites first", "In contact and node lists", SEC_CONTACTS, fav_sort_off, INVERTED, nullptr, nullptr), + + // On / Off / Auto spans two fields (soundctl::setMode): the frontend's own row. + IDX("Volume", nullptr, SEC_SOUND, buzzer_volume, 5, optVolume, applySound), + + IDX("Direct messages", nullptr, SEC_SOUND_FOR, notif_melody_dm, soundctl::SOUND_COUNT, optSound, nullptr), + IDX("Channels", nullptr, SEC_SOUND_FOR, notif_melody_ch, soundctl::SOUND_COUNT, optSound, nullptr), + IDX("Adverts", "A node announcing itself", SEC_SOUND_FOR, notif_melody_ad, soundctl::SOUND_COUNT, optSound, nullptr), + IDX("Advert sound for", "Direct: no repeaters", SEC_SOUND_FOR, advert_sound_scope, 2, optAdvertScope, nullptr), }; #undef IDX #undef SW diff --git a/examples/companion_radio/ui-core/SoundControl.h b/examples/companion_radio/ui-core/SoundControl.h new file mode 100644 index 00000000..176b0d3b --- /dev/null +++ b/examples/companion_radio/ui-core/SoundControl.h @@ -0,0 +1,173 @@ +#pragma once +// Sound settings and melodies, shared by ui-new (Settings > Sound, Tools > +// Ringtone) and ui-lvgl (Settings > Sound, Melodies): the On / Off / Auto mode +// (buzzer_quiet + buzzer_auto), the volume, the built-in sounds, the labels of +// the notification sound choices, and the two user melodies (ringtone_* / +// ringtone2_*) as an editable note list. Which sound a message plays is +// SoundNotifier.h. + +#include "../NodePrefs.h" +#ifdef PIN_BUZZER + #include +#endif + +namespace soundctl { + +// ── Built-in sounds (RTTTL) ─────────────────────────────────────────────────── +static const char* const MEL_ALARM = "alarm:d=8,o=6,b=125:c,c,c,c,p,c,c,c,c,p"; // clock alarm / timer ring +static const char* const MEL_ARRIVE = "locarr:d=8,o=6,b=140:c,e,g"; // arrival alert: reached the target +static const char* const MEL_LEAVE = "loclv:d=8,o=6,b=140:g,e,c"; // ... left its radius +static const char* const MEL_TICK = "locp:d=32,o=7,b=200:c"; // proximity beeper tick +static const char* const MEL_VOLUME = "Vol:d=16,o=6,b=120:c"; // volume preview + +// ── Mode ────────────────────────────────────────────────────────────────────── +// Auto: muted while an app is connected (it notifies instead). +enum Mode : uint8_t { MODE_ON, MODE_OFF, MODE_AUTO, MODE_COUNT }; + +static uint8_t mode(const NodePrefs* p) { + if (!p) return MODE_ON; + if (p->buzzer_auto) return MODE_AUTO; + return p->buzzer_quiet ? MODE_OFF : MODE_ON; +} +static const char* modeLabel(uint8_t m) { + static const char* L[MODE_COUNT] = { "On", "Off", "Auto" }; + return L[m < MODE_COUNT ? m : 0]; +} + +// notif_melody_dm / _ch / _ad: 0 = built-in, 1 / 2 = the user melodies, 3 = none. +static const uint8_t SOUND_COUNT = 4; +static const char* soundLabel(uint8_t v) { + static const char* L[SOUND_COUNT] = { "Built-in", "Melody 1", "Melody 2", "None" }; + return L[v < SOUND_COUNT ? v : 0]; +} +// A contact's / channel's melody override: 0 = its global sound. +static const uint8_t OVERRIDE_COUNT = 3; + +#ifdef PIN_BUZZER +// The mute that goes with the mode; `connected`: an app is connected. +static void applyMode(const NodePrefs* p, genericBuzzer& b, bool connected) { + uint8_t m = mode(p); + b.quiet(m == MODE_OFF || (m == MODE_AUTO && connected)); +} + +// Auto keeps buzzer_quiet (the manual choice it goes back to). The caller +// saves the prefs. +static void setMode(NodePrefs* p, genericBuzzer& b, uint8_t m, bool connected) { + if (!p || m >= MODE_COUNT) return; + p->buzzer_auto = m == MODE_AUTO; + if (m != MODE_AUTO) p->buzzer_quiet = m == MODE_OFF; + applyMode(p, b, connected); +} + +// From the loop: Auto follows the app connection. True when the mute changed. +static bool autoTick(const NodePrefs* p, genericBuzzer& b, bool connected) { + if (!p || !p->buzzer_auto || b.isQuiet() == connected) return false; + b.quiet(connected); + return true; +} + +// Level 0..4, with a preview beep (through mute: it answers the user's own +// change). The caller saves the prefs. +static void setVolume(NodePrefs* p, genericBuzzer& b, uint8_t level) { + if (level > 4) level = 4; + if (p) p->buzzer_volume = level; + b.setVolume(level); + b.playForced(MEL_VOLUME); +} +#endif + +// ── User melodies ───────────────────────────────────────────────────────────── +// A note is one byte: bits 0-2 pitch (0 = rest, 1..7 = c d e f g a b), bits +// 3-4 octave - 4, bits 5-6 duration (1/4, 1/8, 1/16, 1/32). +static const int MAX_NOTES = 32; +static const uint8_t BPM_COUNT = 5, DUR_COUNT = 4, PITCH_COUNT = 8; +static const uint8_t OCT_MIN = 4, OCT_MAX = 6; + +static uint16_t bpm(uint8_t idx) { + static const uint16_t B[BPM_COUNT] = { 60, 90, 120, 150, 180 }; + return B[idx < BPM_COUNT ? idx : 2]; +} +static const char* durLabel(uint8_t d) { + static const char* L[DUR_COUNT] = { "1/4", "1/8", "1/16", "1/32" }; + return L[d & 3]; +} +static char pitchChar(uint8_t p) { // lowercase RTTTL name, 'p' = rest + static const char P[PITCH_COUNT] = { 'p', 'c', 'd', 'e', 'f', 'g', 'a', 'b' }; + return P[p & 7]; +} + +static uint8_t notePitch(uint8_t b) { return b & 0x07; } +static uint8_t noteOctave(uint8_t b) { return ((b >> 3) & 0x03) + 4; } +static uint8_t noteDur(uint8_t b) { return (b >> 5) & 0x03; } +static uint8_t packNote(uint8_t pitch, uint8_t octave, uint8_t dur) { + return (pitch & 0x07) | (((octave - 4) & 0x03) << 3) | ((dur & 0x03) << 5); +} +static const uint8_t NEW_NOTE = (1 & 0x07) | ((5 - 4) << 3) | (1 << 5); // C5, 1/8 + +// "C5", or "--" for a rest. +static void noteName(uint8_t b, char out[3]) { + uint8_t p = notePitch(b); + out[0] = p ? (char)(pitchChar(p) - 32) : '-'; + out[1] = p ? (char)('0' + noteOctave(b)) : '-'; + out[2] = '\0'; +} + +struct Melody { + uint8_t notes[MAX_NOTES]; + uint8_t len; + uint8_t bpm_idx; +}; + +// slot 0 = melody 1, 1 = melody 2. +static void load(const NodePrefs* p, int slot, Melody& m) { + memset(&m, 0, sizeof(m)); + m.bpm_idx = 2; + if (!p) return; + bool s2 = slot == 1; + uint8_t b = s2 ? p->ringtone2_bpm_idx : p->ringtone_bpm_idx; + uint8_t n = s2 ? p->ringtone2_len : p->ringtone_len; + m.bpm_idx = b < BPM_COUNT ? b : 2; + m.len = n <= MAX_NOTES ? n : 0; + memcpy(m.notes, s2 ? p->ringtone2_notes : p->ringtone_notes, MAX_NOTES); +} + +// The caller saves the prefs. +static void store(NodePrefs* p, int slot, const Melody& m) { + if (!p) return; + if (slot == 1) { + p->ringtone2_bpm_idx = m.bpm_idx; p->ringtone2_len = m.len; + memcpy(p->ringtone2_notes, m.notes, MAX_NOTES); + } else { + p->ringtone_bpm_idx = m.bpm_idx; p->ringtone_len = m.len; + memcpy(p->ringtone_notes, m.notes, MAX_NOTES); + } +} + +static void toRtttl(const Melody& m, char* buf, int n) { + NodePrefs::buildRTTTLString(m.notes, m.len, m.bpm_idx, buf, n); +} + +// A short blip of one note, for editing ("" for a rest). +static void notePreview(uint8_t b, char* buf, int n) { + uint8_t p = notePitch(b); + if (!p) { if (n) buf[0] = '\0'; return; } + snprintf(buf, n, "P:d=16,o=5,b=240:%c%d", pitchChar(p), noteOctave(b)); +} + +// `note` at index `at` (0..len). False when full. +static bool insertNote(Melody& m, int at, uint8_t note) { + if (m.len >= MAX_NOTES || at < 0 || at > m.len) return false; + for (int i = m.len; i > at; i--) m.notes[i] = m.notes[i - 1]; + m.notes[at] = note; + m.len++; + return true; +} + +static bool removeNote(Melody& m, int at) { + if (at < 0 || at >= m.len) return false; + for (int i = at; i < m.len - 1; i++) m.notes[i] = m.notes[i + 1]; + m.len--; + return true; +} + +} // namespace soundctl diff --git a/examples/companion_radio/ui-new/SoundNotifier.h b/examples/companion_radio/ui-core/SoundNotifier.h similarity index 90% rename from examples/companion_radio/ui-new/SoundNotifier.h rename to examples/companion_radio/ui-core/SoundNotifier.h index 164ab10a..ce77dc0b 100644 --- a/examples/companion_radio/ui-new/SoundNotifier.h +++ b/examples/companion_radio/ui-core/SoundNotifier.h @@ -1,6 +1,9 @@ #pragma once -// Fork-specific buzzer/melody dispatch — isolated here so upstream changes to -// UITask::notify() don't create merge conflicts. +// Which sound an incoming message / advert plays, shared by ui-new's and +// ui-lvgl's notify(): the global Sound settings, the per-contact / per-channel +// alert (default / muted / always) and melody overrides, the user melodies +// (slot 1 / 2) with a built-in fallback. Kept out of UITask::notify() itself so +// upstream changes there don't conflict. #ifdef PIN_BUZZER #include #include "../NodePrefs.h" diff --git a/examples/companion_radio/ui-core/UiCoreHost.h b/examples/companion_radio/ui-core/UiCoreHost.h index 5d2e8d68..26276985 100644 --- a/examples/companion_radio/ui-core/UiCoreHost.h +++ b/examples/companion_radio/ui-core/UiCoreHost.h @@ -38,6 +38,9 @@ public: // A display pref changed through the settings schema (brightness, ...): // push it to the panel. virtual void applyDisplayPrefs() {} + // A sound pref changed through the schema (the volume): push it to the + // speaker / buzzer. + virtual void applySoundPrefs() {} // Controlled power-down / restart (flush state first). See // AbstractUITask::shutdown(). diff --git a/examples/companion_radio/ui-lvgl/ChannelScreen.h b/examples/companion_radio/ui-lvgl/ChannelScreen.h index 8ba6057b..f25aa334 100644 --- a/examples/companion_radio/ui-lvgl/ChannelScreen.h +++ b/examples/companion_radio/ui-lvgl/ChannelScreen.h @@ -12,7 +12,7 @@ namespace chanview { enum : uint8_t { A_FAV, A_EDIT, A_DELETE, A_READ, A_PIN }; enum : uint8_t { T_PUBLIC, T_HASHTAG, T_PRIVATE }; -enum : uint8_t { C_NOTIF, C_SCOPE }; +enum : uint8_t { C_NOTIF, C_SCOPE, C_MELODY }; static int s_idx = -1; // channel the popup / form is about (-1: adding) static uint8_t s_type = T_HASHTAG; @@ -41,8 +41,9 @@ static void onChanDropdown(lv_event_t* e) { s_ui->channelSet((uint8_t)(uintptr_t)lv_event_get_user_data(e), (int)lv_dropdown_get_selected((lv_obj_t*)lv_event_get_target(e))); } -static void onChanNotif(lv_event_t* e) { - s_ui->channelSet(chanview::C_NOTIF, (int)lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e))); +static void onChanSegment(lv_event_t* e) { // user data: C_NOTIF / C_MELODY + s_ui->channelSet((uint8_t)(uintptr_t)lv_event_get_user_data(e), + (int)lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e))); } static void onChanRowHold(lv_event_t* e) { lv_indev_wait_release(lv_indev_active()); // the hold isn't also a tap that opens the channel @@ -81,25 +82,11 @@ void UITask::channelMenu(int idx) { // Notifications: three segments in the row (a dropdown's list ran off the // bottom of the screen from a popup this low). static const char* NOTIF[] = { "Default", "Muted", "Always", "" }; + static const char* MELODY[] = { "Default", "Melody 1", "Melody 2", "" }; lv_obj_t* row = radioview::settingRow(panel, "Alerts", NULL); - lv_obj_t* seg = lv_buttonmatrix_create(row); - lv_buttonmatrix_set_map(seg, NOTIF); - lv_buttonmatrix_set_button_ctrl_all(seg, LV_BUTTONMATRIX_CTRL_CHECKABLE); - lv_buttonmatrix_set_one_checked(seg, true); - lv_buttonmatrix_set_button_ctrl(seg, chanctl::notif(_prefs, idx), LV_BUTTONMATRIX_CTRL_CHECKED); - lv_obj_set_size(seg, 200, 36); - lv_obj_align(seg, LV_ALIGN_RIGHT_MID, -4, 0); - lv_obj_set_style_pad_all(seg, 0, 0); - lv_obj_set_style_pad_column(seg, 3, 0); - lv_obj_set_style_bg_opa(seg, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(seg, 0, 0); - lv_obj_set_style_bg_color(seg, lv_color_hex(theme::SURFACE_2), LV_PART_ITEMS); - lv_obj_set_style_bg_color(seg, lv_color_hex(theme::ACCENT_DIM), LV_PART_ITEMS | LV_STATE_CHECKED); - lv_obj_set_style_text_color(seg, lv_color_hex(theme::TEXT), LV_PART_ITEMS); - lv_obj_set_style_text_font(seg, THEME_FONT_SMALL, LV_PART_ITEMS); - lv_obj_set_style_shadow_width(seg, 0, LV_PART_ITEMS); - lv_obj_set_style_radius(seg, 8, LV_PART_ITEMS); - lv_obj_add_event_cb(seg, onChanNotif, LV_EVENT_VALUE_CHANGED, NULL); + radioview::rowSegmented(row, NOTIF, chanctl::notif(_prefs, idx), 200, onChanSegment, C_NOTIF); + row = radioview::settingRow(panel, "Sound", NULL); + radioview::rowSegmented(row, MELODY, chanctl::melody(_prefs, idx), 200, onChanSegment, C_MELODY); const ScopeList& sl = the_mesh.scopeList(); if (sl.count > 0) { // only once regions are set up (Settings in the app) row = radioview::settingRow(panel, "Scope", "Region it's sent in"); @@ -148,6 +135,7 @@ void UITask::channelSet(uint8_t which, int v) { if (!chanctl::exists(s_idx) || !_prefs) return; if (which == C_NOTIF) chanctl::setNotif(_prefs, s_idx, (uint8_t)v); else if (which == C_SCOPE) the_mesh.setChannelScope(s_idx, (uint8_t)v); + else if (which == C_MELODY) { chanctl::setMelody(_prefs, s_idx, (uint8_t)v); hearMelody(v); } the_mesh.savePrefs(); } diff --git a/examples/companion_radio/ui-lvgl/ClockScreen.h b/examples/companion_radio/ui-lvgl/ClockScreen.h index 82d4102c..94020a06 100644 --- a/examples/companion_radio/ui-lvgl/ClockScreen.h +++ b/examples/companion_radio/ui-lvgl/ClockScreen.h @@ -299,6 +299,7 @@ void UITask::showRing(const char* text) { } void UITask::dismissRing() { + stopMelody(); _core->clock.dismissRing(); hideRing(); } diff --git a/examples/companion_radio/ui-lvgl/ConversationScreen.h b/examples/companion_radio/ui-lvgl/ConversationScreen.h index 33b557d2..8be0c582 100644 --- a/examples/companion_radio/ui-lvgl/ConversationScreen.h +++ b/examples/companion_radio/ui-lvgl/ConversationScreen.h @@ -73,6 +73,9 @@ static lv_obj_t* actionButton(lv_obj_t* acts, const char* text, lv_event_cb_t cb } // namespace convview static void onConvAction(lv_event_t* e) { s_ui->conversationAction((uint8_t)(uintptr_t)lv_event_get_user_data(e)); } +static void onConvMelody(lv_event_t* e) { + s_ui->conversationMelody((int)lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e))); +} static void onConvNotif(lv_event_t* e) { s_ui->conversationNotif((int)lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e))); } @@ -184,6 +187,9 @@ void UITask::conversationMenu(const uint8_t* pub_key) { static const char* NOTIF[] = { "Default", "Muted", "Always", "" }; lv_obj_t* row = radioview::settingRow(panel, "Alerts", NULL); segmented(row, NOTIF, contactctl::notif(_prefs, ci.id.pub_key), 200, onConvNotif); + static const char* MELODY[] = { "Default", "Melody 1", "Melody 2", "" }; + row = radioview::settingRow(panel, "Sound", NULL); + segmented(row, MELODY, contactctl::melody(_prefs, ci.id.pub_key), 200, onConvMelody); } else { bool in = _core->rooms.isLoggedIn(ci.id.pub_key); lv_obj_t* st = label(panel, in ? LV_SYMBOL_OK " Logged in: you can post" diff --git a/examples/companion_radio/ui-lvgl/NavMap.h b/examples/companion_radio/ui-lvgl/NavMap.h index 8430e025..4726530c 100644 --- a/examples/companion_radio/ui-lvgl/NavMap.h +++ b/examples/companion_radio/ui-lvgl/NavMap.h @@ -446,8 +446,9 @@ lv_obj_t* UITask::navPopupPanel(const char* title, bool full) { if (full) { lv_obj_set_size(panel, w, lv_display_get_vertical_resolution(NULL) - theme::STATUS_H - 12); lv_obj_set_pos(panel, 8, theme::STATUS_H + 6); - } else { + } else { // as tall as its content, scrolling past the screen's height lv_obj_set_size(panel, w, LV_SIZE_CONTENT); + lv_obj_set_style_max_height(panel, lv_display_get_vertical_resolution(NULL) - theme::STATUS_H - 12, 0); lv_obj_align(panel, LV_ALIGN_CENTER, 0, theme::STATUS_H / 2); } lv_obj_set_style_bg_color(panel, lv_color_hex(theme::BG), 0); @@ -457,7 +458,7 @@ lv_obj_t* UITask::navPopupPanel(const char* title, bool full) { lv_obj_set_style_pad_all(panel, theme::PAD, 0); lv_obj_set_style_pad_row(panel, 6, 0); lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN); - lv_obj_remove_flag(panel, LV_OBJ_FLAG_SCROLLABLE); + if (full) lv_obj_remove_flag(panel, LV_OBJ_FLAG_SCROLLABLE); // its own list scrolls lv_obj_t* hdr = lv_obj_create(panel); styleSurface(hdr, theme::BG); diff --git a/examples/companion_radio/ui-lvgl/RadioScreen.h b/examples/companion_radio/ui-lvgl/RadioScreen.h index f2ff528f..90374045 100644 --- a/examples/companion_radio/ui-lvgl/RadioScreen.h +++ b/examples/companion_radio/ui-lvgl/RadioScreen.h @@ -49,6 +49,30 @@ static lv_obj_t* rowDropdown(lv_obj_t* row, const char* opts, int sel, int width return dd; } +// A few choices side by side at the right of a row (a dropdown's list can run +// off the bottom of a popup). `map` ends with "". +static lv_obj_t* rowSegmented(lv_obj_t* row, const char** map, int sel, int width, lv_event_cb_t cb, uintptr_t which) { + lv_obj_t* seg = lv_buttonmatrix_create(row); + lv_buttonmatrix_set_map(seg, map); + lv_buttonmatrix_set_button_ctrl_all(seg, LV_BUTTONMATRIX_CTRL_CHECKABLE); + lv_buttonmatrix_set_one_checked(seg, true); + lv_buttonmatrix_set_button_ctrl(seg, sel, LV_BUTTONMATRIX_CTRL_CHECKED); + lv_obj_set_size(seg, width, 36); + lv_obj_align(seg, LV_ALIGN_RIGHT_MID, -4, 0); + lv_obj_set_style_pad_all(seg, 0, 0); + lv_obj_set_style_pad_column(seg, 3, 0); + lv_obj_set_style_bg_opa(seg, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(seg, 0, 0); + lv_obj_set_style_bg_color(seg, lv_color_hex(theme::SURFACE_2), LV_PART_ITEMS); + lv_obj_set_style_bg_color(seg, lv_color_hex(theme::ACCENT_DIM), LV_PART_ITEMS | LV_STATE_CHECKED); + lv_obj_set_style_text_color(seg, lv_color_hex(theme::TEXT), LV_PART_ITEMS); + lv_obj_set_style_text_font(seg, THEME_FONT_SMALL, LV_PART_ITEMS); + lv_obj_set_style_shadow_width(seg, 0, LV_PART_ITEMS); + lv_obj_set_style_radius(seg, 8, LV_PART_ITEMS); + lv_obj_add_event_cb(seg, cb, LV_EVENT_VALUE_CHANGED, (void*)which); + return seg; +} + } // namespace radioview static void onRadioDropdown(lv_event_t* e) { diff --git a/examples/companion_radio/ui-lvgl/SoundScreen.h b/examples/companion_radio/ui-lvgl/SoundScreen.h new file mode 100644 index 00000000..dfa6c999 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/SoundScreen.h @@ -0,0 +1,468 @@ +// Sound: the speaker (genericBuzzer through the L2's codec), notify(), +// Settings > Sound's own rows (the mode, the two melodies), the melody editor +// (SCR_MELODY) and a conversation's melody. The settings and the melody model +// are ui-core/SoundControl.h; which sound a message plays, SoundNotifier.h. +// Included by UITask.cpp (single translation unit). + +namespace sndview { + +enum : uint8_t { MS_BPM, MS_PITCH, MS_OCT, MS_DUR }; +enum : uint8_t { MA_PLAY, MA_ADD, MA_DELETE }; + +static soundctl::Melody s_mel; // the melody being edited +static int s_slot = 0; // 0 = melody 1, 1 = melody 2 +static int s_sel = 0; // selected note +static bool s_dirty = false; +static lv_obj_t* s_strip = nullptr; // the notes, side by side +static lv_obj_t* s_cells[soundctl::MAX_NOTES]; +static lv_obj_t* s_pitch = nullptr; +static lv_obj_t* s_oct = nullptr; +static lv_obj_t* s_dur = nullptr; +static lv_obj_t* s_bpm = nullptr; +static lv_obj_t* s_title = nullptr; // "Melodies 3/32" +static lv_obj_t* s_play_lbl = nullptr; +static bool s_playing_shown = false; +static bool s_play_all = false; // Play (not a note's preview): follow it in the strip +static int s_hl = -1; // the note lit as playing +// The melody playing from here: the sim's and nRF52's players keep a pointer +// into it (only the I2S one copies), so it must outlive the call. +static char s_play_buf[220]; + +static const char* PITCHES[] = { "Rest", "C", "D", "E", "F", "G", "A", "B", "" }; +static const char* OCTAVES[] = { "4", "5", "6", "" }; +static const char* DURS[] = { "1/4", "1/8", "1/16", "1/32", "" }; +static const char* SLOTS[] = { "Melody 1", "Melody 2", "" }; + +// Selected: dim accent with a border; sounding now (Play): full accent. +static void styleCell(int i, bool sel) { + if (i < 0 || i >= soundctl::MAX_NOTES || !s_cells[i]) return; + uint32_t bg = i == s_hl ? theme::ACCENT : sel ? theme::ACCENT_DIM : theme::SURFACE; + lv_obj_set_style_bg_color(s_cells[i], lv_color_hex(bg), 0); + lv_obj_set_style_border_width(s_cells[i], sel ? 2 : 0, 0); +} + +// Moves the "sounding" light to note `idx` (-1: none). +static void lightNote(int idx) { + if (idx == s_hl) return; + int old = s_hl; + s_hl = idx; + styleCell(old, old == s_sel); + styleCell(s_hl, s_hl == s_sel); + if (s_hl >= 0 && s_hl < s_mel.len && s_cells[s_hl]) lv_obj_scroll_to_view(s_cells[s_hl], LV_ANIM_ON); +} + +static void cellText(int i) { + if (!s_cells[i]) return; + char name[3]; + soundctl::noteName(s_mel.notes[i], name); + lv_label_set_text(lv_obj_get_child(s_cells[i], 0), name); + lv_label_set_text(lv_obj_get_child(s_cells[i], 1), soundctl::durLabel(soundctl::noteDur(s_mel.notes[i]))); +} + +static void refreshCount() { + if (s_title) lv_label_set_text_fmt(s_title, "Melodies %d/%d", s_mel.len, soundctl::MAX_NOTES); +} + +static void setMatrix(lv_obj_t* m, int sel, bool enabled) { + if (!m) return; + lv_buttonmatrix_clear_button_ctrl_all(m, LV_BUTTONMATRIX_CTRL_CHECKED); + if (sel >= 0) lv_buttonmatrix_set_button_ctrl(m, sel, LV_BUTTONMATRIX_CTRL_CHECKED); + if (enabled) lv_buttonmatrix_clear_button_ctrl_all(m, LV_BUTTONMATRIX_CTRL_DISABLED); + else lv_buttonmatrix_set_button_ctrl_all(m, LV_BUTTONMATRIX_CTRL_DISABLED); +} + +// The pitch / octave / length controls show the selected note. +static void syncControls() { + bool any = s_sel < s_mel.len; + uint8_t b = any ? s_mel.notes[s_sel] : 0; + setMatrix(s_pitch, any ? soundctl::notePitch(b) : -1, any); + setMatrix(s_oct, any && soundctl::notePitch(b) ? soundctl::noteOctave(b) - soundctl::OCT_MIN : -1, + any && soundctl::notePitch(b)); // a rest has no octave + setMatrix(s_dur, any ? soundctl::noteDur(b) : -1, any); + refreshCount(); +} + +static void onMelNote(lv_event_t* e); + +static void rebuildStrip() { + if (!s_strip) return; + lv_obj_clean(s_strip); + memset(s_cells, 0, sizeof(s_cells)); + s_hl = -1; + if (!s_mel.len) { + lv_obj_t* t = label(s_strip, "Empty: tap + to add a note", THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_obj_set_style_pad_top(t, 12, 0); + } + for (int i = 0; i < s_mel.len; i++) { + lv_obj_t* c = lv_button_create(s_strip); + lv_obj_set_size(c, 38, 44); + lv_obj_set_style_pad_all(c, 0, 0); + lv_obj_set_style_radius(c, 6, 0); + lv_obj_set_style_shadow_width(c, 0, 0); + lv_obj_set_style_border_color(c, lv_color_hex(theme::ACCENT), 0); + lv_obj_add_event_cb(c, onMelNote, LV_EVENT_CLICKED, (void*)(uintptr_t)i); + lv_obj_align(label(c, "", THEME_FONT_BODY, theme::TEXT), LV_ALIGN_TOP_MID, 0, 5); + lv_obj_align(label(c, "", THEME_FONT_SMALL, theme::TEXT_MUTED), LV_ALIGN_BOTTOM_MID, 0, -4); + s_cells[i] = c; + cellText(i); + styleCell(i, i == s_sel); + } + lv_obj_update_layout(s_strip); + if (s_sel < s_mel.len) lv_obj_scroll_to_view(s_cells[s_sel], LV_ANIM_OFF); +} + +// A short blip of the note, as it's picked or changed. +static void preview(UITask* ui, uint8_t b) { + s_play_all = false; + lightNote(-1); + ui->stopMelody(); // before the buffer changes under the player + soundctl::notePreview(b, s_play_buf, sizeof(s_play_buf)); + if (s_play_buf[0]) ui->playMelody(s_play_buf); +} + +static void onSoundMode(lv_event_t* e) { + s_ui->setSoundMode((int)lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e))); +} +static void onMelodyRow(lv_event_t* e) { s_ui->showMelodies((int)(uintptr_t)lv_event_get_user_data(e)); } +static void onMelSlot(lv_event_t* e) { + s_ui->melodySlot((int)lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e))); +} +static void onMelNote(lv_event_t* e) { s_ui->melodyPick((int)(uintptr_t)lv_event_get_user_data(e)); } +static void onMelMatrix(lv_event_t* e) { + s_ui->melodySet((uint8_t)(uintptr_t)lv_event_get_user_data(e), + (int)lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e))); +} +static void onMelBpm(lv_event_t* e) { + s_ui->melodySet(MS_BPM, (int)lv_dropdown_get_selected((lv_obj_t*)lv_event_get_target(e))); +} +static void onMelAction(lv_event_t* e) { s_ui->melodyAction((uint8_t)(uintptr_t)lv_event_get_user_data(e)); } +static void onMelPlay(lv_event_t* e) { (void)e; s_ui->melodyAction(MA_PLAY); } + +static lv_obj_t* smallButton(lv_obj_t* parent, const char* text, uint8_t act) { + lv_obj_t* b = lv_button_create(parent); + lv_obj_set_size(b, 40, 32); + lv_obj_set_style_pad_all(b, 0, 0); + lv_obj_set_style_radius(b, 8, 0); + lv_obj_set_style_shadow_width(b, 0, 0); + lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE_2), 0); + lv_obj_add_event_cb(b, onMelAction, LV_EVENT_CLICKED, (void*)(uintptr_t)act); + lv_obj_center(label(b, text, THEME_FONT_BODY, theme::TEXT)); + return b; +} + +static lv_obj_t* flexRow(lv_obj_t* parent) { + lv_obj_t* r = lv_obj_create(parent); + styleSurface(r, theme::BG); + lv_obj_remove_flag(r, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(r, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(r, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(r, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(r, theme::GAP, 0); + return r; +} + +} // namespace sndview + +// ── The speaker ─────────────────────────────────────────────────────────────── + +void UITask::playMelody(const char* melody) { +#ifdef PIN_BUZZER + _buzzer.playForced(melody); +#else + (void)melody; +#endif +} + +void UITask::stopMelody() { +#ifdef PIN_BUZZER + _buzzer.stop(); +#endif +} + +int UITask::melodyNote() { +#ifdef PIN_BUZZER + return _buzzer.noteIndex(); +#else + return -1; +#endif +} + +bool UITask::melodyPlaying() { +#ifdef PIN_BUZZER + return _buzzer.isPlaying(); +#else + return false; +#endif +} + +#ifdef PIN_BUZZER +uint16_t UITask::buzzerFreqHz() const { +#ifdef SIM_PLATFORM + return _buzzer.currentFreqHz(); +#else + return 0; +#endif +} +#endif + +// An incoming message, advert or delivery tick: the sound its settings pick. +void UITask::notify(UIEventType t) { +#ifdef PIN_BUZZER + SoundNotifier sn(_buzzer, _prefs, _notif_mel_buf, sizeof(_notif_mel_buf)); + switch (t) { + case UIEventType::contactMessage: + sn.playDM(_notif_dm_valid, _notif_dm_prefix); + _notif_dm_valid = false; + break; + case UIEventType::channelMessage: + sn.playCH(_notif_ch_idx); + _notif_ch_idx = -1; + break; + case UIEventType::roomMessage: // many authors, no per-room melody: the DM default + sn.playDM(false, nullptr); + break; + case UIEventType::advertReceivedFlood: + case UIEventType::advertReceivedZeroHop: + sn.playAD(t == UIEventType::advertReceivedFlood); + break; + case UIEventType::ack: + _buzzer.play("ack:d=32,o=8,b=120:c"); + break; + default: + break; + } +#else + (void)t; +#endif +} + +// ── Settings > Sound ────────────────────────────────────────────────────────── + +void UITask::applySoundPrefs() { +#ifdef PIN_BUZZER + if (_prefs) _buzzer.setVolume(_prefs->buzzer_volume); +#endif +} + +void UITask::setSoundMode(int mode) { + if (!_prefs || mode < 0 || mode >= soundctl::MODE_COUNT) return; +#ifdef PIN_BUZZER + soundctl::setMode(_prefs, _buzzer, (uint8_t)mode, isClientConnected()); + if (mode == soundctl::MODE_ON) notify(UIEventType::ack); // hear that it's on +#endif + the_mesh.savePrefs(); + refreshStatusBar(); + if (mode == soundctl::MODE_AUTO) showToast(isClientConnected() ? "Silent while the app is connected" : "Sound on until the app connects"); +} + +void UITask::setSoundVolume(int level) { + if (!_prefs) return; +#ifdef PIN_BUZZER + soundctl::setVolume(_prefs, _buzzer, (uint8_t)level); +#endif + the_mesh.savePrefs(); +} + +// The Sound page's own rows: the mode at the top, the melodies at the bottom. +void UITask::buildSoundRows(lv_obj_t* body, bool top) { + using namespace sndview; + if (top) { + static const char* MODES[] = { "On", "Off", "Auto", "" }; + lv_obj_t* row = radioview::settingRow(body, "Sound", "Auto: off with the app"); + radioview::rowSegmented(row, MODES, soundctl::mode(_prefs), 150, onSoundMode, 0); + return; + } + sectionTitle(body, "MELODIES"); + for (int slot = 0; slot < 2; slot++) { + soundctl::Melody m; + soundctl::load(_prefs, slot, m); + char sub[40]; + if (m.len) snprintf(sub, sizeof(sub), "%d note%s, %u BPM", m.len, m.len == 1 ? "" : "s", soundctl::bpm(m.bpm_idx)); + else snprintf(sub, sizeof(sub), "Empty - tap to compose"); + listRow(body, slot ? LV_SYMBOL_AUDIO " Melody 2" : LV_SYMBOL_AUDIO " Melody 1", sub, onMelodyRow, (void*)(uintptr_t)slot); + } + lv_obj_t* t = label(body, "Pick them above for each kind of message, or for one chat in its options (hold it in Chats).", + THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP); + lv_obj_set_width(t, LV_PCT(100)); +} + +// ── Melody editor ───────────────────────────────────────────────────────────── + +void UITask::showMelodies(int slot) { + using namespace sndview; + stopMelody(); + s_slot = slot == 1 ? 1 : 0; + soundctl::load(_prefs, s_slot, s_mel); + s_sel = 0; + s_dirty = false; + s_play_all = false; + _screen = SCR_MELODY; + buildMelodies(); +} + +void UITask::buildMelodies() { + using namespace sndview; + lv_obj_t* body = newScreen("Melodies", true); + s_title = lv_obj_get_child(_header, 1); // after the back button + lv_obj_set_style_pad_row(body, 6, 0); + headerButton(_header, LV_SYMBOL_PLAY " Play", onMelPlay, 0, &s_play_lbl); + s_playing_shown = false; + + lv_obj_t* top = flexRow(body); + lv_obj_t* slots = segmented(top, SLOTS, s_slot, 150, 32); + lv_obj_add_event_cb(slots, onMelSlot, LV_EVENT_VALUE_CHANGED, NULL); + s_bpm = lv_dropdown_create(top); + lv_dropdown_set_options_static(s_bpm, "60 BPM\n90 BPM\n120 BPM\n150 BPM\n180 BPM"); + lv_dropdown_set_selected(s_bpm, s_mel.bpm_idx); + lv_obj_set_size(s_bpm, 102, 32); + lv_obj_set_style_pad_ver(s_bpm, 6, 0); + lv_obj_add_event_cb(s_bpm, onMelBpm, LV_EVENT_VALUE_CHANGED, NULL); + smallButton(top, LV_SYMBOL_PLUS, MA_ADD); // (fits: 150 + 102 + 40 + gaps) + + s_strip = lv_obj_create(body); + styleSurface(s_strip, theme::BG); + lv_obj_set_size(s_strip, LV_PCT(100), 50); + lv_obj_set_flex_flow(s_strip, LV_FLEX_FLOW_ROW); + lv_obj_set_style_pad_column(s_strip, 4, 0); + lv_obj_set_style_pad_ver(s_strip, 3, 0); + lv_obj_set_scroll_dir(s_strip, LV_DIR_HOR); + lv_obj_set_scrollbar_mode(s_strip, LV_SCROLLBAR_MODE_OFF); + + s_pitch = segmented(body, PITCHES, -1, LV_PCT(100), 34); + lv_obj_add_event_cb(s_pitch, onMelMatrix, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)MS_PITCH); + + lv_obj_t* bottom = flexRow(body); + s_oct = segmented(bottom, OCTAVES, -1, 84, 32); + lv_obj_add_event_cb(s_oct, onMelMatrix, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)MS_OCT); + s_dur = segmented(bottom, DURS, -1, 158, 32); + lv_obj_add_event_cb(s_dur, onMelMatrix, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)MS_DUR); + smallButton(bottom, LV_SYMBOL_TRASH, MA_DELETE); + for (lv_obj_t* m : { s_pitch, s_oct, s_dur }) { // greyed out with no note to edit + lv_obj_set_style_text_opa(m, LV_OPA_30, LV_PART_ITEMS | LV_STATE_DISABLED); + lv_obj_set_style_bg_color(m, lv_color_hex(theme::SURFACE), LV_PART_ITEMS | LV_STATE_DISABLED); + } + + rebuildStrip(); + syncControls(); +} + +// Every 30 ms on SCR_MELODY: the playing note lights up, the header's +// Play / Stop follows the player. +void UITask::refreshMelody() { + using namespace sndview; + bool playing = melodyPlaying(); + if (!playing) s_play_all = false; + lightNote(s_play_all ? melodyNote() : -1); + if (!s_play_lbl || playing == s_playing_shown) return; + s_playing_shown = playing; + lv_label_set_text(s_play_lbl, playing ? LV_SYMBOL_STOP " Stop" : LV_SYMBOL_PLAY " Play"); +} + +void UITask::melodySave() { + using namespace sndview; + if (!s_dirty || !_prefs) return; + soundctl::store(_prefs, s_slot, s_mel); + the_mesh.savePrefs(); + s_dirty = false; + showToast(s_slot ? "Melody 2 saved" : "Melody 1 saved", 1200); +} + +void UITask::melodySlot(int slot) { + using namespace sndview; + if (slot == s_slot) return; + melodySave(); + stopMelody(); + s_play_all = false; + s_slot = slot == 1 ? 1 : 0; + soundctl::load(_prefs, s_slot, s_mel); + s_sel = 0; + if (s_bpm) lv_dropdown_set_selected(s_bpm, s_mel.bpm_idx); + rebuildStrip(); + syncControls(); + refreshMelody(); +} + +void UITask::melodyPick(int idx) { + using namespace sndview; + if (idx < 0 || idx >= s_mel.len) return; + styleCell(s_sel, false); + s_sel = idx; + styleCell(s_sel, true); + syncControls(); + preview(this, s_mel.notes[s_sel]); +} + +void UITask::melodySet(uint8_t which, int v) { + using namespace sndview; + if (which == MS_BPM) { + if (v < 0 || v >= soundctl::BPM_COUNT) return; + s_mel.bpm_idx = (uint8_t)v; + s_dirty = true; + return; + } + if (s_sel >= s_mel.len || v < 0) return; + uint8_t b = s_mel.notes[s_sel]; + uint8_t p = soundctl::notePitch(b), o = soundctl::noteOctave(b), d = soundctl::noteDur(b); + if (which == MS_PITCH && v < soundctl::PITCH_COUNT) p = (uint8_t)v; + else if (which == MS_OCT && v <= soundctl::OCT_MAX - soundctl::OCT_MIN) o = (uint8_t)(soundctl::OCT_MIN + v); + else if (which == MS_DUR && v < soundctl::DUR_COUNT) d = (uint8_t)v; + else return; + s_mel.notes[s_sel] = soundctl::packNote(p, o, d); + s_dirty = true; + cellText(s_sel); + syncControls(); + if (which != MS_DUR) preview(this, s_mel.notes[s_sel]); +} + +void UITask::melodyAction(uint8_t act) { + using namespace sndview; + switch (act) { + case MA_PLAY: + if (melodyPlaying()) { stopMelody(); s_play_all = false; break; } + if (!s_mel.len) { showToast("Add a note first", 1200); break; } + soundctl::toRtttl(s_mel, s_play_buf, sizeof(s_play_buf)); + playMelody(s_play_buf); + s_play_all = true; + break; + case MA_ADD: { // a copy of the selected note after it (or C5 1/8 to start) + int at = s_mel.len ? s_sel + 1 : 0; + uint8_t note = s_mel.len ? s_mel.notes[s_sel] : soundctl::NEW_NOTE; + if (!soundctl::insertNote(s_mel, at, note)) { showToast("32 notes at most", 1200); break; } + s_sel = at; + s_dirty = true; + rebuildStrip(); + syncControls(); + preview(this, note); + break; + } + case MA_DELETE: + if (!soundctl::removeNote(s_mel, s_sel)) break; + if (s_sel >= s_mel.len && s_sel > 0) s_sel--; + s_dirty = true; + rebuildStrip(); + syncControls(); + break; + } + refreshMelody(); +} + +// ── A conversation's melody (its options popup) ─────────────────────────────── + +void UITask::conversationMelody(int v) { + if (!_prefs || v < 0 || v >= soundctl::OVERRIDE_COUNT) return; + contactctl::setMelody(_prefs, convview::s_key, (uint8_t)v); + the_mesh.savePrefs(); + hearMelody(v); +} + +// A melody picked for a chat: play it (slot 1 / 2; 0 = the default, silent). +void UITask::hearMelody(int slot) { + if (slot < 1 || slot > 2) return; + soundctl::Melody m; + soundctl::load(_prefs, slot - 1, m); + if (!m.len) { showToast("That melody is empty - compose it in Settings > Sound", 2500); return; } + stopMelody(); + soundctl::toRtttl(m, sndview::s_play_buf, sizeof(sndview::s_play_buf)); + playMelody(sndview::s_play_buf); +} diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index b022cd24..5f01584e 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -18,6 +18,8 @@ #include "../ui-core/Battery.h" #include "../ui-core/ChannelControl.h" #include "../ui-core/BotConfig.h" +#include "../ui-core/SoundControl.h" +#include "../ui-core/SoundNotifier.h" #include "Theme.h" #include "LvglPort.h" #include "../ui-core/KeyboardData.h" @@ -34,6 +36,10 @@ static UITask* s_ui = nullptr; // for LVGL's C callbacks // The board's one button, pressed from the simulator page (web/lvgl.html). static bool s_sim_btn_click = false; extern "C" EMSCRIPTEN_KEEPALIVE void sim_lcd_button() { s_sim_btn_click = true; } +// The speaker, polled every frame by the page's Web Audio oscillator. +extern "C" EMSCRIPTEN_KEEPALIVE int sim_buzzer_is_playing() { return s_ui && s_ui->isBuzzerPlaying() ? 1 : 0; } +extern "C" EMSCRIPTEN_KEEPALIVE int sim_buzzer_freq_hz() { return s_ui ? (int)s_ui->buzzerFreqHz() : 0; } +extern "C" EMSCRIPTEN_KEEPALIVE int sim_buzzer_get_volume() { return s_ui ? (int)s_ui->buzzerVolume() : 0; } #endif // ── Small helpers ───────────────────────────────────────────────────────────── @@ -137,6 +143,11 @@ void UITask::begin(DisplayDriver* display_drv, SensorManager* sensors, NodePrefs #ifdef PIN_USER_BTN user_btn.begin(); #endif +#ifdef PIN_BUZZER + soundctl::applyMode(_prefs, _buzzer, isClientConnected()); + _buzzer.setVolume(_prefs ? _prefs->buzzer_volume : 4); + _buzzer.begin(); // plays the startup sound unless muted +#endif lv_init(); lv_tick_set_cb([]() -> uint32_t { return (uint32_t)millis(); }); @@ -182,6 +193,12 @@ void UITask::loop() { _core->loop(); drainCoreEvents(); +#ifdef PIN_BUZZER + _buzzer.loop(); + if (soundctl::autoTick(_prefs, _buzzer, isClientConnected()) && !_asleep) refreshStatusBar(); + // The alarm melody repeats until dismissed or the ring window ends. + if (_core->clock.isRinging() && !_buzzer.isPlaying()) playMelody(soundctl::MEL_ALARM); +#endif checkLowBattery(); mapDownloadTick(); // a map download keeps running on any screen, and asleep if ((int32_t)(millis() - _next_trackback_ms) >= 0) { // walking the trail back, on any screen @@ -219,6 +236,10 @@ void UITask::loop() { _next_clock_ms = millis() + 100; // stopwatch tenths refreshClock(); } + if (_screen == SCR_MELODY && (int32_t)(millis() - _next_clock_ms) >= 0) { + _next_clock_ms = millis() + 30; // the playing note's highlight keeps up + refreshMelody(); + } if (_screen == SCR_THREAD && (int32_t)(millis() - _next_thread_check_ms) >= 0) { _next_thread_check_ms = millis() + 500; uint32_t sig = threadSignature(); @@ -233,6 +254,12 @@ void UITask::shutdown(bool restart) { the_mesh.saveRTCTime(); the_mesh.flushDirtyContacts(); _core->trail.onShutdown(); +#ifdef PIN_BUZZER + _buzzer.shutdown(); // the goodbye sound, unless muted +#ifndef SIM_PLATFORM // the sim's single thread would freeze the page + for (uint32_t t0 = millis(); _buzzer.isPlaying() && millis() - t0 < 2500; ) { _buzzer.loop(); delay(10); } +#endif +#endif if (restart) { _board->reboot(); } else { @@ -296,8 +323,10 @@ void UITask::drainCoreEvents() { break; case UiEventType::ClockAlert: showRing(ev.text); + playMelody(soundctl::MEL_ALARM); break; case UiEventType::ClockRingEnded: + stopMelody(); hideRing(); break; case UiEventType::LiveShareEnded: @@ -305,6 +334,15 @@ void UITask::drainCoreEvents() { break; case UiEventType::LocatorCrossed: showToast(ev.text, 3000); +#ifdef PIN_BUZZER + if (!_buzzer.isQuiet()) playMelody(ev.flag ? soundctl::MEL_ARRIVE : soundctl::MEL_LEAVE); +#endif + break; + case UiEventType::LocatorBeep: // Settings > Proximity beeper + playMelody(soundctl::MEL_TICK); + break; + case UiEventType::AdvertHeard: + notify(ev.flag ? UIEventType::advertReceivedFlood : UIEventType::advertReceivedZeroHop); break; default: break; @@ -313,6 +351,12 @@ void UITask::drainCoreEvents() { } void UITask::onMessageArrived(const UiEvent& ev) { + if (ev.kind == UIEventType::contactMessage && ev.flag) { // the sender's alert / melody overrides + memcpy(_notif_dm_prefix, ev.key, 4); + _notif_dm_valid = true; + } + if (ev.kind == UIEventType::channelMessage) _notif_ch_idx = ev.idx; + notify(ev.kind); char buf[48]; snprintf(buf, sizeof(buf), "Msg: %.20s", ev.text); // Wake for the message unless an app is already showing it, or the user @@ -391,7 +435,12 @@ void UITask::refreshStatusBar() { case battery::VOLTAGE: snprintf(level, sizeof(level), " %u.%02u V", mv / 1000, (mv % 1000) / 10); break; default: break; } - lv_label_set_text_fmt(_status_icons, "%s%s%s%s", _prefs && _prefs->client_repeat ? LV_SYMBOL_LOOP " " : "", + bool muted = false; +#ifdef PIN_BUZZER + muted = _buzzer.isQuiet(); +#endif + lv_label_set_text_fmt(_status_icons, "%s%s%s%s%s", muted ? LV_SYMBOL_MUTE " " : "", + _prefs && _prefs->client_repeat ? LV_SYMBOL_LOOP " " : "", hasConnection() ? LV_SYMBOL_BLUETOOTH " " : "", batt, level); // GPS: green with a fix, muted while on and searching, absent when off. int32_t lat, lon; @@ -513,6 +562,12 @@ void UITask::back() { case SCR_ADMIN: if (_nav_overlay) navClosePopup(); else adminLeave(); break; case SCR_SETTINGS: if (_nav_overlay) navClosePopup(); else showHome(); break; case SCR_SETTINGS_NAV: showSettings(); break; + case SCR_MELODY: // back to the Sound page's bottom, where the melodies are + melodySave(); + stopMelody(); + showSchemaSettings(settings::PG_SOUND); + if (_body) { lv_obj_update_layout(_body); lv_obj_scroll_by(_body, 0, -lv_obj_get_scroll_bottom(_body), LV_ANIM_OFF); } + break; case SCR_CLOCK: showHome(); break; case SCR_RADIO: if (radioPopupOpen()) radioCloseFreq(); @@ -1875,6 +1930,9 @@ static void onSchemaDropdown(lv_event_t* e) { } static void onOpenSchemaPage(lv_event_t* e) { s_ui->showSchemaSettings((int)(uintptr_t)lv_event_get_user_data(e)); } static void onPruneContacts(lv_event_t* e) { (void)e; s_ui->pruneContacts(); } +static void onVolumeSlider(lv_event_t* e) { + s_ui->setSoundVolume((int)lv_slider_get_value((lv_obj_t*)lv_event_get_target(e))); +} void UITask::showSchemaSettings(int page) { _screen = SCR_SETTINGS_NAV; @@ -1930,8 +1988,23 @@ void UITask::buildSchemaSettings() { for (int i = 0; i < settings::COUNT; i++) { const settings::Setting& st = settings::ALL[i]; if (settings::sectionPage(st.section) != _settings_page) continue; - if (st.section != sec) { sec = st.section; sectionTitle(body, settings::sectionTitle(sec)); } + if (st.section != sec) { + sec = st.section; + sectionTitle(body, settings::sectionTitle(sec)); + if (sec == settings::SEC_SOUND) buildSoundRows(body, true); // On / Off / Auto + } uint8_t v = settings::get(*_prefs, st); + if (st.offset == offsetof(NodePrefs, buzzer_volume)) { // a five-step slider, heard on release + lv_obj_t* row = radioview::settingRow(body, st.label, NULL); + lv_obj_t* sl = lv_slider_create(row); + lv_slider_set_range(sl, 0, 4); + lv_slider_set_value(sl, v, LV_ANIM_OFF); + lv_obj_set_size(sl, 170, 10); + lv_obj_align(sl, LV_ALIGN_RIGHT_MID, -18, 0); + lv_obj_set_ext_click_area(sl, 14); + lv_obj_add_event_cb(sl, onVolumeSlider, LV_EVENT_RELEASED, NULL); + continue; + } if (st.offset == offsetof(NodePrefs, display_brightness)) { // a slider here instead of five steps lv_obj_t* row = lv_obj_create(body); styleSurface(row, theme::SURFACE); @@ -1995,6 +2068,7 @@ void UITask::buildSchemaSettings() { _prune_lbl = label(b, LV_SYMBOL_TRASH " Remove inactive contacts now", THEME_FONT_BODY, theme::TEXT); lv_obj_center(_prune_lbl); } + if (_settings_page == settings::PG_SOUND) buildSoundRows(body, false); // the melodies } void UITask::setSchemaValue(int idx, int v) { @@ -2053,6 +2127,10 @@ void UITask::buildSettings() { onOpenSchemaPage, (void*)(uintptr_t)settings::PG_DEVICE); listRow(body, LV_SYMBOL_ENVELOPE " Messages & contacts", "Resending, contact expiry, sorting", onOpenSchemaPage, (void*)(uintptr_t)settings::PG_MESSAGES); + char sub[48], vol[12]; + settings::optVolume(_prefs->buzzer_volume, vol, sizeof(vol), *_prefs); + snprintf(sub, sizeof(sub), "%s, %s - alerts, melodies", soundctl::modeLabel(soundctl::mode(_prefs)), vol); + listRow(body, LV_SYMBOL_VOLUME_MAX " Sound", sub, onOpenSchemaPage, (void*)(uintptr_t)settings::PG_SOUND); } sectionTitle(body, "KEYBOARD"); uint8_t main_a = _prefs ? _prefs->keyboard_main_alphabet : 0; @@ -2095,3 +2173,4 @@ void UITask::setKeyboardAlphabets(int main_idx, int alt_sel) { #include "CompassScreen.h" #include "RadioExtras.h" #include "RepeaterScreen.h" +#include "SoundScreen.h" diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index c8263620..c9a323ae 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -9,6 +9,9 @@ #include #include #include +#ifdef PIN_BUZZER + #include +#endif #include "../AbstractUITask.h" #include "../NodePrefs.h" @@ -24,7 +27,7 @@ public: void begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs); void loop() override; - void notify(UIEventType t = UIEventType::none) override { (void)t; } // no speaker driver yet + void notify(UIEventType t = UIEventType::none) override; void shutdown(bool restart = false) override; MyMesh::Listener* meshListener() override; @@ -33,6 +36,7 @@ public: bool isViewingDM(const uint8_t* pub_key) override; void onViewedHistoryGrew(bool channel) override { (void)channel; _thread_dirty = true; } void botSetGPS(bool on) override; + void applySoundPrefs() override; // ── Navigation (called from LVGL event callbacks) ──────────────────────── void showHome(); @@ -104,6 +108,26 @@ public: void clockAction(uint8_t act); void setAlarm(int which, int v); void dismissRing(); + // Sound (SoundScreen.h): the speaker, Settings > Sound, the melody editor + void playMelody(const char* melody); // through mute (alarms, previews) + void stopMelody(); + bool melodyPlaying(); + int melodyNote(); // the note sounding now, -1 silent + void setSoundMode(int mode); + void setSoundVolume(int level); + void showMelodies(int slot); + void melodySlot(int slot); + void melodyPick(int idx); + void melodySet(uint8_t which, int v); + void melodyAction(uint8_t act); + void conversationMelody(int v); + void hearMelody(int slot); +#ifdef PIN_BUZZER + // For the sim page's Web Audio speaker (sim_buzzer_* in UITask.cpp). + bool isBuzzerPlaying() { return _buzzer.isPlaying(); } + uint16_t buzzerFreqHz() const; + uint8_t buzzerVolume() const { return _buzzer.getVolume(); } +#endif // Settings > Radio (RadioScreen.h) void showRadio(); void radioSet(int which, int v); @@ -185,7 +209,7 @@ public: void openAdminFor(const ContactInfo& ci, bool from_picker) { (void)ci; (void)from_picker; } private: - enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI, SCR_SETTINGS_NAV, SCR_CLOCK, SCR_RADIO, SCR_CHANNEL_EDIT, SCR_ADMIN, SCR_BOT, SCR_FAVS, SCR_DIAG, SCR_COMPASS, SCR_SCOPES, SCR_REPEATER }; + enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI, SCR_SETTINGS_NAV, SCR_CLOCK, SCR_RADIO, SCR_CHANNEL_EDIT, SCR_ADMIN, SCR_BOT, SCR_FAVS, SCR_DIAG, SCR_COMPASS, SCR_SCOPES, SCR_REPEATER, SCR_MELODY }; void buildStatusBar(); void refreshStatusBar(); @@ -243,6 +267,10 @@ private: void buildScopes(); void buildRepeater(); void rebuildRepeater(); + void buildSoundRows(lv_obj_t* body, bool top); + void buildMelodies(); + void refreshMelody(); + void melodySave(); void radioCloseFreq(); void buildChannelEdit(); void buildAdmin(); @@ -274,6 +302,15 @@ private: uint32_t autoOffMillis() const; void checkLowBattery(); +#ifdef PIN_BUZZER + genericBuzzer _buzzer; + char _notif_mel_buf[220]; // a user melody's RTTTL while it plays (the player copies it) +#endif + // Who the message being notified came from (SoundNotifier's overrides). + bool _notif_dm_valid = false; + uint8_t _notif_dm_prefix[4] = {0}; + int _notif_ch_idx = -1; + DisplayDriver* _display = nullptr; SensorManager* _sensors = nullptr; NodePrefs* _prefs = nullptr; diff --git a/examples/companion_radio/ui-new/MessagesScreen.h b/examples/companion_radio/ui-new/MessagesScreen.h index 0284e10a..99ae88d7 100644 --- a/examples/companion_radio/ui-new/MessagesScreen.h +++ b/examples/companion_radio/ui-new/MessagesScreen.h @@ -835,32 +835,6 @@ class MessagesScreen : public UIScreen { return p && (p->ch_fav_bitmask & (1ULL << ch_idx)) != 0; } - // Returns per-channel notification state: 0=follow global, 1=muted, 2=force-on - // Per-contact/channel notification + melody overrides share two storage - // shapes, so the eight accessors below are thin wrappers over two primitives: - // - // • Channels — a 3-state packed into a pair of channel-index bitmasks: a - // "presence" mask (is there an override at all) + a "variant" mask. The two - // uses disagree on which state the variant bit means, so the caller passes - // the state value that corresponds to variant-set (v_set). - // • DMs — a small {prefix[4], value} table: find by 4-byte prefix, update or - // clear (clear frees the slot), else insert into the first free slot, else - // overwrite slot 0. value 0 == "no override" == empty slot. - - static uint8_t maskPairGet(uint64_t presence, uint64_t variant, uint8_t idx, - uint8_t v_set, uint8_t v_clr) { - uint64_t m = 1ULL << idx; - if (!(presence & m)) return 0; - return (variant & m) ? v_set : v_clr; - } - static void maskPairSet(uint64_t& presence, uint64_t& variant, uint8_t idx, - uint8_t state, uint8_t v_set) { - uint64_t m = 1ULL << idx; - if (state == 0) { presence &= ~m; variant &= ~m; return; } - presence |= m; - if (state == v_set) variant |= m; else variant &= ~m; - } - // Channel notif (ui-core/ChannelControl.h): 0 = default, 1 = muted, 2 = force-on. uint8_t chNotifState(uint8_t ch_idx) const { return chanctl::notif(_task->getNodePrefs(), ch_idx); } void setChNotifState(uint8_t ch_idx, uint8_t state) { chanctl::setNotif(_task->getNodePrefs(), ch_idx, state); } @@ -869,15 +843,9 @@ class MessagesScreen : public UIScreen { uint8_t dmNotifState(const uint8_t* pub_key) const { return contactctl::notif(_task->getNodePrefs(), pub_key); } void setDmNotifState(const uint8_t* pub_key, uint8_t state) { contactctl::setNotif(_task->getNodePrefs(), pub_key, state); } - // Channel melody: slot 1 = melody 1 (variant bit clear), 2 = melody 2 (set). - uint8_t chNotifMelody(uint8_t ch_idx) const { - NodePrefs* p = _task->getNodePrefs(); - return p ? maskPairGet(p->ch_notif_melody_set, p->ch_notif_melody_2, ch_idx, 2, 1) : 0; - } - void setChNotifMelody(uint8_t ch_idx, uint8_t slot) { - NodePrefs* p = _task->getNodePrefs(); - if (p) maskPairSet(p->ch_notif_melody_set, p->ch_notif_melody_2, ch_idx, slot, 2); - } + // Channel melody (ui-core/ChannelControl.h): 0 = global, 1 / 2 = melody 1 / 2. + uint8_t chNotifMelody(uint8_t ch_idx) const { return chanctl::melody(_task->getNodePrefs(), ch_idx); } + void setChNotifMelody(uint8_t ch_idx, uint8_t slot) { chanctl::setMelody(_task->getNodePrefs(), ch_idx, slot); } uint8_t dmMelodySlot(const uint8_t* pub_key) const { return contactctl::melody(_task->getNodePrefs(), pub_key); } void setDmMelody(const uint8_t* pub_key, uint8_t slot) { contactctl::setMelody(_task->getNodePrefs(), pub_key, slot); } diff --git a/examples/companion_radio/ui-new/RingtoneEditorScreen.h b/examples/companion_radio/ui-new/RingtoneEditorScreen.h index f2215934..27765ff3 100644 --- a/examples/companion_radio/ui-new/RingtoneEditorScreen.h +++ b/examples/companion_radio/ui-new/RingtoneEditorScreen.h @@ -6,20 +6,17 @@ class RingtoneEditorScreen : public UIScreen { UITask* _task; NodePrefs* _prefs; - static const int MAX_NOTES = 32; + static const int MAX_NOTES = soundctl::MAX_NOTES; // melody model: ui-core/SoundControl.h // Menu item indices enum MenuIdx { MI_PLAY=0, MI_SWITCH, MI_DURATION, MI_BPM, MI_INSERT, MI_DELETE, MI_SAVE, MI_DISCARD, MI_COUNT }; int _visible_notes = 7; // updated in render(); used by clampScroll() - static const uint16_t BPM_OPTS[5]; - static const char* DUR_LABELS[4]; - static const char PITCH_NAMES[8]; // lowercase rtttl names - - uint8_t _notes[MAX_NOTES]; - uint8_t _len; - uint8_t _bpm_idx; + soundctl::Melody _mel; + uint8_t* const _notes = _mel.notes; + uint8_t& _len = _mel.len; + uint8_t& _bpm_idx = _mel.bpm_idx; int _slot; // 0=melody1, 1=melody2 int _cursor; int _scroll; @@ -30,12 +27,10 @@ class RingtoneEditorScreen : public UIScreen { char _menu_dur_label[16]; char _menu_bpm_label[10]; - static uint8_t notePitch(uint8_t b) { return b & 0x07; } - static uint8_t noteOctave(uint8_t b) { return ((b >> 3) & 0x03) + 4; } - static uint8_t noteDurIdx(uint8_t b) { return (b >> 5) & 0x03; } - static uint8_t packNote(uint8_t pitch, uint8_t octave, uint8_t dur_idx) { - return (pitch & 0x07) | (((octave - 4) & 0x03) << 3) | ((dur_idx & 0x03) << 5); - } + static uint8_t notePitch(uint8_t b) { return soundctl::notePitch(b); } + static uint8_t noteOctave(uint8_t b) { return soundctl::noteOctave(b); } + static uint8_t noteDurIdx(uint8_t b) { return soundctl::noteDur(b); } + static uint8_t packNote(uint8_t pitch, uint8_t octave, uint8_t dur_idx) { return soundctl::packNote(pitch, octave, dur_idx); } void clampScroll() { if (_cursor < _scroll) _scroll = _cursor; @@ -43,14 +38,11 @@ class RingtoneEditorScreen : public UIScreen { if (_scroll < 0) _scroll = 0; } - void buildRTTTL() { - NodePrefs::buildRTTTLString(_notes, _len, _bpm_idx, _play_buf, sizeof(_play_buf)); - } + void buildRTTTL() { soundctl::toRtttl(_mel, _play_buf, sizeof(_play_buf)); } void previewNote(uint8_t note_byte) { - uint8_t pitch = notePitch(note_byte); - if (pitch == 0) { _task->stopMelody(); return; } - snprintf(_play_buf, sizeof(_play_buf), "P:d=16,o=5,b=240:%c%d", PITCH_NAMES[pitch], noteOctave(note_byte)); + soundctl::notePreview(note_byte, _play_buf, sizeof(_play_buf)); + if (!_play_buf[0]) { _task->stopMelody(); return; } _task->playMelody(_play_buf); } @@ -61,12 +53,7 @@ public: // setCurrScreen() (whose onShow() can't carry the slot argument). void selectSlot(int slot = 0) { _slot = (slot == 1) ? 1 : 0; - bool s2 = (_slot == 1); - _bpm_idx = (_prefs && (s2 ? _prefs->ringtone2_bpm_idx : _prefs->ringtone_bpm_idx) < 5) - ? (s2 ? _prefs->ringtone2_bpm_idx : _prefs->ringtone_bpm_idx) : 2; - uint8_t rlen = _prefs ? (s2 ? _prefs->ringtone2_len : _prefs->ringtone_len) : 0; - _len = (rlen <= (uint8_t)MAX_NOTES) ? rlen : 0; - if (_prefs) memcpy(_notes, s2 ? _prefs->ringtone2_notes : _prefs->ringtone_notes, sizeof(_notes)); + soundctl::load(_prefs, _slot, _mel); _cursor = 0; _scroll = 0; _menu.active = false; @@ -76,8 +63,8 @@ public: snprintf(_menu_play_label, sizeof(_menu_play_label), "%s", _task->isMelodyPlaying() ? "Stop" : "Play"); snprintf(_menu_slot_label, sizeof(_menu_slot_label), "Melody %d", (_slot == 0) ? 2 : 1); uint8_t di = (_cursor < _len) ? noteDurIdx(_notes[_cursor]) : 0; - snprintf(_menu_dur_label, sizeof(_menu_dur_label), "Duration: %s", DUR_LABELS[di]); - snprintf(_menu_bpm_label, sizeof(_menu_bpm_label), "BPM: %u", BPM_OPTS[_bpm_idx]); + snprintf(_menu_dur_label, sizeof(_menu_dur_label), "Duration: %s", soundctl::durLabel(di)); + snprintf(_menu_bpm_label, sizeof(_menu_bpm_label), "BPM: %u", soundctl::bpm(_bpm_idx)); _menu.begin("Options", 5); _menu.addItem(_menu_play_label); _menu.addItem(_menu_slot_label); @@ -101,7 +88,7 @@ public: _visible_notes = display.width() / cell_w; char hdr[32]; - snprintf(hdr, sizeof(hdr), "M%d BPM:%u %d/%d", _slot + 1, BPM_OPTS[_bpm_idx], _len, MAX_NOTES); + snprintf(hdr, sizeof(hdr), "M%d BPM:%u %d/%d", _slot + 1, soundctl::bpm(_bpm_idx), _len, MAX_NOTES); display.setCursor(0, 0); display.print(hdr); display.fillRect(0, display.headerH() - 1, display.width(), display.sepH()); @@ -117,7 +104,7 @@ public: if (pitch == 0) { label[0] = '-'; label[1] = '-'; label[2] = '\0'; } else { - label[0] = PITCH_NAMES[pitch] - 32; // uppercase + label[0] = soundctl::pitchChar(pitch) - 32; // uppercase label[1] = '0' + octave; label[2] = '\0'; } @@ -147,7 +134,7 @@ public: if (_cursor < _len) { char info[24]; snprintf(info, sizeof(info), "oct:%d dur:%s", - noteOctave(_notes[_cursor]), DUR_LABELS[noteDurIdx(_notes[_cursor])]); + noteOctave(_notes[_cursor]), soundctl::durLabel(noteDurIdx(_notes[_cursor]))); display.setCursor(display.getCharWidth() + 2, info_y); display.print(info); } else if (_cursor == _len) { @@ -170,11 +157,11 @@ public: uint8_t di = noteDurIdx(_notes[_cursor]); di = (dir > 0) ? (di + 1) & 0x03 : (di + 3) & 0x03; _notes[_cursor] = packNote(p, o, di); - snprintf(_menu_dur_label, sizeof(_menu_dur_label), "Duration: %s", DUR_LABELS[di]); + snprintf(_menu_dur_label, sizeof(_menu_dur_label), "Duration: %s", soundctl::durLabel(di)); } else if (sel == MI_BPM) { if (dir > 0) { if (_bpm_idx < 4) _bpm_idx++; } else { if (_bpm_idx > 0) _bpm_idx--; } - snprintf(_menu_bpm_label, sizeof(_menu_bpm_label), "BPM: %u", BPM_OPTS[_bpm_idx]); + snprintf(_menu_bpm_label, sizeof(_menu_bpm_label), "BPM: %u", soundctl::bpm(_bpm_idx)); } } @@ -226,15 +213,7 @@ public: break; case MI_SAVE: if (_prefs) { - if (_slot == 1) { - _prefs->ringtone2_bpm_idx = _bpm_idx; - _prefs->ringtone2_len = _len; - memcpy(_prefs->ringtone2_notes, _notes, sizeof(_notes)); - } else { - _prefs->ringtone_bpm_idx = _bpm_idx; - _prefs->ringtone_len = _len; - memcpy(_prefs->ringtone_notes, _notes, sizeof(_notes)); - } + soundctl::store(_prefs, _slot, _mel); the_mesh.savePrefs(); } _task->stopMelody(); @@ -294,7 +273,3 @@ public: return false; } }; - -const uint16_t RingtoneEditorScreen::BPM_OPTS[5] = { 60, 90, 120, 150, 180 }; -const char* RingtoneEditorScreen::DUR_LABELS[4] = { "1/4", "1/8", "1/16", "1/32" }; -const char RingtoneEditorScreen::PITCH_NAMES[8] = { 'p', 'c', 'd', 'e', 'f', 'g', 'a', 'b' }; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 0f139345..dd7a5b2c 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1,5 +1,6 @@ #include "UITask.h" -#include "SoundNotifier.h" +#include "../ui-core/SoundNotifier.h" +#include "../ui-core/SoundControl.h" #include #include "../MyMesh.h" #include "../MsgExpand.h" @@ -1667,9 +1668,9 @@ void UITask::gotoGpioScreen() { void UITask::gotoLiveShareScreen() { setCurrScreen(live_share_screen); } // ── Clock tools (engine in ui-core/ClockEngine.h) ─────────────────────────── -// The melody overrides mute (playMelody → buzzer.playForced); the ring -// auto-stops after ClockEngine::RING_MS if no key dismisses it (see loop()). -static const char* CLOCK_ALARM_MELODY = "alarm:d=8,o=6,b=125:c,c,c,c,p,c,c,c,c,p"; +// The melody (soundctl::MEL_ALARM) overrides mute (playMelody → +// buzzer.playForced); the ring auto-stops after ClockEngine::RING_MS if no key +// dismisses it (see loop()). void UITask::wakeForAlarm() { if (_display != NULL) _display->turnOn(); @@ -1693,7 +1694,7 @@ void UITask::tickCore() { _core->loop(); drainCoreEvents(); // Repeat the ring melody until dismissed or the ring window elapses. - if (_core->clock.isRinging() && !isMelodyPlaying()) playMelody(CLOCK_ALARM_MELODY); + if (_core->clock.isRinging() && !isMelodyPlaying()) playMelody(soundctl::MEL_ALARM); } void UITask::drainCoreEvents() { @@ -1703,7 +1704,7 @@ void UITask::drainCoreEvents() { case UiEventType::ClockAlert: wakeForAlarm(); showAlert(ev.text, ClockEngine::RING_MS); - playMelody(CLOCK_ALARM_MELODY); + playMelody(soundctl::MEL_ALARM); break; case UiEventType::ClockRingEnded: stopMelody(); @@ -1715,10 +1716,10 @@ void UITask::drainCoreEvents() { case UiEventType::LocatorCrossed: showAlert(ev.text, 3000); if (!isBuzzerQuiet()) - playMelody(ev.flag ? "locarr:d=8,o=6,b=140:c,e,g" : "loclv:d=8,o=6,b=140:g,e,c"); + playMelody(ev.flag ? soundctl::MEL_ARRIVE : soundctl::MEL_LEAVE); break; case UiEventType::LocatorBeep: - playMelody("locp:d=32,o=7,b=200:c"); + playMelody(soundctl::MEL_TICK); break; case UiEventType::MessageArrived: onMessageArrived(ev); @@ -2681,13 +2682,8 @@ void UITask::loop() { userLedHandler(); #ifdef PIN_BUZZER - if (_node_prefs && _node_prefs->buzzer_auto) { - bool should_quiet = isClientConnected(); // BLE bonded or an open USB port - if (buzzer.isQuiet() != should_quiet) { - buzzer.quiet(should_quiet); - _next_refresh = 0; - } - } + if (soundctl::autoTick(_node_prefs, buzzer, isClientConnected())) // BLE bonded or an open USB port + _next_refresh = 0; if (buzzer.isPlaying()) buzzer.loop(); #endif @@ -3257,10 +3253,7 @@ void UITask::setBrightnessLevel(uint8_t level) { void UITask::setBuzzerVolumeLevel(uint8_t level) { #ifdef PIN_BUZZER if (_node_prefs == NULL) return; - if (level > 4) level = 4; - _node_prefs->buzzer_volume = level; - buzzer.setVolume(level); - if (level > 0) buzzer.playForced("Vol:d=16,o=6,b=120:c"); + soundctl::setVolume(_node_prefs, buzzer, level); _next_refresh = 0; #endif } @@ -3283,8 +3276,8 @@ void UITask::toggleBuzzer() { int UITask::getBuzzerMode() { #ifdef PIN_BUZZER - if (_node_prefs && _node_prefs->buzzer_auto) return 2; - return buzzer.isQuiet() ? 1 : 0; + if (_node_prefs && _node_prefs->buzzer_auto) return soundctl::MODE_AUTO; + return buzzer.isQuiet() ? soundctl::MODE_OFF : soundctl::MODE_ON; #else return 1; #endif @@ -3294,11 +3287,9 @@ void UITask::cycleBuzzerMode() { #ifdef PIN_BUZZER if (!_node_prefs) return; int mode = getBuzzerMode(); - mode = (mode + 1) % 3; // ON(0) → OFF(1) → Auto(2) → ON - _node_prefs->buzzer_auto = (mode == 2) ? 1 : 0; - if (mode == 0) { buzzer.quiet(false); _node_prefs->buzzer_quiet = 0; notify(UIEventType::ack); } - if (mode == 1) { buzzer.quiet(true); _node_prefs->buzzer_quiet = 1; } - if (mode == 2) { buzzer.quiet(isClientConnected()); } + mode = (mode + 1) % soundctl::MODE_COUNT; // ON → OFF → Auto → ON + soundctl::setMode(_node_prefs, buzzer, (uint8_t)mode, isClientConnected()); + if (mode == soundctl::MODE_ON) notify(UIEventType::ack); static const char* labels[] = { "Buzzer: ON", "Buzzer: OFF", "Buzzer: Auto" }; showAlert(labels[mode], 800); _next_refresh = 0; diff --git a/solo/wio-tracker-l2/platformio.ini b/solo/wio-tracker-l2/platformio.ini index 91a4c639..5d5f6348 100644 --- a/solo/wio-tracker-l2/platformio.ini +++ b/solo/wio-tracker-l2/platformio.ini @@ -55,10 +55,13 @@ build_flags = -D MESHCORE_VERSION='"1.17.1"' -D AUTO_OFF_MILLIS=60000 -D MESH_IN_PSRAM=1 ; frees ~120 KB internal RAM for WiFi/TLS map downloads + -D PIN_BUZZER=-1 ; no piezo pin: the speaker plays through the codec + -D BUZZER_I2S build_src_filter = ${Wio_Tracker_L2.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-lvgl/*.cpp> +<../examples/companion_radio/ui-lvgl/fonts/*.c> diff --git a/src/helpers/ui/ES8311.h b/src/helpers/ui/ES8311.h new file mode 100644 index 00000000..eac8d95a --- /dev/null +++ b/src/helpers/ui/ES8311.h @@ -0,0 +1,45 @@ +#pragma once +// Minimal Everest ES8311 mono codec setup for tone playback: DAC only, I2S +// slave, 16-bit, the host supplies MCLK = 256 x fs. Register values follow +// Espressif's esp-adf es8311 driver, with its clock coefficients for MCLK +// 4.096 MHz / fs 16 kHz folded in. + +#include +#include + +namespace es8311 { + +static bool write(TwoWire& w, uint8_t addr, uint8_t reg, uint8_t val) { + w.beginTransmission(addr); + w.write(reg); + w.write(val); + return w.endTransmission() == 0; +} + +// Codec on and playing whatever arrives on I2S. False: no codec at `addr`. +static bool begin(TwoWire& w, uint8_t addr) { + static const uint8_t SEQ[][2] = { + { 0x45, 0x00 }, { 0x01, 0x30 }, { 0x02, 0x00 }, { 0x03, 0x10 }, { 0x16, 0x24 }, + { 0x04, 0x10 }, { 0x05, 0x00 }, { 0x0B, 0x00 }, { 0x0C, 0x00 }, { 0x10, 0x1F }, + { 0x11, 0x7F }, + { 0x00, 0x80 }, // power up the state machine, slave mode + { 0x01, 0x3F }, // MCLK from its pin, every clock on + // fs = MCLK / 256: pre-divider / multiplier 1, ADC / DAC dividers 1, + // single-speed, OSR 0x10, LRCK divider 0x0FF, BCLK divider 4 + { 0x02, 0x00 }, { 0x05, 0x00 }, { 0x03, 0x10 }, { 0x04, 0x10 }, + { 0x07, 0x00 }, { 0x08, 0xFF }, { 0x06, 0x03 }, + { 0x13, 0x10 }, { 0x1B, 0x0A }, { 0x1C, 0x6A }, + { 0x09, 0x0C }, // DAC serial port: I2S, 16-bit, unmuted + { 0x0A, 0x0C }, // ADC serial port: the same (unused) + // start: analog up, DAC powered, output driver on + { 0x17, 0xBF }, { 0x0E, 0x02 }, { 0x12, 0x00 }, { 0x14, 0x1A }, { 0x0D, 0x01 }, + { 0x15, 0x40 }, { 0x37, 0x08 }, { 0x45, 0x00 }, + { 0x32, 0xBF }, // DAC volume 0 dB (the player scales its samples) + { 0x31, 0x00 }, // DAC unmuted + }; + for (size_t i = 0; i < sizeof(SEQ) / sizeof(SEQ[0]); i++) + if (!write(w, addr, SEQ[i][0], SEQ[i][1])) return false; + return true; +} + +} // namespace es8311 diff --git a/src/helpers/ui/buzzer.cpp b/src/helpers/ui/buzzer.cpp index a93e802e..0fcd283e 100644 --- a/src/helpers/ui/buzzer.cpp +++ b/src/helpers/ui/buzzer.cpp @@ -8,7 +8,7 @@ void genericBuzzer::begin() { // 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 +#if !defined(SIM_PLATFORM) && !defined(BUZZER_I2S) #ifdef PIN_BUZZER_EN pinMode(PIN_BUZZER_EN, OUTPUT); digitalWrite(PIN_BUZZER_EN, HIGH); @@ -29,6 +29,9 @@ void genericBuzzer::begin() { NVIC_SetPriority(TIMER1_IRQn, 7); NVIC_ClearPendingIRQ(TIMER1_IRQn); NVIC_EnableIRQ(TIMER1_IRQn); +#endif +#if defined(BUZZER_I2S) + if (!_i2sBegin()) return; // no codec: stay silent #endif startup(); } @@ -50,7 +53,7 @@ void genericBuzzer::shutdown() { play(shutdown_song); } // 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) || defined(SIM_PLATFORM) +#if defined(NRF52_PLATFORM) || defined(SIM_PLATFORM) || defined(BUZZER_I2S) // 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 }; @@ -109,7 +112,9 @@ bool genericBuzzer::_parseNext(const char*& p, uint8_t def_dur, uint8_t def_oct, return true; } -#endif // NRF52_PLATFORM || SIM_PLATFORM +int genericBuzzer::noteIndex() const { return _note_idx; } + +#endif // NRF52_PLATFORM || SIM_PLATFORM || BUZZER_I2S // --------------------------------------------------------------------------- // nRF52 path — direct NRF_PWM2 control, bypasses tone() @@ -216,6 +221,7 @@ void genericBuzzer::_timer1ISR() { void genericBuzzer::_nrfBegin(const char* melody) { _disarmNoteTimer(); // drop any in-flight/pending note advance before reconfiguring _nrfStopPwm(); + _note_idx = -1; if (!melody || !*melody) { _rtttl_done = true; return; } const char* notes; _parseHeader(melody, _def_dur, _def_oct, _def_bpm, notes); @@ -227,10 +233,12 @@ void genericBuzzer::_nrfBegin(const char* melody) { void genericBuzzer::_nrfAdvance() { uint16_t freq; uint32_t dur_ms; if (_parseNext(_rtttl_pos, _def_dur, _def_oct, _def_bpm, freq, dur_ms)) { + _note_idx++; _armNoteTimer(dur_ms); if (freq > 0) _nrfStartPwm(freq); else _nrfStopPwm(); } else { _nrfStopPwm(); + _note_idx = -1; _rtttl_done = true; } } @@ -257,6 +265,7 @@ bool genericBuzzer::isPlaying() { return !_rtttl_done; } void genericBuzzer::stop() { _disarmNoteTimer(); // ensure no latched note-advance fires after we stop _nrfStopPwm(); + _note_idx = -1; _rtttl_done = true; } @@ -288,9 +297,11 @@ void genericBuzzer::setVolume(uint8_t level) { void genericBuzzer::_advance() { uint16_t freq; uint32_t dur_ms; if (_parseNext(_rtttl_pos, _def_dur, _def_oct, _def_bpm, freq, dur_ms)) { + _note_idx++; _cur_freq = freq; _note_end_ms = millis() + dur_ms; } else { + _note_idx = -1; _cur_freq = 0; _rtttl_done = true; } @@ -309,6 +320,7 @@ void genericBuzzer::play(const char* melody) { void genericBuzzer::playForced(const char* melody) { _rtttl_done = true; _cur_freq = 0; + _note_idx = -1; if (!melody || !*melody) return; const char* notes; _parseHeader(melody, _def_dur, _def_oct, _def_bpm, notes); @@ -322,6 +334,7 @@ bool genericBuzzer::isPlaying() { return !_rtttl_done; } void genericBuzzer::stop() { _rtttl_done = true; _cur_freq = 0; + _note_idx = -1; } void genericBuzzer::loop() { @@ -333,7 +346,277 @@ void genericBuzzer::setVolume(uint8_t level) { _volume_level = level < 5 ? level : 4; } -#else // NRF52_PLATFORM / SIM_PLATFORM +#elif defined(BUZZER_I2S) + +// --------------------------------------------------------------------------- +// I2S codec path -- a speaker behind an ES8311 (Wio Tracker L2). An audio +// task synthesises a sine per note and counts samples to end it, so timing +// holds however long the UI loop stalls (map tiles, SD). The task only +// touches I2S; the codec (I2C) and the amp (the board's IO expander, I2C +// too) are driven from the caller's thread, which owns the bus. +// --------------------------------------------------------------------------- + +#include +#include +#include "ES8311.h" + +#ifndef AUDIO_AMP_SETTLE_MS + // Silence after amp power-up, so the first note isn't clipped. Seeed's + // Meshtastic port waits 250 ms, PR #3381's player 3 ms: in between. + #define AUDIO_AMP_SETTLE_MS 100 +#endif + +static const int SAMPLE_RATE = 16000; +static const i2s_port_t I2S_PORT = I2S_NUM_0; +static const uint32_t AMP_LINGER_MS = 3000; // amp stays on between close sounds (no settle wait each time) +static const uint32_t CLK_SETTLE_MS = 30; // codec clocked this long before the amp comes on +// Note edges follow a raised cosine (a linear 3 ms ramp still ticked): the +// attack, the release, and the fade of a note cut short by the next sound. +static const uint32_t ATTACK = 80; // 5 ms +static const uint32_t RELEASE = 160; // 10 ms +static const int CUT_FADE = 240; // 15 ms +static const int CHUNK = 128; // frames per i2s_write +static portMUX_TYPE s_mux = portMUX_INITIALIZER_UNLOCKED; +static int16_t s_sine[256]; +static int16_t s_ease[65]; // (1 - cos(pi x)) / 2 over 0..1, Q15 + +// Gain `peak` eased in over `n` samples: position x of n. +static int32_t ease(int32_t peak, uint32_t x, uint32_t n) { + if (x >= n) return peak; + return peak * s_ease[x * 64 / n] / 32767; +} +static uint32_t s_amp_on_ms = 0; + +// Peak sample per volume level: -24/-16/-9/-3/0 dB like the nRF52 duty +// steps, under an -8 dBFS ceiling (the class-D amp is loud near full scale; +// PR #3381 played at about -21 dBFS and called it gentle). +static int16_t peakFor(uint8_t level) { + static const int16_t PEAK[5] = { 820, 2060, 4620, 9220, 13000 }; + return PEAK[level < 5 ? level : 4]; +} + +bool genericBuzzer::_i2sBegin() { + // i2s_driver_install() crashes in IDF's cleanup when its DMA allocation + // fails (PR #3381 saw a boot loop): don't try without clear headroom. + if (heap_caps_get_free_size(MALLOC_CAP_DMA) < 32000) return false; + for (int i = 0; i < 256; i++) s_sine[i] = (int16_t)(32767.0f * sinf(i * 2.0f * (float)M_PI / 256.0f)); + for (int i = 0; i <= 64; i++) s_ease[i] = (int16_t)(32767.0f * 0.5f * (1.0f - cosf(i * (float)M_PI / 64.0f))); + + i2s_config_t cfg = {}; + cfg.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX); + cfg.sample_rate = SAMPLE_RATE; + cfg.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT; + cfg.channel_format = I2S_CHANNEL_FMT_RIGHT_LEFT; + cfg.communication_format = I2S_COMM_FORMAT_STAND_I2S; + cfg.dma_buf_count = 6; // 48 ms queued: short, since the task keeps it topped up + cfg.dma_buf_len = CHUNK; + cfg.tx_desc_auto_clear = true; // an underrun plays silence, not the last buffer again + cfg.mclk_multiple = I2S_MCLK_MULTIPLE_256; + i2s_pin_config_t pins = {}; + pins.mck_io_num = PIN_I2S_MCLK; + pins.bck_io_num = PIN_I2S_BCK; + pins.ws_io_num = PIN_I2S_WS; + pins.data_out_num = PIN_I2S_DOUT; + pins.data_in_num = I2S_PIN_NO_CHANGE; + if (i2s_driver_install(I2S_PORT, &cfg, 0, nullptr) != ESP_OK) return false; + if (i2s_set_pin(I2S_PORT, &pins) != ESP_OK) { i2s_driver_uninstall(I2S_PORT); return false; } + i2s_zero_dma_buffer(I2S_PORT); + + // MCLK is running now, so the codec's clock tree comes up with it. + if (!es8311::begin(Wire, BUZZER_CODEC_ES8311)) { + i2s_driver_uninstall(I2S_PORT); + return false; + } + _clk_on_ms = millis(); + _clk_running = true; + delay(CLK_SETTLE_MS); // settled before the startup sound powers the amp + // Above the UI loop and LVGL (priority 1), so rendering never starves it. + _i2s_ok = xTaskCreate(_taskEntry, "buzzer", 3072, this, 6, (TaskHandle_t*)&_task) == pdPASS; + return _i2s_ok; +} + +void genericBuzzer::_taskEntry(void* self) { ((genericBuzzer*)self)->_taskLoop(); } + +// Waits for play()/stop(), then plays the latest melody; a newer request +// (_req changed) cuts the one playing within a chunk plus the DMA queue, +// fading the cut note out so it doesn't click. +void genericBuzzer::_taskLoop() { + static int16_t buf[CHUNK * 2]; + static const int16_t zeros[CHUNK * 2] = {0}; + static char mel[MEL_MAX]; + uint32_t done_req = 0; + size_t w; + for (;;) { + // Between sounds, while the codec is clocked, keep the DMA queue full of + // silence: a sound starting into a queue that had run dry could be played + // from a half-written buffer -- a knock at slow taps, never at fast ones. + // Idle past the amp's linger, stop the clocks: only with the amp off (and + // not about to come on), or the codec's output step pops through it. + uint32_t idle_since = millis(); + while (_req == done_req) { + if (!_clk_running) { ulTaskNotifyTake(pdTRUE, portMAX_DELAY); idle_since = millis(); continue; } + i2s_write(I2S_PORT, zeros, sizeof(zeros), &w, pdMS_TO_TICKS(50)); + if (millis() - idle_since < AMP_LINGER_MS + 500) continue; + bool stop = false; + portENTER_CRITICAL(&s_mux); + if (!_amp_on && !_amp_pending && _req == done_req) { _clk_running = false; stop = true; } + portEXIT_CRITICAL(&s_mux); + if (stop) i2s_stop(I2S_PORT); else idle_since = millis(); + } + ulTaskNotifyTake(pdTRUE, 0); // its request is being taken now + for (;;) { + uint32_t req; bool stop; uint16_t settle; + portENTER_CRITICAL(&s_mux); + req = _req; stop = _stop_req; settle = _settle_ms; _settle_ms = 0; + memcpy(mel, _mel, MEL_MAX); + portEXIT_CRITICAL(&s_mux); + if (req == done_req) break; + done_req = req; + _note_idx = -1; + if (stop || !mel[0]) { _task_playing = false; break; } + if (!_clk_running) { + i2s_zero_dma_buffer(I2S_PORT); + i2s_start(I2S_PORT); + portENTER_CRITICAL(&s_mux); + _clk_on_ms = millis(); + _clk_running = true; // loop() powers the amp once the codec settles + portEXIT_CRITICAL(&s_mux); + } + + bool cut = false; + memset(buf, 0, sizeof(buf)); + for (uint32_t n = (uint32_t)settle * SAMPLE_RATE / 1000; n > 0 && !cut; ) { + uint32_t k = n < CHUNK ? n : CHUNK; + i2s_write(I2S_PORT, buf, k * 4, &w, portMAX_DELAY); + n -= k; + cut = _req != req; + } + + uint8_t def_dur, def_oct; uint16_t bpm; const char* pos; + _parseHeader(mel, def_dur, def_oct, bpm, pos); + uint16_t freq; uint32_t dur_ms; + int16_t idx = -1; + while (!cut && _parseNext(pos, def_dur, def_oct, bpm, freq, dur_ms)) { + _note_idx = ++idx; + uint32_t total = dur_ms * SAMPLE_RATE / 1000; + uint32_t phase = 0, step = (uint32_t)(((uint64_t)freq << 32) / SAMPLE_RATE); + int32_t peak = peakFor(_volume_level), g = 0; + // Short notes (1/32 at 180 BPM is 41 ms) get shorter edges. + uint32_t att = total / 4 < ATTACK ? total / 4 : ATTACK; + uint32_t rel = total / 3 < RELEASE ? total / 3 : RELEASE; + uint32_t i = 0; + while (i < total && !cut) { + uint32_t k = total - i < CHUNK ? total - i : CHUNK; + for (uint32_t j = 0; j < k; j++, i++) { + int16_t s = 0; + if (freq) { + uint32_t left = total - 1 - i; + g = i < att ? ease(peak, i, att) : ease(peak, left, rel); + s = (int16_t)((int32_t)s_sine[phase >> 24] * g / 32767); + phase += step; + } + buf[j * 2] = buf[j * 2 + 1] = s; + } + i2s_write(I2S_PORT, buf, k * 4, &w, portMAX_DELAY); + cut = _req != req; + } + if (cut && freq && g) { // fade out from where the note was cut + for (int j = 0; j < CUT_FADE; j += CHUNK) { + int k = CUT_FADE - j < CHUNK ? CUT_FADE - j : CHUNK; + for (int m = 0; m < k; m++) { + int32_t gj = ease(g, CUT_FADE - 1 - (j + m), CUT_FADE); + buf[m * 2] = buf[m * 2 + 1] = (int16_t)((int32_t)s_sine[phase >> 24] * gj / 32767); + phase += step; + } + i2s_write(I2S_PORT, buf, k * 4, &w, portMAX_DELAY); + } + } + } + _note_idx = -1; + if (cut) continue; // a newer request: take it at once + // Let the DMA queue play out before reporting the melody done. + for (int i = 0; i < 6; i++) i2s_write(I2S_PORT, zeros, sizeof(zeros), &w, portMAX_DELAY); + if (_req == req) _task_playing = false; + } + } +} + +void genericBuzzer::_start(const char* melody) { + if (!_i2s_ok) return; + if (!melody || !*melody) { stop(); return; } + uint16_t settle = 0; + bool power_now = false; + portENTER_CRITICAL(&s_mux); + if (_amp_on) { // a replay within the settle time still waits out the rest + uint32_t since = millis() - s_amp_on_ms; + settle = since < AUDIO_AMP_SETTLE_MS ? (uint16_t)(AUDIO_AMP_SETTLE_MS - since) : 0; + } else if (_clk_running && millis() - _clk_on_ms >= CLK_SETTLE_MS) { + _amp_on = power_now = true; // claimed here, so the task keeps the clocks + settle = AUDIO_AMP_SETTLE_MS; + } else { + _amp_pending = true; // loop() powers it once the clocks have settled + settle = CLK_SETTLE_MS + AUDIO_AMP_SETTLE_MS; + } + strncpy(_mel, melody, MEL_MAX - 1); + _mel[MEL_MAX - 1] = 0; + _stop_req = false; + _settle_ms = settle; + _req++; + _task_playing = true; + portEXIT_CRITICAL(&s_mux); + if (power_now) { buzzerAmpPower(true); s_amp_on_ms = millis(); } + _amp_off_at = millis() + AMP_LINGER_MS; + xTaskNotifyGive((TaskHandle_t)_task); +} + +void genericBuzzer::applyVolume() {} // the task reads _volume_level per note + +void genericBuzzer::play(const char* melody) { + if (_is_quiet) return; + _start(melody); +} + +void genericBuzzer::playForced(const char* melody) { _start(melody); } + +bool genericBuzzer::isPlaying() { return _task_playing; } + +void genericBuzzer::stop() { + if (!_i2s_ok) return; + portENTER_CRITICAL(&s_mux); + _stop_req = true; + _req++; + _task_playing = false; + portEXIT_CRITICAL(&s_mux); + xTaskNotifyGive((TaskHandle_t)_task); +} + +// The amp: on once the codec's clocks have settled (a pending start), off +// once nothing has played for AMP_LINGER_MS. +void genericBuzzer::loop() { + if (!_i2s_ok) return; + if (_amp_pending) { + bool power = false; + portENTER_CRITICAL(&s_mux); + if (!_task_playing) _amp_pending = false; // stopped before it got going + else if (_clk_running && millis() - _clk_on_ms >= CLK_SETTLE_MS) { _amp_pending = false; _amp_on = power = true; } + portEXIT_CRITICAL(&s_mux); + if (power) { buzzerAmpPower(true); s_amp_on_ms = millis(); } + } + if (!_amp_on) return; + if (_task_playing) _amp_off_at = millis() + AMP_LINGER_MS; + else if ((int32_t)(millis() - _amp_off_at) >= 0) { + buzzerAmpPower(false); + portENTER_CRITICAL(&s_mux); + _amp_on = false; + portEXIT_CRITICAL(&s_mux); + } +} + +void genericBuzzer::setVolume(uint8_t level) { + _volume_level = level < 5 ? level : 4; +} + +#else // NRF52_PLATFORM / SIM_PLATFORM / BUZZER_I2S // --------------------------------------------------------------------------- // Non-nRF52, non-sim path — NonBlockingRtttl + analogWrite for volume @@ -359,6 +642,8 @@ void genericBuzzer::playForced(const char* melody) { bool genericBuzzer::isPlaying() { return rtttl::isPlaying(); } +int genericBuzzer::noteIndex() const { return -1; } // the library doesn't say + void genericBuzzer::stop() { rtttl::stop(); } void genericBuzzer::loop() { @@ -373,6 +658,6 @@ void genericBuzzer::setVolume(uint8_t level) { if (isPlaying()) applyVolume(); } -#endif // NRF52_PLATFORM / SIM_PLATFORM +#endif // NRF52_PLATFORM / SIM_PLATFORM / BUZZER_I2S #endif // PIN_BUZZER diff --git a/src/helpers/ui/buzzer.h b/src/helpers/ui/buzzer.h index 429a7f16..ac95571a 100644 --- a/src/helpers/ui/buzzer.h +++ b/src/helpers/ui/buzzer.h @@ -5,10 +5,17 @@ // 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) +#if !defined(NRF52_PLATFORM) && !defined(SIM_PLATFORM) && !defined(BUZZER_I2S) #include #endif +#if defined(BUZZER_I2S) +// Board-provided (its target.cpp): powers the speaker amplifier on / off. +// Called from the thread that calls genericBuzzer's methods, never the +// audio task, so it may use the I2C bus. +void buzzerAmpPower(bool on); +#endif + /* class abstracts underlying RTTTL library Just a simple implementation to start. At the moment use same @@ -32,6 +39,9 @@ class genericBuzzer void startup(); void shutdown(); bool isPlaying(); + // The note sounding now (0-based, rests count), -1 when silent or + // unknown -- for an editor to follow the playback. + int noteIndex() const; void quiet(bool buzzer_state); bool isQuiet(); void setVolume(uint8_t level); @@ -45,7 +55,7 @@ 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) +#if defined(NRF52_PLATFORM) || defined(SIM_PLATFORM) || defined(BUZZER_I2S) // 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 @@ -56,6 +66,7 @@ class genericBuzzer uint8_t _def_dur = 4; uint8_t _def_oct = 5; uint16_t _def_bpm = 120; + volatile int16_t _note_idx = -1; 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, @@ -111,5 +122,28 @@ class genericBuzzer uint16_t currentFreqHz() const { return _cur_freq; } private: uint16_t _cur_freq = 0; +#elif defined(BUZZER_I2S) + // A speaker behind an I2S codec (BUZZER_CODEC_ES8311) instead of a + // PWM pin. An audio task synthesises the melody and advances its + // notes by samples written, so timing holds through a stalled UI + // loop; loop() only powers the amp. See buzzer.cpp. + static const int MEL_MAX = 256; + char _mel[MEL_MAX]; // the melody playing (a copy: callers reuse buffers) + volatile uint32_t _req = 0; // bumped by play()/stop(); the task restarts on a change + volatile bool _stop_req = false; + volatile bool _task_playing = false; + volatile uint16_t _settle_ms = 0; // silence before the first note while the amp powers up + volatile bool _amp_on = false; // guarded by the player's lock (the task reads it) + volatile bool _amp_pending = false; // wanted, waiting for the codec's clocks to settle + volatile bool _clk_running = false; // I2S clocking the codec + volatile uint32_t _clk_on_ms = 0; + bool _i2s_ok = false; + uint32_t _amp_off_at = 0; // amp stays on this long after the last sound + void* _task = nullptr; // TaskHandle_t + + bool _i2sBegin(); + void _start(const char* melody); + void _taskLoop(); + static void _taskEntry(void* self); #endif }; diff --git a/variants/sim/web/lvgl.html b/variants/sim/web/lvgl.html index 04950a9e..cf74735c 100644 --- a/variants/sim/web/lvgl.html +++ b/variants/sim/web/lvgl.html @@ -224,8 +224,45 @@ const y = (e.clientY - r.top - by) * 240 / canvas.clientHeight; return [Math.max(0, Math.min(319, x)), Math.max(0, Math.min(239, y))]; } + // Speaker: a Web Audio oscillator polled against L's sim_buzzer_* exports, + // as index.html does for the piezo (see its comments). A sine here: the L2 + // plays its melodies through a codec and a speaker, not a buzzer. The + // context can only start on a user gesture, so the first touch creates it. + let audioCtx = null, osc = null, gain = null, wasPlaying = false; + function ensureAudio() { + if (audioCtx) { if (audioCtx.state === 'suspended') audioCtx.resume(); return; } + audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + if (audioCtx.state === 'suspended') audioCtx.resume(); + osc = audioCtx.createOscillator(); + gain = audioCtx.createGain(); + gain.gain.value = 0; + osc.type = 'sine'; + osc.connect(gain).connect(audioCtx.destination); + osc.start(); + } + window.__buzzAudio = () => (audioCtx ? { state: audioCtx.state, gain: gain.gain.value, freq: osc.frequency.value } : null); + const SPEAKER_GAIN = [0.02, 0.05, 0.1, 0.18, 0.28]; // the firmware's -24..0 dB steps + function rampGain(target, s, now) { + gain.gain.cancelScheduledValues(now); + gain.gain.setValueAtTime(gain.gain.value, now); + gain.gain.linearRampToValueAtTime(target, now + s); + } + setInterval(() => { + if (!audioCtx || !L || !L.mod._sim_buzzer_is_playing) return; + const playing = L.call('sim_buzzer_is_playing', 'number') === 1; + const now = audioCtx.currentTime; + if (playing) { + const freq = L.call('sim_buzzer_freq_hz', 'number'); + if (freq > 0) osc.frequency.setValueAtTime(freq, now); + rampGain(freq > 0 ? (SPEAKER_GAIN[L.call('sim_buzzer_get_volume', 'number')] || 0) : 0, 0.005, now); + } else if (wasPlaying) { + rampGain(0, 0.01, now); + } + wasPlaying = playing; + }, 20); + let pressed = false; - canvas.addEventListener('pointerdown', (e) => { pressed = true; canvas.setPointerCapture(e.pointerId); touch(...toDevice(e), true); }); + canvas.addEventListener('pointerdown', (e) => { ensureAudio(); pressed = true; canvas.setPointerCapture(e.pointerId); touch(...toDevice(e), true); }); canvas.addEventListener('pointermove', (e) => { if (pressed) touch(...toDevice(e), true); }); const lift = (e) => { if (!pressed) return; pressed = false; touch(...toDevice(e), false); }; canvas.addEventListener('pointerup', lift); diff --git a/variants/wio-tracker-l2/platformio.ini b/variants/wio-tracker-l2/platformio.ini index 711211c4..478b18dd 100644 --- a/variants/wio-tracker-l2/platformio.ini +++ b/variants/wio-tracker-l2/platformio.ini @@ -36,6 +36,14 @@ build_flags = ${esp32_base.build_flags} -D PIN_GPS_TX=18 ; L76K TX -> ESP32 RX -D PIN_GPS_RX=17 ; L76K RX <- ESP32 TX -D GPS_BAUD_RATE=9600 + ; ES8311 codec (I2C 0x18) -> class-D amp (power on expander P12) -> speaker. + ; A frontend opts in with -D PIN_BUZZER=-1 -D BUZZER_I2S (genericBuzzer's + ; I2S path, src/helpers/ui/buzzer.cpp) and +. + -D PIN_I2S_MCLK=10 + -D PIN_I2S_BCK=11 + -D PIN_I2S_WS=12 + -D PIN_I2S_DOUT=16 + -D BUZZER_CODEC_ES8311=0x18 -D ENV_SKIP_GPS_DETECT=1 ; GPS is always present, powered via IO expander ; disable ALL env sensor I2C probes: this bus carries six onboard chips ; (TCA9535 0x21, AW35615 0x22, LP5814 0x2C, ADS1115 0x48, GT911 0x5D, diff --git a/variants/wio-tracker-l2/target.cpp b/variants/wio-tracker-l2/target.cpp index b1dfefaf..92b42bb7 100644 --- a/variants/wio-tracker-l2/target.cpp +++ b/variants/wio-tracker-l2/target.cpp @@ -18,6 +18,10 @@ EnvironmentSensorManager sensors(gps); MomentaryButton user_btn(PIN_USER_BTN, 1000, true, true); #endif +#ifdef BUZZER_I2S +void buzzerAmpPower(bool on) { board.setSpeakerAmp(on); } +#endif + bool radio_init() { MESH_DEBUG_PRINTLN("radio_init: rtc + sx1262 init"); fallback_clock.begin();