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
@@ -171,6 +171,21 @@ static void setNotif(NodePrefs* p, uint8_t idx, uint8_t state) {
if (state == NOTIF_MUTED) p->ch_notif_muted |= m; else p->ch_notif_muted &= ~m;
}
// Melody slot: 0 = the global channel sound, 1 / 2 = the user melodies.
static uint8_t melody(const NodePrefs* p, uint8_t idx) {
uint64_t m = 1ULL << idx;
if (!p || !(p->ch_notif_melody_set & m)) return 0;
return (p->ch_notif_melody_2 & m) ? 2 : 1;
}
static void setMelody(NodePrefs* p, uint8_t idx, uint8_t slot) {
if (!p) return;
uint64_t m = 1ULL << idx;
if (slot == 0) { p->ch_notif_melody_set &= ~m; p->ch_notif_melody_2 &= ~m; return; }
p->ch_notif_melody_set |= m;
if (slot == 2) p->ch_notif_melody_2 |= m; else p->ch_notif_melody_2 &= ~m;
}
static bool favourite(const NodePrefs* p, uint8_t idx) { return p && (p->ch_fav_bitmask & (1ULL << idx)); }
static void setFavourite(NodePrefs* p, uint8_t idx, bool on) {
@@ -14,29 +14,33 @@
#include <stddef.h>
#include "../NodePrefs.h"
#include "../Trail.h"
#include "SoundControl.h"
namespace settings {
// Pages (one screen each) and the sections on them, in display order.
enum Page : uint8_t { PG_NAV, PG_DEVICE, PG_MESSAGES, PG_COUNT };
enum Page : uint8_t { PG_NAV, PG_DEVICE, PG_MESSAGES, PG_SOUND, PG_COUNT };
enum Section : uint8_t { SEC_TRAIL, SEC_LIVE_SHARE, SEC_LOCATOR, SEC_UNITS,
SEC_DISPLAY, SEC_POWER, SEC_TIME,
SEC_MESSAGES, SEC_CONTACTS, SEC_COUNT };
SEC_MESSAGES, SEC_CONTACTS,
SEC_SOUND, SEC_SOUND_FOR, SEC_COUNT };
static const char* pageTitle(uint8_t p) {
static const char* T[PG_COUNT] = { "Trail, live share, alerts", "Display & power", "Messages & contacts" };
static const char* T[PG_COUNT] = { "Trail, live share, alerts", "Display & power", "Messages & contacts", "Sound" };
return p < PG_COUNT ? T[p] : "";
}
static const char* sectionTitle(uint8_t s) {
static const char* T[SEC_COUNT] = { "TRAIL", "LIVE SHARE", "ARRIVAL ALERT", "UNITS",
"DISPLAY", "POWER", "TIME",
"MESSAGES", "CONTACTS" };
"MESSAGES", "CONTACTS",
"SOUND", "PLAYS FOR" };
return s < SEC_COUNT ? T[s] : "";
}
static uint8_t sectionPage(uint8_t s) {
static const uint8_t P[SEC_COUNT] = { PG_NAV, PG_NAV, PG_NAV, PG_NAV,
PG_DEVICE, PG_DEVICE, PG_DEVICE,
PG_MESSAGES, PG_MESSAGES };
PG_MESSAGES, PG_MESSAGES,
PG_SOUND, PG_SOUND };
return s < SEC_COUNT ? P[s] : PG_NAV;
}
@@ -126,12 +130,21 @@ static void optBattDisplay(uint8_t v, char* b, int n, const NodePrefs&) {
static void optExpiry(uint8_t v, char* b, int n, const NodePrefs&) {
snprintf(b, n, "%s", NodePrefs::contactExpiryLabel(v));
}
static void optVolume(uint8_t v, char* b, int n, const NodePrefs&) {
static const char* L[5] = { "Quietest", "Quiet", "Medium", "Loud", "Loudest" };
snprintf(b, n, "%s", L[v < 5 ? v : 4]);
}
static void optSound(uint8_t v, char* b, int n, const NodePrefs&) { snprintf(b, n, "%s", soundctl::soundLabel(v)); }
static void optAdvertScope(uint8_t v, char* b, int n, const NodePrefs&) {
snprintf(b, n, "%s", v == ADVERT_SOUND_SCOPE_ZERO_HOP ? "Direct only" : "All");
}
// ── Side effects ──────────────────────────────────────────────────────────────
static void rearmLocator(UiCore& c) { c.locator.reset(); }
static void restartShareClock(UiCore& c) { c.live_share.restartClock(); } // a new length starts over
static void applyDisplay(UiCore& c) { c.host()->applyDisplayPrefs(); }
static void applyGpsDuty(UiCore& c) { c.applyGpsInterval(); }
static void applySound(UiCore& c) { c.host()->applySoundPrefs(); }
#define NP_OFF(f) (uint16_t)offsetof(NodePrefs, f)
#define NP_SIZE(f) (uint8_t)sizeof(((NodePrefs*)0)->f)
@@ -190,6 +203,14 @@ static const Setting ALL[] = {
IDX("Contact expiry", "Inactive this long can be pruned", SEC_CONTACTS, contact_expiry_idx,
NodePrefs::CONTACT_EXPIRY_COUNT, optExpiry, nullptr),
MAP("Favourites first", "In contact and node lists", SEC_CONTACTS, fav_sort_off, INVERTED, nullptr, nullptr),
// On / Off / Auto spans two fields (soundctl::setMode): the frontend's own row.
IDX("Volume", nullptr, SEC_SOUND, buzzer_volume, 5, optVolume, applySound),
IDX("Direct messages", nullptr, SEC_SOUND_FOR, notif_melody_dm, soundctl::SOUND_COUNT, optSound, nullptr),
IDX("Channels", nullptr, SEC_SOUND_FOR, notif_melody_ch, soundctl::SOUND_COUNT, optSound, nullptr),
IDX("Adverts", "A node announcing itself", SEC_SOUND_FOR, notif_melody_ad, soundctl::SOUND_COUNT, optSound, nullptr),
IDX("Advert sound for", "Direct: no repeaters", SEC_SOUND_FOR, advert_sound_scope, 2, optAdvertScope, nullptr),
};
#undef IDX
#undef SW
@@ -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
@@ -0,0 +1,99 @@
#pragma once
// Which sound an incoming message / advert plays, shared by ui-new's and
// ui-lvgl's notify(): the global Sound settings, the per-contact / per-channel
// alert (default / muted / always) and melody overrides, the user melodies
// (slot 1 / 2) with a built-in fallback. Kept out of UITask::notify() itself so
// upstream changes there don't conflict.
#ifdef PIN_BUZZER
#include <helpers/ui/buzzer.h>
#include "../NodePrefs.h"
class SoundNotifier {
genericBuzzer& _buz;
const NodePrefs* _prefs;
char* _mel_buf;
int _mel_buf_sz;
static void buildMelody(const NodePrefs* p, int slot, char* buf, int size) {
const uint8_t* notes = (slot == 2) ? p->ringtone2_notes : p->ringtone_notes;
uint8_t len = (slot == 2) ? p->ringtone2_len : p->ringtone_len;
uint8_t bpm_i = (slot == 2) ? p->ringtone2_bpm_idx : p->ringtone_bpm_idx;
NodePrefs::buildRTTTLString(notes, len, bpm_i, buf, size);
}
// Play custom slot (1/2) or fall back to built-in RTTTL. Slot 3 = explicit silence.
void playSlot(int slot, bool force, const char* fallback) {
if (slot == 3) return;
if (slot > 0 && _prefs) {
if (_buz.isPlaying()) _buz.stop();
buildMelody(_prefs, slot, _mel_buf, _mel_buf_sz);
if (_mel_buf[0]) {
if (force) _buz.playForced(_mel_buf); else _buz.play(_mel_buf);
return;
}
}
if (force) _buz.playForced(fallback); else _buz.play(fallback);
}
public:
SoundNotifier(genericBuzzer& buz, const NodePrefs* prefs, char* buf, int sz)
: _buz(buz), _prefs(prefs), _mel_buf(buf), _mel_buf_sz(sz) {}
void playDM(bool dm_valid, const uint8_t* dm_prefix) {
bool play = false, force = false;
if (dm_valid && _prefs) {
uint8_t state = 0;
for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) {
if (_prefs->dm_notif[i].state &&
memcmp(_prefs->dm_notif[i].prefix, dm_prefix, 4) == 0)
{ state = _prefs->dm_notif[i].state; break; }
}
if (state == 2) { play = true; force = true; }
else if (state == 1) { /* muted */ }
else { play = !_buz.isQuiet(); }
} else {
play = !_buz.isQuiet();
}
if (!play) return;
int slot = _prefs ? (int)_prefs->notif_melody_dm : 0;
if (dm_valid && _prefs) {
for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++)
if (_prefs->dm_melody[i].slot &&
memcmp(_prefs->dm_melody[i].prefix, dm_prefix, 4) == 0)
{ slot = _prefs->dm_melody[i].slot; break; }
}
playSlot(slot, force, "MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7");
}
void playCH(int ch_idx) {
bool play = false, force = false;
if (ch_idx >= 0 && ch_idx < 64 && _prefs) {
uint64_t mask = 1ULL << ch_idx;
if (_prefs->ch_notif_override & mask) {
if (!(_prefs->ch_notif_muted & mask)) { play = true; force = true; }
} else {
play = !_buz.isQuiet();
}
} else {
play = !_buz.isQuiet();
}
if (!play) return;
int slot = _prefs ? (int)_prefs->notif_melody_ch : 0;
if (ch_idx >= 0 && ch_idx < 64 && _prefs) {
uint64_t mask = 1ULL << ch_idx;
if (_prefs->ch_notif_melody_set & mask)
slot = (_prefs->ch_notif_melody_2 & mask) ? 2 : 1;
}
playSlot(slot, force, "kerplop:d=16,o=6,b=120:32g#,32c#");
}
void playAD(bool is_flood) {
if (_prefs && _prefs->advert_sound_scope == ADVERT_SOUND_SCOPE_ZERO_HOP && is_flood) return;
if (_buz.isQuiet()) return;
int slot = _prefs ? (int)_prefs->notif_melody_ad : 0;
playSlot(slot, false, "MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7");
}
};
#endif
@@ -38,6 +38,9 @@ public:
// A display pref changed through the settings schema (brightness, ...):
// push it to the panel.
virtual void applyDisplayPrefs() {}
// A sound pref changed through the schema (the volume): push it to the
// speaker / buzzer.
virtual void applySoundPrefs() {}
// Controlled power-down / restart (flush state first). See
// AbstractUITask::shutdown().