feat(firmware): volume control goes to eleven

Rework the speaker volume from a 0-100 percentage to an 11-step level
(0..11, mapped to the codec's percent), with mute that remembers the
prior level so unmute restores it. Any non-silent change plays the gong
as feedback; a new change cuts the in-flight gong off and restarts it
rather than queueing another. The tap overlay shows the level number and
0..11 bar, and a "11" drifts up off the bar when you hit maximum.

Also lands the device side of gateway volume commands: gw_client routes a
"command" message to face_volume_command, which applies the change and
shows a compact auto-hiding volume HUD (a centered bar) — the gateway
half that sends these lives in a following commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKPbR6DY2JygHbyLjxm7Uu
This commit is contained in:
2026-07-15 21:27:48 +02:00
co-authored by Claude Opus 4.8
parent 9521deddc5
commit cdb23bda05
5 changed files with 228 additions and 26 deletions
+159 -9
View File
@@ -128,8 +128,12 @@ static struct {
lv_obj_t *arc;
lv_obj_t *controls; /* tap-to-reveal control overlay (mic + volume) */
lv_obj_t *vol_bar;
lv_obj_t *vol_lbl; /* "NN%" */
lv_obj_t *vol_lbl; /* level number 0..11, or "MUTE" */
lv_timer_t *ctl_timer; /* one-shot auto-hide for the controls */
lv_obj_t *vol_hud; /* lightweight volume feedback for voice commands */
lv_obj_t *hud_bar;
lv_obj_t *hud_lbl;
lv_timer_t *hud_timer; /* one-shot auto-hide for the HUD */
stream_t streams[RAIN_MAX];
int arc_rot;
uint32_t state_since;
@@ -430,11 +434,15 @@ static void clock_cb(lv_timer_t *timer)
static void controls_update_vol(void)
{
int v = audio_volume_get();
lv_bar_set_value(F.vol_bar, v, LV_ANIM_OFF);
char buf[8];
snprintf(buf, sizeof(buf), "%d%%", v);
lv_label_set_text(F.vol_lbl, buf);
int lvl = audio_level_get();
lv_bar_set_value(F.vol_bar, lvl, LV_ANIM_OFF);
if (audio_is_muted()) {
lv_label_set_text(F.vol_lbl, "MUTE");
} else {
char buf[8];
snprintf(buf, sizeof(buf), "%d", lvl);
lv_label_set_text(F.vol_lbl, buf);
}
}
static void controls_hide(void)
@@ -452,6 +460,109 @@ static void controls_show(void)
lv_timer_resume(F.ctl_timer);
}
/* Audible feedback at the new volume: any non-silent change plays the gong. A new
* change cuts the in-flight gong off and restarts it (audio.c) rather than queueing. */
static void volume_feedback(void)
{
if (!audio_is_muted() && audio_level_get() > 0) {
audio_play_gong();
}
}
/* "This one goes to eleven": a big 11 drifts up and fades when max is hit. */
static void eleven_ty_cb(void *o, int32_t v) { lv_obj_set_style_translate_y((lv_obj_t *)o, v, 0); }
static void eleven_opa_cb(void *o, int32_t v) { lv_obj_set_style_text_opa((lv_obj_t *)o, (lv_opa_t)v, 0); }
static void eleven_del_cb(lv_anim_t *a) { lv_obj_del((lv_obj_t *)a->var); }
static void eleven_pop(lv_obj_t *anchor)
{
lv_obj_t *l = lv_label_create(lv_layer_top()); /* floats above everything */
lv_label_set_text(l, "11");
lv_obj_set_style_text_font(l, &lv_font_montserrat_48, 0);
lv_obj_set_style_text_color(l, lv_color_hex(0xC3FFD7), 0);
lv_obj_update_layout(l);
lv_area_t a; /* start just above the bar */
lv_obj_get_coords(anchor, &a);
lv_obj_set_pos(l, (a.x1 + a.x2) / 2 - lv_obj_get_width(l) / 2,
a.y1 - lv_obj_get_height(l) - 4);
lv_anim_t up; /* drift up (via translate, keeps align) */
lv_anim_init(&up);
lv_anim_set_var(&up, l);
lv_anim_set_exec_cb(&up, eleven_ty_cb);
lv_anim_set_values(&up, 0, -200);
lv_anim_set_duration(&up, 1100);
lv_anim_set_path_cb(&up, lv_anim_path_ease_out);
lv_anim_set_deleted_cb(&up, eleven_del_cb); /* deletes the label (cancels the fade too) */
lv_anim_start(&up);
lv_anim_t fade;
lv_anim_init(&fade);
lv_anim_set_var(&fade, l);
lv_anim_set_exec_cb(&fade, eleven_opa_cb);
lv_anim_set_values(&fade, LV_OPA_COVER, LV_OPA_TRANSP);
lv_anim_set_duration(&fade, 1100);
lv_anim_start(&fade);
}
/* --- voice-command volume HUD (shown when a gateway command changes volume) --- */
static void hud_update(void)
{
int lvl = audio_level_get();
if (audio_is_muted()) {
lv_bar_set_value(F.hud_bar, 0, LV_ANIM_OFF);
lv_label_set_text(F.hud_lbl, "MUTE");
} else {
lv_bar_set_value(F.hud_bar, lvl, LV_ANIM_OFF);
char b[8];
snprintf(b, sizeof(b), "%d", lvl);
lv_label_set_text(F.hud_lbl, b);
}
}
static void hud_autohide_cb(lv_timer_t *t)
{
(void)t;
lv_obj_add_flag(F.vol_hud, LV_OBJ_FLAG_HIDDEN);
lv_timer_pause(F.hud_timer);
}
static void hud_show(void)
{
hud_update();
lv_obj_clear_flag(F.vol_hud, LV_OBJ_FLAG_HIDDEN);
face_activity();
lv_timer_reset(F.hud_timer);
lv_timer_resume(F.hud_timer);
}
/* Apply a gateway volume command (runs on the websocket task). */
void face_volume_command(const char *action, int level)
{
if (strcmp(action, "volume_up") == 0) {
audio_level_set(audio_level_get() + 1);
} else if (strcmp(action, "volume_down") == 0) {
audio_level_set(audio_level_get() - 1);
} else if (strcmp(action, "volume_set") == 0) {
audio_level_set(level);
} else if (strcmp(action, "mute") == 0) {
audio_mute(true);
} else if (strcmp(action, "unmute") == 0) {
audio_mute(false);
} else {
return;
}
bsp_display_lock(UINT32_MAX);
hud_show();
if (!audio_is_muted() && audio_level_get() == AUDIO_VOL_MAX) {
eleven_pop(F.hud_bar);
}
bsp_display_unlock();
volume_feedback();
}
static void ctl_autohide_cb(lv_timer_t *t) { (void)t; controls_hide(); }
static void mic_cb(lv_event_t *e)
@@ -464,16 +575,21 @@ static void mic_cb(lv_event_t *e)
static void volup_cb(lv_event_t *e)
{
(void)e;
audio_volume_set(audio_volume_get() + 10);
audio_level_set(audio_level_get() + 1);
controls_update_vol();
if (audio_level_get() == AUDIO_VOL_MAX) {
eleven_pop(F.vol_bar);
}
volume_feedback();
lv_timer_reset(F.ctl_timer);
}
static void voldown_cb(lv_event_t *e)
{
(void)e;
audio_volume_set(audio_volume_get() - 10);
audio_level_set(audio_level_get() - 1);
controls_update_vol();
volume_feedback();
lv_timer_reset(F.ctl_timer);
}
@@ -625,7 +741,7 @@ void face_init(void)
F.vol_bar = lv_bar_create(F.controls);
lv_obj_set_size(F.vol_bar, 360, 16);
lv_obj_align(F.vol_bar, LV_ALIGN_CENTER, 0, 140);
lv_bar_set_range(F.vol_bar, 0, 100);
lv_bar_set_range(F.vol_bar, 0, AUDIO_VOL_MAX);
lv_obj_set_style_bg_color(F.vol_bar, lv_color_hex(0x0A140E), LV_PART_MAIN);
lv_obj_set_style_bg_opa(F.vol_bar, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_bg_color(F.vol_bar, lv_color_hex(0x00C860), LV_PART_INDICATOR);
@@ -641,6 +757,40 @@ void face_init(void)
F.ctl_timer = lv_timer_create(ctl_autohide_cb, 5000, NULL);
lv_timer_pause(F.ctl_timer);
/* voice-command volume HUD: just a slim volume bar (+ level number), centered
* and auto-hiding — separate from the tap overlay so a spoken "volume up"
* doesn't pop the mic button. The "11" animation flows off this bar. */
F.vol_hud = lv_obj_create(scr);
lv_obj_remove_style_all(F.vol_hud);
lv_obj_set_size(F.vol_hud, 560, 64);
lv_obj_center(F.vol_hud);
lv_obj_set_style_bg_color(F.vol_hud, lv_color_black(), 0);
lv_obj_set_style_bg_opa(F.vol_hud, 180, 0);
lv_obj_set_style_radius(F.vol_hud, 16, 0);
lv_obj_set_style_pad_all(F.vol_hud, 10, 0);
lv_obj_clear_flag(F.vol_hud, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(F.vol_hud, LV_OBJ_FLAG_HIDDEN);
/* bar centered (symmetric on screen); level number hangs off to the right */
F.hud_bar = lv_bar_create(F.vol_hud);
lv_obj_set_size(F.hud_bar, 360, 18);
lv_obj_align(F.hud_bar, LV_ALIGN_CENTER, 0, 0);
lv_bar_set_range(F.hud_bar, 0, AUDIO_VOL_MAX);
lv_obj_set_style_bg_color(F.hud_bar, lv_color_hex(0x0A140E), LV_PART_MAIN);
lv_obj_set_style_bg_opa(F.hud_bar, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_bg_color(F.hud_bar, lv_color_hex(0x00C860), LV_PART_INDICATOR);
lv_obj_set_style_radius(F.hud_bar, 9, LV_PART_MAIN);
lv_obj_set_style_radius(F.hud_bar, 9, LV_PART_INDICATOR);
F.hud_lbl = lv_label_create(F.vol_hud);
lv_obj_set_style_text_font(F.hud_lbl, &lv_font_montserrat_28, 0);
lv_obj_set_style_text_color(F.hud_lbl, lv_color_hex(0xC3FFD7), 0);
lv_label_set_text(F.hud_lbl, "");
lv_obj_align(F.hud_lbl, LV_ALIGN_RIGHT_MID, -6, 0);
F.hud_timer = lv_timer_create(hud_autohide_cb, 2000, NULL);
lv_timer_pause(F.hud_timer);
/* Boot straight into the calm idle face (dry — rain gate holds it off until
* the gateway connects). No "CONNECTING" banner churn on every recovery. */
F.state = FACE_IDLE;