diff --git a/CHANGELOG.md b/CHANGELOG.md index f890c32..03d5e2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/firmware/main/audio.c b/firmware/main/audio.c index d09d98b..6f71462 100644 --- a/firmware/main/audio.c +++ b/firmware/main/audio.c @@ -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; } diff --git a/firmware/main/desklock.h b/firmware/main/desklock.h index 30716a1..399a8a5 100644 --- a/firmware/main/desklock.h +++ b/firmware/main/desklock.h @@ -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); diff --git a/firmware/main/face.c b/firmware/main/face.c index d077911..f9f1368 100644 --- a/firmware/main/face.c +++ b/firmware/main/face.c @@ -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; diff --git a/firmware/main/gw_client.c b/firmware/main/gw_client.c index 5f854b9..34fc28f 100644 --- a/firmware/main/gw_client.c +++ b/firmware/main/gw_client.c @@ -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); }