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
@@ -835,32 +835,6 @@ class MessagesScreen : public UIScreen {
return p && (p->ch_fav_bitmask & (1ULL << ch_idx)) != 0;
}
// Returns per-channel notification state: 0=follow global, 1=muted, 2=force-on
// Per-contact/channel notification + melody overrides share two storage
// shapes, so the eight accessors below are thin wrappers over two primitives:
//
// • Channels — a 3-state packed into a pair of channel-index bitmasks: a
// "presence" mask (is there an override at all) + a "variant" mask. The two
// uses disagree on which state the variant bit means, so the caller passes
// the state value that corresponds to variant-set (v_set).
// • DMs — a small {prefix[4], value} table: find by 4-byte prefix, update or
// clear (clear frees the slot), else insert into the first free slot, else
// overwrite slot 0. value 0 == "no override" == empty slot.
static uint8_t maskPairGet(uint64_t presence, uint64_t variant, uint8_t idx,
uint8_t v_set, uint8_t v_clr) {
uint64_t m = 1ULL << idx;
if (!(presence & m)) return 0;
return (variant & m) ? v_set : v_clr;
}
static void maskPairSet(uint64_t& presence, uint64_t& variant, uint8_t idx,
uint8_t state, uint8_t v_set) {
uint64_t m = 1ULL << idx;
if (state == 0) { presence &= ~m; variant &= ~m; return; }
presence |= m;
if (state == v_set) variant |= m; else variant &= ~m;
}
// Channel notif (ui-core/ChannelControl.h): 0 = default, 1 = muted, 2 = force-on.
uint8_t chNotifState(uint8_t ch_idx) const { return chanctl::notif(_task->getNodePrefs(), ch_idx); }
void setChNotifState(uint8_t ch_idx, uint8_t state) { chanctl::setNotif(_task->getNodePrefs(), ch_idx, state); }
@@ -869,15 +843,9 @@ class MessagesScreen : public UIScreen {
uint8_t dmNotifState(const uint8_t* pub_key) const { return contactctl::notif(_task->getNodePrefs(), pub_key); }
void setDmNotifState(const uint8_t* pub_key, uint8_t state) { contactctl::setNotif(_task->getNodePrefs(), pub_key, state); }
// Channel melody: slot 1 = melody 1 (variant bit clear), 2 = melody 2 (set).
uint8_t chNotifMelody(uint8_t ch_idx) const {
NodePrefs* p = _task->getNodePrefs();
return p ? maskPairGet(p->ch_notif_melody_set, p->ch_notif_melody_2, ch_idx, 2, 1) : 0;
}
void setChNotifMelody(uint8_t ch_idx, uint8_t slot) {
NodePrefs* p = _task->getNodePrefs();
if (p) maskPairSet(p->ch_notif_melody_set, p->ch_notif_melody_2, ch_idx, slot, 2);
}
// Channel melody (ui-core/ChannelControl.h): 0 = global, 1 / 2 = melody 1 / 2.
uint8_t chNotifMelody(uint8_t ch_idx) const { return chanctl::melody(_task->getNodePrefs(), ch_idx); }
void setChNotifMelody(uint8_t ch_idx, uint8_t slot) { chanctl::setMelody(_task->getNodePrefs(), ch_idx, slot); }
uint8_t dmMelodySlot(const uint8_t* pub_key) const { return contactctl::melody(_task->getNodePrefs(), pub_key); }
void setDmMelody(const uint8_t* pub_key, uint8_t slot) { contactctl::setMelody(_task->getNodePrefs(), pub_key, slot); }
@@ -6,20 +6,17 @@ class RingtoneEditorScreen : public UIScreen {
UITask* _task;
NodePrefs* _prefs;
static const int MAX_NOTES = 32;
static const int MAX_NOTES = soundctl::MAX_NOTES; // melody model: ui-core/SoundControl.h
// Menu item indices
enum MenuIdx { MI_PLAY=0, MI_SWITCH, MI_DURATION, MI_BPM, MI_INSERT, MI_DELETE, MI_SAVE, MI_DISCARD, MI_COUNT };
int _visible_notes = 7; // updated in render(); used by clampScroll()
static const uint16_t BPM_OPTS[5];
static const char* DUR_LABELS[4];
static const char PITCH_NAMES[8]; // lowercase rtttl names
uint8_t _notes[MAX_NOTES];
uint8_t _len;
uint8_t _bpm_idx;
soundctl::Melody _mel;
uint8_t* const _notes = _mel.notes;
uint8_t& _len = _mel.len;
uint8_t& _bpm_idx = _mel.bpm_idx;
int _slot; // 0=melody1, 1=melody2
int _cursor;
int _scroll;
@@ -30,12 +27,10 @@ class RingtoneEditorScreen : public UIScreen {
char _menu_dur_label[16];
char _menu_bpm_label[10];
static uint8_t notePitch(uint8_t b) { return b & 0x07; }
static uint8_t noteOctave(uint8_t b) { return ((b >> 3) & 0x03) + 4; }
static uint8_t noteDurIdx(uint8_t b) { return (b >> 5) & 0x03; }
static uint8_t packNote(uint8_t pitch, uint8_t octave, uint8_t dur_idx) {
return (pitch & 0x07) | (((octave - 4) & 0x03) << 3) | ((dur_idx & 0x03) << 5);
}
static uint8_t notePitch(uint8_t b) { return soundctl::notePitch(b); }
static uint8_t noteOctave(uint8_t b) { return soundctl::noteOctave(b); }
static uint8_t noteDurIdx(uint8_t b) { return soundctl::noteDur(b); }
static uint8_t packNote(uint8_t pitch, uint8_t octave, uint8_t dur_idx) { return soundctl::packNote(pitch, octave, dur_idx); }
void clampScroll() {
if (_cursor < _scroll) _scroll = _cursor;
@@ -43,14 +38,11 @@ class RingtoneEditorScreen : public UIScreen {
if (_scroll < 0) _scroll = 0;
}
void buildRTTTL() {
NodePrefs::buildRTTTLString(_notes, _len, _bpm_idx, _play_buf, sizeof(_play_buf));
}
void buildRTTTL() { soundctl::toRtttl(_mel, _play_buf, sizeof(_play_buf)); }
void previewNote(uint8_t note_byte) {
uint8_t pitch = notePitch(note_byte);
if (pitch == 0) { _task->stopMelody(); return; }
snprintf(_play_buf, sizeof(_play_buf), "P:d=16,o=5,b=240:%c%d", PITCH_NAMES[pitch], noteOctave(note_byte));
soundctl::notePreview(note_byte, _play_buf, sizeof(_play_buf));
if (!_play_buf[0]) { _task->stopMelody(); return; }
_task->playMelody(_play_buf);
}
@@ -61,12 +53,7 @@ public:
// setCurrScreen() (whose onShow() can't carry the slot argument).
void selectSlot(int slot = 0) {
_slot = (slot == 1) ? 1 : 0;
bool s2 = (_slot == 1);
_bpm_idx = (_prefs && (s2 ? _prefs->ringtone2_bpm_idx : _prefs->ringtone_bpm_idx) < 5)
? (s2 ? _prefs->ringtone2_bpm_idx : _prefs->ringtone_bpm_idx) : 2;
uint8_t rlen = _prefs ? (s2 ? _prefs->ringtone2_len : _prefs->ringtone_len) : 0;
_len = (rlen <= (uint8_t)MAX_NOTES) ? rlen : 0;
if (_prefs) memcpy(_notes, s2 ? _prefs->ringtone2_notes : _prefs->ringtone_notes, sizeof(_notes));
soundctl::load(_prefs, _slot, _mel);
_cursor = 0;
_scroll = 0;
_menu.active = false;
@@ -76,8 +63,8 @@ public:
snprintf(_menu_play_label, sizeof(_menu_play_label), "%s", _task->isMelodyPlaying() ? "Stop" : "Play");
snprintf(_menu_slot_label, sizeof(_menu_slot_label), "Melody %d", (_slot == 0) ? 2 : 1);
uint8_t di = (_cursor < _len) ? noteDurIdx(_notes[_cursor]) : 0;
snprintf(_menu_dur_label, sizeof(_menu_dur_label), "Duration: %s", DUR_LABELS[di]);
snprintf(_menu_bpm_label, sizeof(_menu_bpm_label), "BPM: %u", BPM_OPTS[_bpm_idx]);
snprintf(_menu_dur_label, sizeof(_menu_dur_label), "Duration: %s", soundctl::durLabel(di));
snprintf(_menu_bpm_label, sizeof(_menu_bpm_label), "BPM: %u", soundctl::bpm(_bpm_idx));
_menu.begin("Options", 5);
_menu.addItem(_menu_play_label);
_menu.addItem(_menu_slot_label);
@@ -101,7 +88,7 @@ public:
_visible_notes = display.width() / cell_w;
char hdr[32];
snprintf(hdr, sizeof(hdr), "M%d BPM:%u %d/%d", _slot + 1, BPM_OPTS[_bpm_idx], _len, MAX_NOTES);
snprintf(hdr, sizeof(hdr), "M%d BPM:%u %d/%d", _slot + 1, soundctl::bpm(_bpm_idx), _len, MAX_NOTES);
display.setCursor(0, 0);
display.print(hdr);
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
@@ -117,7 +104,7 @@ public:
if (pitch == 0) {
label[0] = '-'; label[1] = '-'; label[2] = '\0';
} else {
label[0] = PITCH_NAMES[pitch] - 32; // uppercase
label[0] = soundctl::pitchChar(pitch) - 32; // uppercase
label[1] = '0' + octave;
label[2] = '\0';
}
@@ -147,7 +134,7 @@ public:
if (_cursor < _len) {
char info[24];
snprintf(info, sizeof(info), "oct:%d dur:%s",
noteOctave(_notes[_cursor]), DUR_LABELS[noteDurIdx(_notes[_cursor])]);
noteOctave(_notes[_cursor]), soundctl::durLabel(noteDurIdx(_notes[_cursor])));
display.setCursor(display.getCharWidth() + 2, info_y);
display.print(info);
} else if (_cursor == _len) {
@@ -170,11 +157,11 @@ public:
uint8_t di = noteDurIdx(_notes[_cursor]);
di = (dir > 0) ? (di + 1) & 0x03 : (di + 3) & 0x03;
_notes[_cursor] = packNote(p, o, di);
snprintf(_menu_dur_label, sizeof(_menu_dur_label), "Duration: %s", DUR_LABELS[di]);
snprintf(_menu_dur_label, sizeof(_menu_dur_label), "Duration: %s", soundctl::durLabel(di));
} else if (sel == MI_BPM) {
if (dir > 0) { if (_bpm_idx < 4) _bpm_idx++; }
else { if (_bpm_idx > 0) _bpm_idx--; }
snprintf(_menu_bpm_label, sizeof(_menu_bpm_label), "BPM: %u", BPM_OPTS[_bpm_idx]);
snprintf(_menu_bpm_label, sizeof(_menu_bpm_label), "BPM: %u", soundctl::bpm(_bpm_idx));
}
}
@@ -226,15 +213,7 @@ public:
break;
case MI_SAVE:
if (_prefs) {
if (_slot == 1) {
_prefs->ringtone2_bpm_idx = _bpm_idx;
_prefs->ringtone2_len = _len;
memcpy(_prefs->ringtone2_notes, _notes, sizeof(_notes));
} else {
_prefs->ringtone_bpm_idx = _bpm_idx;
_prefs->ringtone_len = _len;
memcpy(_prefs->ringtone_notes, _notes, sizeof(_notes));
}
soundctl::store(_prefs, _slot, _mel);
the_mesh.savePrefs();
}
_task->stopMelody();
@@ -294,7 +273,3 @@ public:
return false;
}
};
const uint16_t RingtoneEditorScreen::BPM_OPTS[5] = { 60, 90, 120, 150, 180 };
const char* RingtoneEditorScreen::DUR_LABELS[4] = { "1/4", "1/8", "1/16", "1/32" };
const char RingtoneEditorScreen::PITCH_NAMES[8] = { 'p', 'c', 'd', 'e', 'f', 'g', 'a', 'b' };
@@ -1,96 +0,0 @@
#pragma once
// Fork-specific buzzer/melody dispatch — isolated here so upstream changes to
// UITask::notify() don't create merge conflicts.
#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
+17 -26
View File
@@ -1,5 +1,6 @@
#include "UITask.h"
#include "SoundNotifier.h"
#include "../ui-core/SoundNotifier.h"
#include "../ui-core/SoundControl.h"
#include <helpers/TxtDataHelpers.h>
#include "../MyMesh.h"
#include "../MsgExpand.h"
@@ -1667,9 +1668,9 @@ void UITask::gotoGpioScreen() {
void UITask::gotoLiveShareScreen() { setCurrScreen(live_share_screen); }
// ── Clock tools (engine in ui-core/ClockEngine.h) ───────────────────────────
// The melody overrides mute (playMelody → buzzer.playForced); the ring
// auto-stops after ClockEngine::RING_MS if no key dismisses it (see loop()).
static const char* CLOCK_ALARM_MELODY = "alarm:d=8,o=6,b=125:c,c,c,c,p,c,c,c,c,p";
// The melody (soundctl::MEL_ALARM) overrides mute (playMelody →
// buzzer.playForced); the ring auto-stops after ClockEngine::RING_MS if no key
// dismisses it (see loop()).
void UITask::wakeForAlarm() {
if (_display != NULL) _display->turnOn();
@@ -1693,7 +1694,7 @@ void UITask::tickCore() {
_core->loop();
drainCoreEvents();
// Repeat the ring melody until dismissed or the ring window elapses.
if (_core->clock.isRinging() && !isMelodyPlaying()) playMelody(CLOCK_ALARM_MELODY);
if (_core->clock.isRinging() && !isMelodyPlaying()) playMelody(soundctl::MEL_ALARM);
}
void UITask::drainCoreEvents() {
@@ -1703,7 +1704,7 @@ void UITask::drainCoreEvents() {
case UiEventType::ClockAlert:
wakeForAlarm();
showAlert(ev.text, ClockEngine::RING_MS);
playMelody(CLOCK_ALARM_MELODY);
playMelody(soundctl::MEL_ALARM);
break;
case UiEventType::ClockRingEnded:
stopMelody();
@@ -1715,10 +1716,10 @@ void UITask::drainCoreEvents() {
case UiEventType::LocatorCrossed:
showAlert(ev.text, 3000);
if (!isBuzzerQuiet())
playMelody(ev.flag ? "locarr:d=8,o=6,b=140:c,e,g" : "loclv:d=8,o=6,b=140:g,e,c");
playMelody(ev.flag ? soundctl::MEL_ARRIVE : soundctl::MEL_LEAVE);
break;
case UiEventType::LocatorBeep:
playMelody("locp:d=32,o=7,b=200:c");
playMelody(soundctl::MEL_TICK);
break;
case UiEventType::MessageArrived:
onMessageArrived(ev);
@@ -2681,13 +2682,8 @@ void UITask::loop() {
userLedHandler();
#ifdef PIN_BUZZER
if (_node_prefs && _node_prefs->buzzer_auto) {
bool should_quiet = isClientConnected(); // BLE bonded or an open USB port
if (buzzer.isQuiet() != should_quiet) {
buzzer.quiet(should_quiet);
_next_refresh = 0;
}
}
if (soundctl::autoTick(_node_prefs, buzzer, isClientConnected())) // BLE bonded or an open USB port
_next_refresh = 0;
if (buzzer.isPlaying()) buzzer.loop();
#endif
@@ -3257,10 +3253,7 @@ void UITask::setBrightnessLevel(uint8_t level) {
void UITask::setBuzzerVolumeLevel(uint8_t level) {
#ifdef PIN_BUZZER
if (_node_prefs == NULL) return;
if (level > 4) level = 4;
_node_prefs->buzzer_volume = level;
buzzer.setVolume(level);
if (level > 0) buzzer.playForced("Vol:d=16,o=6,b=120:c");
soundctl::setVolume(_node_prefs, buzzer, level);
_next_refresh = 0;
#endif
}
@@ -3283,8 +3276,8 @@ void UITask::toggleBuzzer() {
int UITask::getBuzzerMode() {
#ifdef PIN_BUZZER
if (_node_prefs && _node_prefs->buzzer_auto) return 2;
return buzzer.isQuiet() ? 1 : 0;
if (_node_prefs && _node_prefs->buzzer_auto) return soundctl::MODE_AUTO;
return buzzer.isQuiet() ? soundctl::MODE_OFF : soundctl::MODE_ON;
#else
return 1;
#endif
@@ -3294,11 +3287,9 @@ void UITask::cycleBuzzerMode() {
#ifdef PIN_BUZZER
if (!_node_prefs) return;
int mode = getBuzzerMode();
mode = (mode + 1) % 3; // ON(0) → OFF(1) → Auto(2) → ON
_node_prefs->buzzer_auto = (mode == 2) ? 1 : 0;
if (mode == 0) { buzzer.quiet(false); _node_prefs->buzzer_quiet = 0; notify(UIEventType::ack); }
if (mode == 1) { buzzer.quiet(true); _node_prefs->buzzer_quiet = 1; }
if (mode == 2) { buzzer.quiet(isClientConnected()); }
mode = (mode + 1) % soundctl::MODE_COUNT; // ON → OFF → Auto → ON
soundctl::setMode(_node_prefs, buzzer, (uint8_t)mode, isClientConnected());
if (mode == soundctl::MODE_ON) notify(UIEventType::ack);
static const char* labels[] = { "Buzzer: ON", "Buzzer: OFF", "Buzzer: Auto" };
showAlert(labels[mode], 800);
_next_refresh = 0;