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 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-26 00:27:52 +02:00
co-authored by Claude Opus 5.5
parent 2624ab0548
commit 938dfefba6
25 changed files with 1335 additions and 151 deletions
+16 -1
View File
@@ -34,7 +34,7 @@ Confirmed: green dot inside the ring, waypoints much better, download works, set
## New: Clock, settings pages, radio (2026-09-25) ## 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) - [ ] 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 > 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 - [ ] Clock > Stopwatch: Start / Stop / Reset with tenths
- [ ] Settings > Display & power: Screen off after; Wake on message - [ ] 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) - [ ] 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) - [ ] 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 - [ ] 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) ## 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 - [ ] 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 - [ ] 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
File diff suppressed because one or more lines are too long
@@ -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; 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 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) { static void setFavourite(NodePrefs* p, uint8_t idx, bool on) {
@@ -14,29 +14,33 @@
#include <stddef.h> #include <stddef.h>
#include "../NodePrefs.h" #include "../NodePrefs.h"
#include "../Trail.h" #include "../Trail.h"
#include "SoundControl.h"
namespace settings { namespace settings {
// Pages (one screen each) and the sections on them, in display order. // 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, enum Section : uint8_t { SEC_TRAIL, SEC_LIVE_SHARE, SEC_LOCATOR, SEC_UNITS,
SEC_DISPLAY, SEC_POWER, SEC_TIME, 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* 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] : ""; return p < PG_COUNT ? T[p] : "";
} }
static const char* sectionTitle(uint8_t s) { static const char* sectionTitle(uint8_t s) {
static const char* T[SEC_COUNT] = { "TRAIL", "LIVE SHARE", "ARRIVAL ALERT", "UNITS", static const char* T[SEC_COUNT] = { "TRAIL", "LIVE SHARE", "ARRIVAL ALERT", "UNITS",
"DISPLAY", "POWER", "TIME", "DISPLAY", "POWER", "TIME",
"MESSAGES", "CONTACTS" }; "MESSAGES", "CONTACTS",
"SOUND", "PLAYS FOR" };
return s < SEC_COUNT ? T[s] : ""; return s < SEC_COUNT ? T[s] : "";
} }
static uint8_t sectionPage(uint8_t s) { static uint8_t sectionPage(uint8_t s) {
static const uint8_t P[SEC_COUNT] = { PG_NAV, PG_NAV, PG_NAV, PG_NAV, static const uint8_t P[SEC_COUNT] = { PG_NAV, PG_NAV, PG_NAV, PG_NAV,
PG_DEVICE, PG_DEVICE, PG_DEVICE, 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; 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&) { static void optExpiry(uint8_t v, char* b, int n, const NodePrefs&) {
snprintf(b, n, "%s", NodePrefs::contactExpiryLabel(v)); 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 ────────────────────────────────────────────────────────────── // ── Side effects ──────────────────────────────────────────────────────────────
static void rearmLocator(UiCore& c) { c.locator.reset(); } static void rearmLocator(UiCore& c) { c.locator.reset(); }
static void restartShareClock(UiCore& c) { c.live_share.restartClock(); } // a new length starts over static void restartShareClock(UiCore& c) { c.live_share.restartClock(); } // a new length starts over
static void applyDisplay(UiCore& c) { c.host()->applyDisplayPrefs(); } static void applyDisplay(UiCore& c) { c.host()->applyDisplayPrefs(); }
static void applyGpsDuty(UiCore& c) { c.applyGpsInterval(); } 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_OFF(f) (uint16_t)offsetof(NodePrefs, f)
#define NP_SIZE(f) (uint8_t)sizeof(((NodePrefs*)0)->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, IDX("Contact expiry", "Inactive this long can be pruned", SEC_CONTACTS, contact_expiry_idx,
NodePrefs::CONTACT_EXPIRY_COUNT, optExpiry, nullptr), NodePrefs::CONTACT_EXPIRY_COUNT, optExpiry, nullptr),
MAP("Favourites first", "In contact and node lists", SEC_CONTACTS, fav_sort_off, INVERTED, nullptr, 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 IDX
#undef SW #undef SW
@@ -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 <helpers/ui/buzzer.h>
#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
@@ -1,6 +1,9 @@
#pragma once #pragma once
// Fork-specific buzzer/melody dispatch — isolated here so upstream changes to // Which sound an incoming message / advert plays, shared by ui-new's and
// UITask::notify() don't create merge conflicts. // 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 #ifdef PIN_BUZZER
#include <helpers/ui/buzzer.h> #include <helpers/ui/buzzer.h>
#include "../NodePrefs.h" #include "../NodePrefs.h"
@@ -38,6 +38,9 @@ public:
// A display pref changed through the settings schema (brightness, ...): // A display pref changed through the settings schema (brightness, ...):
// push it to the panel. // push it to the panel.
virtual void applyDisplayPrefs() {} 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 // Controlled power-down / restart (flush state first). See
// AbstractUITask::shutdown(). // AbstractUITask::shutdown().
@@ -12,7 +12,7 @@ namespace chanview {
enum : uint8_t { A_FAV, A_EDIT, A_DELETE, A_READ, A_PIN }; 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 { 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 int s_idx = -1; // channel the popup / form is about (-1: adding)
static uint8_t s_type = T_HASHTAG; 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), 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))); (int)lv_dropdown_get_selected((lv_obj_t*)lv_event_get_target(e)));
} }
static void onChanNotif(lv_event_t* e) { static void onChanSegment(lv_event_t* e) { // user data: C_NOTIF / C_MELODY
s_ui->channelSet(chanview::C_NOTIF, (int)lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e))); 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) { 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 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 // Notifications: three segments in the row (a dropdown's list ran off the
// bottom of the screen from a popup this low). // bottom of the screen from a popup this low).
static const char* NOTIF[] = { "Default", "Muted", "Always", "" }; 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* row = radioview::settingRow(panel, "Alerts", NULL);
lv_obj_t* seg = lv_buttonmatrix_create(row); radioview::rowSegmented(row, NOTIF, chanctl::notif(_prefs, idx), 200, onChanSegment, C_NOTIF);
lv_buttonmatrix_set_map(seg, NOTIF); row = radioview::settingRow(panel, "Sound", NULL);
lv_buttonmatrix_set_button_ctrl_all(seg, LV_BUTTONMATRIX_CTRL_CHECKABLE); radioview::rowSegmented(row, MELODY, chanctl::melody(_prefs, idx), 200, onChanSegment, C_MELODY);
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);
const ScopeList& sl = the_mesh.scopeList(); const ScopeList& sl = the_mesh.scopeList();
if (sl.count > 0) { // only once regions are set up (Settings in the app) if (sl.count > 0) { // only once regions are set up (Settings in the app)
row = radioview::settingRow(panel, "Scope", "Region it's sent in"); 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 (!chanctl::exists(s_idx) || !_prefs) return;
if (which == C_NOTIF) chanctl::setNotif(_prefs, s_idx, (uint8_t)v); 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_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(); the_mesh.savePrefs();
} }
@@ -299,6 +299,7 @@ void UITask::showRing(const char* text) {
} }
void UITask::dismissRing() { void UITask::dismissRing() {
stopMelody();
_core->clock.dismissRing(); _core->clock.dismissRing();
hideRing(); hideRing();
} }
@@ -73,6 +73,9 @@ static lv_obj_t* actionButton(lv_obj_t* acts, const char* text, lv_event_cb_t cb
} // namespace convview } // namespace convview
static void onConvAction(lv_event_t* e) { s_ui->conversationAction((uint8_t)(uintptr_t)lv_event_get_user_data(e)); } 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) { static void onConvNotif(lv_event_t* e) {
s_ui->conversationNotif((int)lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(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", "" }; static const char* NOTIF[] = { "Default", "Muted", "Always", "" };
lv_obj_t* row = radioview::settingRow(panel, "Alerts", NULL); lv_obj_t* row = radioview::settingRow(panel, "Alerts", NULL);
segmented(row, NOTIF, contactctl::notif(_prefs, ci.id.pub_key), 200, onConvNotif); 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 { } else {
bool in = _core->rooms.isLoggedIn(ci.id.pub_key); bool in = _core->rooms.isLoggedIn(ci.id.pub_key);
lv_obj_t* st = label(panel, in ? LV_SYMBOL_OK " Logged in: you can post" lv_obj_t* st = label(panel, in ? LV_SYMBOL_OK " Logged in: you can post"
+3 -2
View File
@@ -446,8 +446,9 @@ lv_obj_t* UITask::navPopupPanel(const char* title, bool full) {
if (full) { if (full) {
lv_obj_set_size(panel, w, lv_display_get_vertical_resolution(NULL) - theme::STATUS_H - 12); 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); 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_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_align(panel, LV_ALIGN_CENTER, 0, theme::STATUS_H / 2);
} }
lv_obj_set_style_bg_color(panel, lv_color_hex(theme::BG), 0); 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_all(panel, theme::PAD, 0);
lv_obj_set_style_pad_row(panel, 6, 0); lv_obj_set_style_pad_row(panel, 6, 0);
lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN); 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); lv_obj_t* hdr = lv_obj_create(panel);
styleSurface(hdr, theme::BG); styleSurface(hdr, theme::BG);
@@ -49,6 +49,30 @@ static lv_obj_t* rowDropdown(lv_obj_t* row, const char* opts, int sel, int width
return dd; 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 } // namespace radioview
static void onRadioDropdown(lv_event_t* e) { static void onRadioDropdown(lv_event_t* e) {
@@ -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);
}
+81 -2
View File
@@ -18,6 +18,8 @@
#include "../ui-core/Battery.h" #include "../ui-core/Battery.h"
#include "../ui-core/ChannelControl.h" #include "../ui-core/ChannelControl.h"
#include "../ui-core/BotConfig.h" #include "../ui-core/BotConfig.h"
#include "../ui-core/SoundControl.h"
#include "../ui-core/SoundNotifier.h"
#include "Theme.h" #include "Theme.h"
#include "LvglPort.h" #include "LvglPort.h"
#include "../ui-core/KeyboardData.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). // The board's one button, pressed from the simulator page (web/lvgl.html).
static bool s_sim_btn_click = false; static bool s_sim_btn_click = false;
extern "C" EMSCRIPTEN_KEEPALIVE void sim_lcd_button() { s_sim_btn_click = true; } 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 #endif
// ── Small helpers ───────────────────────────────────────────────────────────── // ── Small helpers ─────────────────────────────────────────────────────────────
@@ -137,6 +143,11 @@ void UITask::begin(DisplayDriver* display_drv, SensorManager* sensors, NodePrefs
#ifdef PIN_USER_BTN #ifdef PIN_USER_BTN
user_btn.begin(); user_btn.begin();
#endif #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_init();
lv_tick_set_cb([]() -> uint32_t { return (uint32_t)millis(); }); lv_tick_set_cb([]() -> uint32_t { return (uint32_t)millis(); });
@@ -182,6 +193,12 @@ void UITask::loop() {
_core->loop(); _core->loop();
drainCoreEvents(); 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(); checkLowBattery();
mapDownloadTick(); // a map download keeps running on any screen, and asleep 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 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 _next_clock_ms = millis() + 100; // stopwatch tenths
refreshClock(); 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) { if (_screen == SCR_THREAD && (int32_t)(millis() - _next_thread_check_ms) >= 0) {
_next_thread_check_ms = millis() + 500; _next_thread_check_ms = millis() + 500;
uint32_t sig = threadSignature(); uint32_t sig = threadSignature();
@@ -233,6 +254,12 @@ void UITask::shutdown(bool restart) {
the_mesh.saveRTCTime(); the_mesh.saveRTCTime();
the_mesh.flushDirtyContacts(); the_mesh.flushDirtyContacts();
_core->trail.onShutdown(); _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) { if (restart) {
_board->reboot(); _board->reboot();
} else { } else {
@@ -296,8 +323,10 @@ void UITask::drainCoreEvents() {
break; break;
case UiEventType::ClockAlert: case UiEventType::ClockAlert:
showRing(ev.text); showRing(ev.text);
playMelody(soundctl::MEL_ALARM);
break; break;
case UiEventType::ClockRingEnded: case UiEventType::ClockRingEnded:
stopMelody();
hideRing(); hideRing();
break; break;
case UiEventType::LiveShareEnded: case UiEventType::LiveShareEnded:
@@ -305,6 +334,15 @@ void UITask::drainCoreEvents() {
break; break;
case UiEventType::LocatorCrossed: case UiEventType::LocatorCrossed:
showToast(ev.text, 3000); 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; break;
default: default:
break; break;
@@ -313,6 +351,12 @@ void UITask::drainCoreEvents() {
} }
void UITask::onMessageArrived(const UiEvent& ev) { 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]; char buf[48];
snprintf(buf, sizeof(buf), "Msg: %.20s", ev.text); snprintf(buf, sizeof(buf), "Msg: %.20s", ev.text);
// Wake for the message unless an app is already showing it, or the user // 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; case battery::VOLTAGE: snprintf(level, sizeof(level), " %u.%02u V", mv / 1000, (mv % 1000) / 10); break;
default: 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); hasConnection() ? LV_SYMBOL_BLUETOOTH " " : "", batt, level);
// GPS: green with a fix, muted while on and searching, absent when off. // GPS: green with a fix, muted while on and searching, absent when off.
int32_t lat, lon; int32_t lat, lon;
@@ -513,6 +562,12 @@ void UITask::back() {
case SCR_ADMIN: if (_nav_overlay) navClosePopup(); else adminLeave(); break; case SCR_ADMIN: if (_nav_overlay) navClosePopup(); else adminLeave(); break;
case SCR_SETTINGS: if (_nav_overlay) navClosePopup(); else showHome(); break; case SCR_SETTINGS: if (_nav_overlay) navClosePopup(); else showHome(); break;
case SCR_SETTINGS_NAV: showSettings(); 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_CLOCK: showHome(); break;
case SCR_RADIO: case SCR_RADIO:
if (radioPopupOpen()) radioCloseFreq(); 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 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 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) { void UITask::showSchemaSettings(int page) {
_screen = SCR_SETTINGS_NAV; _screen = SCR_SETTINGS_NAV;
@@ -1930,8 +1988,23 @@ void UITask::buildSchemaSettings() {
for (int i = 0; i < settings::COUNT; i++) { for (int i = 0; i < settings::COUNT; i++) {
const settings::Setting& st = settings::ALL[i]; const settings::Setting& st = settings::ALL[i];
if (settings::sectionPage(st.section) != _settings_page) continue; 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); 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 if (st.offset == offsetof(NodePrefs, display_brightness)) { // a slider here instead of five steps
lv_obj_t* row = lv_obj_create(body); lv_obj_t* row = lv_obj_create(body);
styleSurface(row, theme::SURFACE); 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); _prune_lbl = label(b, LV_SYMBOL_TRASH " Remove inactive contacts now", THEME_FONT_BODY, theme::TEXT);
lv_obj_center(_prune_lbl); lv_obj_center(_prune_lbl);
} }
if (_settings_page == settings::PG_SOUND) buildSoundRows(body, false); // the melodies
} }
void UITask::setSchemaValue(int idx, int v) { void UITask::setSchemaValue(int idx, int v) {
@@ -2053,6 +2127,10 @@ void UITask::buildSettings() {
onOpenSchemaPage, (void*)(uintptr_t)settings::PG_DEVICE); onOpenSchemaPage, (void*)(uintptr_t)settings::PG_DEVICE);
listRow(body, LV_SYMBOL_ENVELOPE " Messages & contacts", "Resending, contact expiry, sorting", listRow(body, LV_SYMBOL_ENVELOPE " Messages & contacts", "Resending, contact expiry, sorting",
onOpenSchemaPage, (void*)(uintptr_t)settings::PG_MESSAGES); 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"); sectionTitle(body, "KEYBOARD");
uint8_t main_a = _prefs ? _prefs->keyboard_main_alphabet : 0; 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 "CompassScreen.h"
#include "RadioExtras.h" #include "RadioExtras.h"
#include "RepeaterScreen.h" #include "RepeaterScreen.h"
#include "SoundScreen.h"
+39 -2
View File
@@ -9,6 +9,9 @@
#include <helpers/SensorManager.h> #include <helpers/SensorManager.h>
#include <helpers/BaseSerialInterface.h> #include <helpers/BaseSerialInterface.h>
#include <lvgl.h> #include <lvgl.h>
#ifdef PIN_BUZZER
#include <helpers/ui/buzzer.h>
#endif
#include "../AbstractUITask.h" #include "../AbstractUITask.h"
#include "../NodePrefs.h" #include "../NodePrefs.h"
@@ -24,7 +27,7 @@ public:
void begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs); void begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs);
void loop() override; 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; void shutdown(bool restart = false) override;
MyMesh::Listener* meshListener() override; MyMesh::Listener* meshListener() override;
@@ -33,6 +36,7 @@ public:
bool isViewingDM(const uint8_t* pub_key) override; bool isViewingDM(const uint8_t* pub_key) override;
void onViewedHistoryGrew(bool channel) override { (void)channel; _thread_dirty = true; } void onViewedHistoryGrew(bool channel) override { (void)channel; _thread_dirty = true; }
void botSetGPS(bool on) override; void botSetGPS(bool on) override;
void applySoundPrefs() override;
// ── Navigation (called from LVGL event callbacks) ──────────────────────── // ── Navigation (called from LVGL event callbacks) ────────────────────────
void showHome(); void showHome();
@@ -104,6 +108,26 @@ public:
void clockAction(uint8_t act); void clockAction(uint8_t act);
void setAlarm(int which, int v); void setAlarm(int which, int v);
void dismissRing(); 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) // Settings > Radio (RadioScreen.h)
void showRadio(); void showRadio();
void radioSet(int which, int v); void radioSet(int which, int v);
@@ -185,7 +209,7 @@ public:
void openAdminFor(const ContactInfo& ci, bool from_picker) { (void)ci; (void)from_picker; } void openAdminFor(const ContactInfo& ci, bool from_picker) { (void)ci; (void)from_picker; }
private: 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 buildStatusBar();
void refreshStatusBar(); void refreshStatusBar();
@@ -243,6 +267,10 @@ private:
void buildScopes(); void buildScopes();
void buildRepeater(); void buildRepeater();
void rebuildRepeater(); void rebuildRepeater();
void buildSoundRows(lv_obj_t* body, bool top);
void buildMelodies();
void refreshMelody();
void melodySave();
void radioCloseFreq(); void radioCloseFreq();
void buildChannelEdit(); void buildChannelEdit();
void buildAdmin(); void buildAdmin();
@@ -274,6 +302,15 @@ private:
uint32_t autoOffMillis() const; uint32_t autoOffMillis() const;
void checkLowBattery(); 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; DisplayDriver* _display = nullptr;
SensorManager* _sensors = nullptr; SensorManager* _sensors = nullptr;
NodePrefs* _prefs = nullptr; NodePrefs* _prefs = nullptr;
@@ -835,32 +835,6 @@ class MessagesScreen : public UIScreen {
return p && (p->ch_fav_bitmask & (1ULL << ch_idx)) != 0; 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. // 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); } 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); } 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); } 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); } 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). // Channel melody (ui-core/ChannelControl.h): 0 = global, 1 / 2 = melody 1 / 2.
uint8_t chNotifMelody(uint8_t ch_idx) const { uint8_t chNotifMelody(uint8_t ch_idx) const { return chanctl::melody(_task->getNodePrefs(), ch_idx); }
NodePrefs* p = _task->getNodePrefs(); void setChNotifMelody(uint8_t ch_idx, uint8_t slot) { chanctl::setMelody(_task->getNodePrefs(), ch_idx, slot); }
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);
}
uint8_t dmMelodySlot(const uint8_t* pub_key) const { return contactctl::melody(_task->getNodePrefs(), pub_key); } 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); } void setDmMelody(const uint8_t* pub_key, uint8_t slot) { contactctl::setMelody(_task->getNodePrefs(), pub_key, slot); }
@@ -6,20 +6,17 @@ class RingtoneEditorScreen : public UIScreen {
UITask* _task; UITask* _task;
NodePrefs* _prefs; 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 // Menu item indices
enum MenuIdx { MI_PLAY=0, MI_SWITCH, MI_DURATION, MI_BPM, MI_INSERT, MI_DELETE, MI_SAVE, MI_DISCARD, MI_COUNT }; 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() int _visible_notes = 7; // updated in render(); used by clampScroll()
static const uint16_t BPM_OPTS[5]; soundctl::Melody _mel;
static const char* DUR_LABELS[4]; uint8_t* const _notes = _mel.notes;
static const char PITCH_NAMES[8]; // lowercase rtttl names uint8_t& _len = _mel.len;
uint8_t& _bpm_idx = _mel.bpm_idx;
uint8_t _notes[MAX_NOTES];
uint8_t _len;
uint8_t _bpm_idx;
int _slot; // 0=melody1, 1=melody2 int _slot; // 0=melody1, 1=melody2
int _cursor; int _cursor;
int _scroll; int _scroll;
@@ -30,12 +27,10 @@ class RingtoneEditorScreen : public UIScreen {
char _menu_dur_label[16]; char _menu_dur_label[16];
char _menu_bpm_label[10]; char _menu_bpm_label[10];
static uint8_t notePitch(uint8_t b) { return b & 0x07; } static uint8_t notePitch(uint8_t b) { return soundctl::notePitch(b); }
static uint8_t noteOctave(uint8_t b) { return ((b >> 3) & 0x03) + 4; } static uint8_t noteOctave(uint8_t b) { return soundctl::noteOctave(b); }
static uint8_t noteDurIdx(uint8_t b) { return (b >> 5) & 0x03; } 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) { static uint8_t packNote(uint8_t pitch, uint8_t octave, uint8_t dur_idx) { return soundctl::packNote(pitch, octave, dur_idx); }
return (pitch & 0x07) | (((octave - 4) & 0x03) << 3) | ((dur_idx & 0x03) << 5);
}
void clampScroll() { void clampScroll() {
if (_cursor < _scroll) _scroll = _cursor; if (_cursor < _scroll) _scroll = _cursor;
@@ -43,14 +38,11 @@ class RingtoneEditorScreen : public UIScreen {
if (_scroll < 0) _scroll = 0; if (_scroll < 0) _scroll = 0;
} }
void buildRTTTL() { void buildRTTTL() { soundctl::toRtttl(_mel, _play_buf, sizeof(_play_buf)); }
NodePrefs::buildRTTTLString(_notes, _len, _bpm_idx, _play_buf, sizeof(_play_buf));
}
void previewNote(uint8_t note_byte) { void previewNote(uint8_t note_byte) {
uint8_t pitch = notePitch(note_byte); soundctl::notePreview(note_byte, _play_buf, sizeof(_play_buf));
if (pitch == 0) { _task->stopMelody(); return; } if (!_play_buf[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));
_task->playMelody(_play_buf); _task->playMelody(_play_buf);
} }
@@ -61,12 +53,7 @@ public:
// setCurrScreen() (whose onShow() can't carry the slot argument). // setCurrScreen() (whose onShow() can't carry the slot argument).
void selectSlot(int slot = 0) { void selectSlot(int slot = 0) {
_slot = (slot == 1) ? 1 : 0; _slot = (slot == 1) ? 1 : 0;
bool s2 = (_slot == 1); soundctl::load(_prefs, _slot, _mel);
_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));
_cursor = 0; _cursor = 0;
_scroll = 0; _scroll = 0;
_menu.active = false; _menu.active = false;
@@ -76,8 +63,8 @@ public:
snprintf(_menu_play_label, sizeof(_menu_play_label), "%s", _task->isMelodyPlaying() ? "Stop" : "Play"); 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); snprintf(_menu_slot_label, sizeof(_menu_slot_label), "Melody %d", (_slot == 0) ? 2 : 1);
uint8_t di = (_cursor < _len) ? noteDurIdx(_notes[_cursor]) : 0; uint8_t di = (_cursor < _len) ? noteDurIdx(_notes[_cursor]) : 0;
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));
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));
_menu.begin("Options", 5); _menu.begin("Options", 5);
_menu.addItem(_menu_play_label); _menu.addItem(_menu_play_label);
_menu.addItem(_menu_slot_label); _menu.addItem(_menu_slot_label);
@@ -101,7 +88,7 @@ public:
_visible_notes = display.width() / cell_w; _visible_notes = display.width() / cell_w;
char hdr[32]; 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.setCursor(0, 0);
display.print(hdr); display.print(hdr);
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH()); display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
@@ -117,7 +104,7 @@ public:
if (pitch == 0) { if (pitch == 0) {
label[0] = '-'; label[1] = '-'; label[2] = '\0'; label[0] = '-'; label[1] = '-'; label[2] = '\0';
} else { } else {
label[0] = PITCH_NAMES[pitch] - 32; // uppercase label[0] = soundctl::pitchChar(pitch) - 32; // uppercase
label[1] = '0' + octave; label[1] = '0' + octave;
label[2] = '\0'; label[2] = '\0';
} }
@@ -147,7 +134,7 @@ public:
if (_cursor < _len) { if (_cursor < _len) {
char info[24]; char info[24];
snprintf(info, sizeof(info), "oct:%d dur:%s", 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.setCursor(display.getCharWidth() + 2, info_y);
display.print(info); display.print(info);
} else if (_cursor == _len) { } else if (_cursor == _len) {
@@ -170,11 +157,11 @@ public:
uint8_t di = noteDurIdx(_notes[_cursor]); uint8_t di = noteDurIdx(_notes[_cursor]);
di = (dir > 0) ? (di + 1) & 0x03 : (di + 3) & 0x03; di = (dir > 0) ? (di + 1) & 0x03 : (di + 3) & 0x03;
_notes[_cursor] = packNote(p, o, di); _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) { } else if (sel == MI_BPM) {
if (dir > 0) { if (_bpm_idx < 4) _bpm_idx++; } if (dir > 0) { if (_bpm_idx < 4) _bpm_idx++; }
else { if (_bpm_idx > 0) _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; break;
case MI_SAVE: case MI_SAVE:
if (_prefs) { if (_prefs) {
if (_slot == 1) { soundctl::store(_prefs, _slot, _mel);
_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));
}
the_mesh.savePrefs(); the_mesh.savePrefs();
} }
_task->stopMelody(); _task->stopMelody();
@@ -294,7 +273,3 @@ public:
return false; 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' };
+17 -26
View File
@@ -1,5 +1,6 @@
#include "UITask.h" #include "UITask.h"
#include "SoundNotifier.h" #include "../ui-core/SoundNotifier.h"
#include "../ui-core/SoundControl.h"
#include <helpers/TxtDataHelpers.h> #include <helpers/TxtDataHelpers.h>
#include "../MyMesh.h" #include "../MyMesh.h"
#include "../MsgExpand.h" #include "../MsgExpand.h"
@@ -1667,9 +1668,9 @@ void UITask::gotoGpioScreen() {
void UITask::gotoLiveShareScreen() { setCurrScreen(live_share_screen); } void UITask::gotoLiveShareScreen() { setCurrScreen(live_share_screen); }
// ── Clock tools (engine in ui-core/ClockEngine.h) ─────────────────────────── // ── Clock tools (engine in ui-core/ClockEngine.h) ───────────────────────────
// The melody overrides mute (playMelody → buzzer.playForced); the ring // The melody (soundctl::MEL_ALARM) overrides mute (playMelody →
// auto-stops after ClockEngine::RING_MS if no key dismisses it (see loop()). // buzzer.playForced); the ring auto-stops after ClockEngine::RING_MS if no key
static const char* CLOCK_ALARM_MELODY = "alarm:d=8,o=6,b=125:c,c,c,c,p,c,c,c,c,p"; // dismisses it (see loop()).
void UITask::wakeForAlarm() { void UITask::wakeForAlarm() {
if (_display != NULL) _display->turnOn(); if (_display != NULL) _display->turnOn();
@@ -1693,7 +1694,7 @@ void UITask::tickCore() {
_core->loop(); _core->loop();
drainCoreEvents(); drainCoreEvents();
// Repeat the ring melody until dismissed or the ring window elapses. // 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() { void UITask::drainCoreEvents() {
@@ -1703,7 +1704,7 @@ void UITask::drainCoreEvents() {
case UiEventType::ClockAlert: case UiEventType::ClockAlert:
wakeForAlarm(); wakeForAlarm();
showAlert(ev.text, ClockEngine::RING_MS); showAlert(ev.text, ClockEngine::RING_MS);
playMelody(CLOCK_ALARM_MELODY); playMelody(soundctl::MEL_ALARM);
break; break;
case UiEventType::ClockRingEnded: case UiEventType::ClockRingEnded:
stopMelody(); stopMelody();
@@ -1715,10 +1716,10 @@ void UITask::drainCoreEvents() {
case UiEventType::LocatorCrossed: case UiEventType::LocatorCrossed:
showAlert(ev.text, 3000); showAlert(ev.text, 3000);
if (!isBuzzerQuiet()) 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; break;
case UiEventType::LocatorBeep: case UiEventType::LocatorBeep:
playMelody("locp:d=32,o=7,b=200:c"); playMelody(soundctl::MEL_TICK);
break; break;
case UiEventType::MessageArrived: case UiEventType::MessageArrived:
onMessageArrived(ev); onMessageArrived(ev);
@@ -2681,13 +2682,8 @@ void UITask::loop() {
userLedHandler(); userLedHandler();
#ifdef PIN_BUZZER #ifdef PIN_BUZZER
if (_node_prefs && _node_prefs->buzzer_auto) { if (soundctl::autoTick(_node_prefs, buzzer, isClientConnected())) // BLE bonded or an open USB port
bool should_quiet = isClientConnected(); // BLE bonded or an open USB port _next_refresh = 0;
if (buzzer.isQuiet() != should_quiet) {
buzzer.quiet(should_quiet);
_next_refresh = 0;
}
}
if (buzzer.isPlaying()) buzzer.loop(); if (buzzer.isPlaying()) buzzer.loop();
#endif #endif
@@ -3257,10 +3253,7 @@ void UITask::setBrightnessLevel(uint8_t level) {
void UITask::setBuzzerVolumeLevel(uint8_t level) { void UITask::setBuzzerVolumeLevel(uint8_t level) {
#ifdef PIN_BUZZER #ifdef PIN_BUZZER
if (_node_prefs == NULL) return; if (_node_prefs == NULL) return;
if (level > 4) level = 4; soundctl::setVolume(_node_prefs, buzzer, level);
_node_prefs->buzzer_volume = level;
buzzer.setVolume(level);
if (level > 0) buzzer.playForced("Vol:d=16,o=6,b=120:c");
_next_refresh = 0; _next_refresh = 0;
#endif #endif
} }
@@ -3283,8 +3276,8 @@ void UITask::toggleBuzzer() {
int UITask::getBuzzerMode() { int UITask::getBuzzerMode() {
#ifdef PIN_BUZZER #ifdef PIN_BUZZER
if (_node_prefs && _node_prefs->buzzer_auto) return 2; if (_node_prefs && _node_prefs->buzzer_auto) return soundctl::MODE_AUTO;
return buzzer.isQuiet() ? 1 : 0; return buzzer.isQuiet() ? soundctl::MODE_OFF : soundctl::MODE_ON;
#else #else
return 1; return 1;
#endif #endif
@@ -3294,11 +3287,9 @@ void UITask::cycleBuzzerMode() {
#ifdef PIN_BUZZER #ifdef PIN_BUZZER
if (!_node_prefs) return; if (!_node_prefs) return;
int mode = getBuzzerMode(); int mode = getBuzzerMode();
mode = (mode + 1) % 3; // ON(0) → OFF(1) → Auto(2) → ON mode = (mode + 1) % soundctl::MODE_COUNT; // ON → OFF → Auto → ON
_node_prefs->buzzer_auto = (mode == 2) ? 1 : 0; soundctl::setMode(_node_prefs, buzzer, (uint8_t)mode, isClientConnected());
if (mode == 0) { buzzer.quiet(false); _node_prefs->buzzer_quiet = 0; notify(UIEventType::ack); } if (mode == soundctl::MODE_ON) notify(UIEventType::ack);
if (mode == 1) { buzzer.quiet(true); _node_prefs->buzzer_quiet = 1; }
if (mode == 2) { buzzer.quiet(isClientConnected()); }
static const char* labels[] = { "Buzzer: ON", "Buzzer: OFF", "Buzzer: Auto" }; static const char* labels[] = { "Buzzer: ON", "Buzzer: OFF", "Buzzer: Auto" };
showAlert(labels[mode], 800); showAlert(labels[mode], 800);
_next_refresh = 0; _next_refresh = 0;
+3
View File
@@ -55,10 +55,13 @@ build_flags =
-D MESHCORE_VERSION='"1.17.1"' -D MESHCORE_VERSION='"1.17.1"'
-D AUTO_OFF_MILLIS=60000 -D AUTO_OFF_MILLIS=60000
-D MESH_IN_PSRAM=1 ; frees ~120 KB internal RAM for WiFi/TLS map downloads -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} build_src_filter = ${Wio_Tracker_L2.build_src_filter}
+<helpers/esp32/*.cpp> +<helpers/esp32/*.cpp>
+<helpers/ui/LGFXDisplay.cpp> +<helpers/ui/LGFXDisplay.cpp>
+<helpers/ui/MomentaryButton.cpp> +<helpers/ui/MomentaryButton.cpp>
+<helpers/ui/buzzer.cpp>
+<../examples/companion_radio/*.cpp> +<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-lvgl/*.cpp> +<../examples/companion_radio/ui-lvgl/*.cpp>
+<../examples/companion_radio/ui-lvgl/fonts/*.c> +<../examples/companion_radio/ui-lvgl/fonts/*.c>
+45
View File
@@ -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 <Arduino.h>
#include <Wire.h>
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
+290 -5
View File
@@ -8,7 +8,7 @@ void genericBuzzer::begin() {
// file included) activate at all; variants/sim/arduino/Arduino.h // file included) activate at all; variants/sim/arduino/Arduino.h
// deliberately has no pinMode()/digitalWrite() shim since nothing else // deliberately has no pinMode()/digitalWrite() shim since nothing else
// ever needed one before this. // ever needed one before this.
#ifndef SIM_PLATFORM #if !defined(SIM_PLATFORM) && !defined(BUZZER_I2S)
#ifdef PIN_BUZZER_EN #ifdef PIN_BUZZER_EN
pinMode(PIN_BUZZER_EN, OUTPUT); pinMode(PIN_BUZZER_EN, OUTPUT);
digitalWrite(PIN_BUZZER_EN, HIGH); digitalWrite(PIN_BUZZER_EN, HIGH);
@@ -29,6 +29,9 @@ void genericBuzzer::begin() {
NVIC_SetPriority(TIMER1_IRQn, 7); NVIC_SetPriority(TIMER1_IRQn, 7);
NVIC_ClearPendingIRQ(TIMER1_IRQn); NVIC_ClearPendingIRQ(TIMER1_IRQn);
NVIC_EnableIRQ(TIMER1_IRQn); NVIC_EnableIRQ(TIMER1_IRQn);
#endif
#if defined(BUZZER_I2S)
if (!_i2sBegin()) return; // no codec: stay silent
#endif #endif
startup(); 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 // the NRF52 direct-PWM player and the sim's poll-only player (below) reuse
// it verbatim instead of each carrying their own copy. // 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 // 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 }; 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; 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() // nRF52 path — direct NRF_PWM2 control, bypasses tone()
@@ -216,6 +221,7 @@ void genericBuzzer::_timer1ISR() {
void genericBuzzer::_nrfBegin(const char* melody) { void genericBuzzer::_nrfBegin(const char* melody) {
_disarmNoteTimer(); // drop any in-flight/pending note advance before reconfiguring _disarmNoteTimer(); // drop any in-flight/pending note advance before reconfiguring
_nrfStopPwm(); _nrfStopPwm();
_note_idx = -1;
if (!melody || !*melody) { _rtttl_done = true; return; } if (!melody || !*melody) { _rtttl_done = true; return; }
const char* notes; const char* notes;
_parseHeader(melody, _def_dur, _def_oct, _def_bpm, notes); _parseHeader(melody, _def_dur, _def_oct, _def_bpm, notes);
@@ -227,10 +233,12 @@ void genericBuzzer::_nrfBegin(const char* melody) {
void genericBuzzer::_nrfAdvance() { void genericBuzzer::_nrfAdvance() {
uint16_t freq; uint32_t dur_ms; uint16_t freq; uint32_t dur_ms;
if (_parseNext(_rtttl_pos, _def_dur, _def_oct, _def_bpm, freq, dur_ms)) { if (_parseNext(_rtttl_pos, _def_dur, _def_oct, _def_bpm, freq, dur_ms)) {
_note_idx++;
_armNoteTimer(dur_ms); _armNoteTimer(dur_ms);
if (freq > 0) _nrfStartPwm(freq); else _nrfStopPwm(); if (freq > 0) _nrfStartPwm(freq); else _nrfStopPwm();
} else { } else {
_nrfStopPwm(); _nrfStopPwm();
_note_idx = -1;
_rtttl_done = true; _rtttl_done = true;
} }
} }
@@ -257,6 +265,7 @@ bool genericBuzzer::isPlaying() { return !_rtttl_done; }
void genericBuzzer::stop() { void genericBuzzer::stop() {
_disarmNoteTimer(); // ensure no latched note-advance fires after we stop _disarmNoteTimer(); // ensure no latched note-advance fires after we stop
_nrfStopPwm(); _nrfStopPwm();
_note_idx = -1;
_rtttl_done = true; _rtttl_done = true;
} }
@@ -288,9 +297,11 @@ void genericBuzzer::setVolume(uint8_t level) {
void genericBuzzer::_advance() { void genericBuzzer::_advance() {
uint16_t freq; uint32_t dur_ms; uint16_t freq; uint32_t dur_ms;
if (_parseNext(_rtttl_pos, _def_dur, _def_oct, _def_bpm, freq, dur_ms)) { if (_parseNext(_rtttl_pos, _def_dur, _def_oct, _def_bpm, freq, dur_ms)) {
_note_idx++;
_cur_freq = freq; _cur_freq = freq;
_note_end_ms = millis() + dur_ms; _note_end_ms = millis() + dur_ms;
} else { } else {
_note_idx = -1;
_cur_freq = 0; _cur_freq = 0;
_rtttl_done = true; _rtttl_done = true;
} }
@@ -309,6 +320,7 @@ void genericBuzzer::play(const char* melody) {
void genericBuzzer::playForced(const char* melody) { void genericBuzzer::playForced(const char* melody) {
_rtttl_done = true; _rtttl_done = true;
_cur_freq = 0; _cur_freq = 0;
_note_idx = -1;
if (!melody || !*melody) return; if (!melody || !*melody) return;
const char* notes; const char* notes;
_parseHeader(melody, _def_dur, _def_oct, _def_bpm, notes); _parseHeader(melody, _def_dur, _def_oct, _def_bpm, notes);
@@ -322,6 +334,7 @@ bool genericBuzzer::isPlaying() { return !_rtttl_done; }
void genericBuzzer::stop() { void genericBuzzer::stop() {
_rtttl_done = true; _rtttl_done = true;
_cur_freq = 0; _cur_freq = 0;
_note_idx = -1;
} }
void genericBuzzer::loop() { void genericBuzzer::loop() {
@@ -333,7 +346,277 @@ void genericBuzzer::setVolume(uint8_t level) {
_volume_level = level < 5 ? level : 4; _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 <driver/i2s.h>
#include <esp_heap_caps.h>
#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 // 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(); } bool genericBuzzer::isPlaying() { return rtttl::isPlaying(); }
int genericBuzzer::noteIndex() const { return -1; } // the library doesn't say
void genericBuzzer::stop() { rtttl::stop(); } void genericBuzzer::stop() { rtttl::stop(); }
void genericBuzzer::loop() { void genericBuzzer::loop() {
@@ -373,6 +658,6 @@ void genericBuzzer::setVolume(uint8_t level) {
if (isPlaying()) applyVolume(); if (isPlaying()) applyVolume();
} }
#endif // NRF52_PLATFORM / SIM_PLATFORM #endif // NRF52_PLATFORM / SIM_PLATFORM / BUZZER_I2S
#endif // PIN_BUZZER #endif // PIN_BUZZER
+36 -2
View File
@@ -5,10 +5,17 @@
// NRF52 (and the sim, see buzzer.cpp) use a custom non-blocking RTTTL // NRF52 (and the sim, see buzzer.cpp) use a custom non-blocking RTTTL
// player; only the remaining platforms pull in the NonBlockingRtttl library // player; only the remaining platforms pull in the NonBlockingRtttl library
// here. // here.
#if !defined(NRF52_PLATFORM) && !defined(SIM_PLATFORM) #if !defined(NRF52_PLATFORM) && !defined(SIM_PLATFORM) && !defined(BUZZER_I2S)
#include <NonBlockingRtttl.h> #include <NonBlockingRtttl.h>
#endif #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 /* class abstracts underlying RTTTL library
Just a simple implementation to start. At the moment use same Just a simple implementation to start. At the moment use same
@@ -32,6 +39,9 @@ class genericBuzzer
void startup(); void startup();
void shutdown(); void shutdown();
bool isPlaying(); 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); void quiet(bool buzzer_state);
bool isQuiet(); bool isQuiet();
void setVolume(uint8_t level); 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"; const char *shutdown_song = "Shutdown:d=4,o=5,b=100:8g5,16e5,16c5";
bool _is_quiet = true; 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 // Shared RTTTL cursor state + parser, reused by both the NRF52
// direct-PWM player below and the sim's poll-only player (buzzer.cpp, // direct-PWM player below and the sim's poll-only player (buzzer.cpp,
// #elif defined(SIM_PLATFORM)) -- the parser itself never touches // #elif defined(SIM_PLATFORM)) -- the parser itself never touches
@@ -56,6 +66,7 @@ class genericBuzzer
uint8_t _def_dur = 4; uint8_t _def_dur = 4;
uint8_t _def_oct = 5; uint8_t _def_oct = 5;
uint16_t _def_bpm = 120; uint16_t _def_bpm = 120;
volatile int16_t _note_idx = -1;
static uint16_t _noteFreq(char letter, bool sharp, uint8_t octave); 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, 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; } uint16_t currentFreqHz() const { return _cur_freq; }
private: private:
uint16_t _cur_freq = 0; 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 #endif
}; };
+38 -1
View File
@@ -224,8 +224,45 @@
const y = (e.clientY - r.top - by) * 240 / canvas.clientHeight; 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))]; 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; 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); }); canvas.addEventListener('pointermove', (e) => { if (pressed) touch(...toDevice(e), true); });
const lift = (e) => { if (!pressed) return; pressed = false; touch(...toDevice(e), false); }; const lift = (e) => { if (!pressed) return; pressed = false; touch(...toDevice(e), false); };
canvas.addEventListener('pointerup', lift); canvas.addEventListener('pointerup', lift);
+8
View File
@@ -36,6 +36,14 @@ build_flags = ${esp32_base.build_flags}
-D PIN_GPS_TX=18 ; L76K TX -> ESP32 RX -D PIN_GPS_TX=18 ; L76K TX -> ESP32 RX
-D PIN_GPS_RX=17 ; L76K RX <- ESP32 TX -D PIN_GPS_RX=17 ; L76K RX <- ESP32 TX
-D GPS_BAUD_RATE=9600 -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 +<helpers/ui/buzzer.cpp>.
-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 -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 ; disable ALL env sensor I2C probes: this bus carries six onboard chips
; (TCA9535 0x21, AW35615 0x22, LP5814 0x2C, ADS1115 0x48, GT911 0x5D, ; (TCA9535 0x21, AW35615 0x22, LP5814 0x2C, ADS1115 0x48, GT911 0x5D,
+4
View File
@@ -18,6 +18,10 @@ EnvironmentSensorManager sensors(gps);
MomentaryButton user_btn(PIN_USER_BTN, 1000, true, true); MomentaryButton user_btn(PIN_USER_BTN, 1000, true, true);
#endif #endif
#ifdef BUZZER_I2S
void buzzerAmpPower(bool on) { board.setSpeakerAmp(on); }
#endif
bool radio_init() { bool radio_init() {
MESH_DEBUG_PRINTLN("radio_init: rtc + sx1262 init"); MESH_DEBUG_PRINTLN("radio_init: rtc + sx1262 init");
fallback_clock.begin(); fallback_clock.begin();