Files
MeshCore-Solo/examples/companion_radio/ui-lvgl/SoundScreen.h
T
JakubandClaude Opus 5.5 938dfefba6 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>
2026-09-26 00:27:52 +02:00

469 lines
17 KiB
C++

// 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);
}