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
+2
View File
@@ -22,5 +22,7 @@ until the first tagged release.
### Changed
- Volume is now an 011 scale (it goes to eleven) with a gong cue on each change
and a little "11" that floats off the bar at maximum.
- Matrix rain is drawn from a reused pool of pre-rendered streak sprites instead
of live text labels, and eases off while the device is listening or speaking.
+55 -15
View File
@@ -29,13 +29,20 @@ static const char *TAG = "audio";
#define GONG_SECONDS 5
#define GONG_SAMPLES (RATE * GONG_SECONDS)
#define GONG_VOLUME 75
#define GONG_PEAK 14000.0f
/* Speaker volume is an 11-step level (0..AUDIO_VOL_MAX). This one goes to eleven. */
#define VOL_DEFAULT_LEVEL 8 /* ~73%, matches the previous 75 default */
static esp_codec_dev_handle_t s_spk;
static esp_codec_dev_handle_t s_mic;
static int s_vol = GONG_VOLUME; /* speaker out volume 0..100 */
static int s_level = VOL_DEFAULT_LEVEL; /* speaker volume, 0..AUDIO_VOL_MAX */
static bool s_muted;
static int level_to_pct(int level) { return level * 100 / AUDIO_VOL_MAX; }
static int16_t *s_gong;
#define GONG_CHUNK 1600 /* 100 ms — cut-off granularity for a restart */
static volatile uint32_t s_gong_gen; /* bumped per request; a change mid-play restarts */
static volatile bool s_gong_active;
static uint8_t *s_reply;
static volatile size_t s_reply_len;
@@ -153,7 +160,20 @@ static void audio_task(void *arg)
* ourselves. The tail delay covers the DMA that plays after write()
* returns, preventing a playback-boundary false wake. */
if (job == JOB_GONG && s_gong != NULL) {
esp_codec_dev_write(s_spk, s_gong, GONG_SAMPLES * sizeof(int16_t));
/* Play in chunks so a new request (a fresh volume change) cuts the
* current gong off and restarts, instead of queueing another 5 s. */
s_playing = true;
uint32_t gen;
do {
gen = s_gong_gen;
for (size_t off = 0; off < GONG_SAMPLES && s_gong_gen == gen; off += GONG_CHUNK) {
size_t n = GONG_SAMPLES - off;
if (n > GONG_CHUNK) n = GONG_CHUNK;
esp_codec_dev_write(s_spk, s_gong + off, n * sizeof(int16_t));
}
} while (s_gong_gen != gen); /* a new request arrived mid-play → restart */
s_playing = false;
s_gong_active = false;
} else if (job == JOB_CHIME && s_chime != NULL) {
s_playing = true;
esp_codec_dev_write(s_spk, s_chime, CHIME_SAMPLES * sizeof(int16_t));
@@ -277,7 +297,7 @@ void audio_init(void)
};
s_spk = bsp_audio_codec_speaker_init();
if (s_spk != NULL) {
esp_codec_dev_set_out_vol(s_spk, s_vol);
esp_codec_dev_set_out_vol(s_spk, level_to_pct(s_level));
esp_codec_dev_open(s_spk, &fs);
}
s_mic = bsp_audio_codec_microphone_init();
@@ -304,8 +324,14 @@ void audio_play_chime(void)
void audio_play_gong(void)
{
job_t job = JOB_GONG;
xQueueSend(s_jobs, &job, 0);
s_gong_gen++; /* (re)start; cuts any in-flight gong */
if (!s_gong_active) {
s_gong_active = true;
job_t job = JOB_GONG;
if (xQueueSend(s_jobs, &job, 0) != pdTRUE) {
s_gong_active = false; /* queue full — nothing will run it */
}
}
}
/* Streaming = the detect_task forwards AFE audio upstream. Wake word and touch
@@ -350,20 +376,34 @@ bool audio_is_playing(void)
return s_playing;
}
void audio_volume_set(int vol)
void audio_level_set(int level)
{
if (vol < 0) {
vol = 0;
} else if (vol > 100) {
vol = 100;
if (level < 0) {
level = 0;
} else if (level > AUDIO_VOL_MAX) {
level = AUDIO_VOL_MAX;
}
s_vol = vol;
s_level = level;
s_muted = false; /* setting a level unmutes */
if (s_spk != NULL) {
esp_codec_dev_set_out_vol(s_spk, s_vol);
esp_codec_dev_set_out_vol(s_spk, level_to_pct(s_level));
}
}
int audio_volume_get(void)
int audio_level_get(void)
{
return s_vol;
return s_level;
}
void audio_mute(bool on)
{
s_muted = on; /* gate output; s_level is preserved */
if (s_spk != NULL) {
esp_codec_dev_set_out_vol(s_spk, on ? 0 : level_to_pct(s_level));
}
}
bool audio_is_muted(void)
{
return s_muted;
}
+6 -2
View File
@@ -23,6 +23,7 @@ face_state_t face_get(void);
void face_activity(void);
void face_status(const char *text); /* bottom status line (diag/boot) */ /* reset the power ladder to active */
void face_screenshot_dump(bool overlay); /* dev: raw RGB565 screen dump over USB */
void face_volume_command(const char *action, int level); /* apply a gateway volume command */
/* audio.c */
void audio_init(void);
@@ -35,8 +36,11 @@ void audio_playback_begin(void);
void audio_playback_feed(const uint8_t *data, size_t len);
void audio_playback_end(void);
bool audio_is_playing(void);
void audio_volume_set(int vol); /* 0..100 */
int audio_volume_get(void);
#define AUDIO_VOL_MAX 11 /* volume level range 0..11 (goes to eleven) */
void audio_level_set(int level); /* 0..AUDIO_VOL_MAX; also unmutes */
int audio_level_get(void);
void audio_mute(bool on); /* gate output; keeps the set level */
bool audio_is_muted(void);
/* net.c */
void net_start(void);
+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;
+6
View File
@@ -52,6 +52,12 @@ static void handle_text(const char *data, int len)
audio_playback_end();
} else if (strcmp(type->valuestring, "error") == 0) {
face_set(FACE_RAGE);
} else if (strcmp(type->valuestring, "command") == 0) {
const cJSON *action = cJSON_GetObjectItem(root, "action");
const cJSON *level = cJSON_GetObjectItem(root, "level");
if (cJSON_IsString(action)) {
face_volume_command(action->valuestring, cJSON_IsNumber(level) ? level->valueint : 0);
}
}
cJSON_Delete(root);
}