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:
@@ -22,5 +22,7 @@ until the first tagged release.
|
||||
|
||||
### Changed
|
||||
|
||||
- Volume is now an 0–11 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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user