Files
MeshCore-Solo/examples/companion_radio/ui-lvgl/SoundScreen.h
T
JakubandClaude Opus 5.5 a40a87d02c feat(ui-lvgl): settings kit, choice picker, reorganised Settings
group() / settingRow / listRow / actionRow / choiceRow / groupNote: one card
per section, hairlines between rows, hints that wrap. Dropdowns replaced by
a centred picker list. Settings regrouped (Device, Connections, Map & data,
System); Display & power split into Display, Power and Time on L2 only
(the shared schema is unchanged), Keyboard and About get their own pages.
Radio and Repeater share one LoRa parameter block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 01:17:48 +02:00

463 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, theme::RADIUS_SM, 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, choiceSelected((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, theme::RADIUS_SM, 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 = settingRow(body, "Sound", "Auto: off with the app");
radioview::rowSegmented(row, MODES, soundctl::mode(_prefs), 150, onSoundMode, 0);
return;
}
lv_obj_t* g = group(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(g, slot ? "Melody 2" : "Melody 1", sub, onMelodyRow, (void*)(uintptr_t)slot);
}
groupNote(body, "Per chat: hold it in Chats for its options.");
}
// ── 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 = choiceCreate(top, "60 BPM\n90 BPM\n120 BPM\n150 BPM\n180 BPM", s_mel.bpm_idx, "Tempo");
lv_obj_set_size(s_bpm, 102, 32);
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) choiceSetSelected(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);
}