Bump raw_buf_almost_empty_thrd 512 -> 1024 right after display start so the bridge demands a DMA refill with more slack still in the FIFO, letting it ride out a PSRAM-bus latency spike instead of draining to the blue underrun colour. Symptom mitigation for the bus-arbitration starvation; the register isn't otherwise exposed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LKPbR6DY2JygHbyLjxm7Uu
291 lines
11 KiB
C
291 lines
11 KiB
C
/* DeskLock — living-room face and voice for the Tatlock butler.
|
|
*
|
|
* Boot: display + face -> audio (gong) -> Wi-Fi (C6/ESP-Hosted) -> gateway WS.
|
|
* Interaction (phase 2): touch-to-talk — tap to speak, tap again to finish.
|
|
*
|
|
* Touch is DECOUPLED from the LVGL thread: the touch callback only signals a
|
|
* semaphore; all the real work (blocking WebSocket sends, audio, face changes)
|
|
* runs in app_task. A blocking WS send on the LVGL render thread stalls the
|
|
* MIPI-DSI flush -> blue/garbage frame + task-watchdog reboot.
|
|
*/
|
|
|
|
#include "esp_log.h"
|
|
#include "esp_system.h"
|
|
#include "freertos/FreeRTOS.h"
|
|
#include "freertos/queue.h"
|
|
#include "freertos/semphr.h"
|
|
#include "freertos/task.h"
|
|
#include "bsp/esp-bsp.h"
|
|
#include "soc/mipi_dsi_bridge_struct.h"
|
|
|
|
#include "desklock.h"
|
|
|
|
/* 1 = L1 wifi driver diagnosis (SoftAP, no STA/gateway). 0 = normal app. */
|
|
#define WIFI_DIAG_MODE 0
|
|
|
|
/* 1 = deterministic load-emulation harness for the blue-flicker RCA: cycles
|
|
* isolated load types (render-only ladder, light-render+network, heavy-render+
|
|
* network, heavy-render+playback) with a log marker per phase, so we can see
|
|
* which load starves the DSI. Kept (gated off) for future bus-contention debugging.
|
|
* 0 = normal app. */
|
|
#define FACE_LOADTEST 0
|
|
|
|
static const char *TAG = "desklock";
|
|
|
|
/* All utterance framing (blocking WS sends, chime, face) runs on app_task, never
|
|
* on the LVGL touch callback nor the AFE detect/fetch thread. The wake word, VAD,
|
|
* and touch just POST an event; app_task is the single serializer of start/end,
|
|
* which removes both the fetch-thread stall and the double-start race. */
|
|
typedef enum { EV_TOUCH, EV_WAKE, EV_SPEECH_END } app_event_t;
|
|
static QueueHandle_t s_events;
|
|
|
|
static void post_event(app_event_t ev)
|
|
{
|
|
if (s_events != NULL) {
|
|
xQueueSend(s_events, &ev, 0);
|
|
}
|
|
}
|
|
|
|
/* --- these three are the fast, non-blocking hooks called from other tasks --- */
|
|
void app_on_touch(void) { post_event(EV_TOUCH); } /* LVGL touch callback */
|
|
void app_on_wake(void) { post_event(EV_WAKE); } /* AFE detect_task: wake word */
|
|
void app_on_speech_end(void) { post_event(EV_SPEECH_END); } /* AFE detect_task: VAD silence */
|
|
|
|
/* --- the actual work, all on app_task --- */
|
|
static void start_utterance(void)
|
|
{
|
|
if (!gw_connected() || audio_is_playing() || audio_capture_active()
|
|
|| face_get() == FACE_EFFORT) {
|
|
return;
|
|
}
|
|
face_activity();
|
|
audio_play_chime(); /* audible "I heard you" */
|
|
gw_send_text("{\"type\":\"utterance_start\"}"); /* must precede any audio frames */
|
|
audio_capture_start(); /* now detect_task forwards audio */
|
|
face_set(FACE_LISTENING);
|
|
ESP_LOGI(TAG, "listening…");
|
|
}
|
|
|
|
static void end_utterance(void)
|
|
{
|
|
if (!audio_capture_active()) {
|
|
return;
|
|
}
|
|
audio_capture_stop();
|
|
gw_send_text("{\"type\":\"utterance_end\"}");
|
|
face_set(FACE_PENSIVE);
|
|
ESP_LOGI(TAG, "utterance sent");
|
|
}
|
|
|
|
/* WS link dropped: abandon any half-utterance so the next wake starts fresh. */
|
|
void app_on_disconnect(void)
|
|
{
|
|
audio_capture_stop();
|
|
}
|
|
|
|
static void app_task(void *arg)
|
|
{
|
|
(void)arg;
|
|
app_event_t ev;
|
|
for (;;) {
|
|
if (xQueueReceive(s_events, &ev, portMAX_DELAY) != pdTRUE) {
|
|
continue;
|
|
}
|
|
if (ev == EV_TOUCH) {
|
|
vTaskDelay(pdMS_TO_TICKS(60)); /* debounce */
|
|
xQueueReset(s_events); /* coalesce a bouncy tap burst */
|
|
face_activity();
|
|
if (!gw_connected()) {
|
|
audio_capture_stop();
|
|
} else if (!audio_capture_active()) {
|
|
start_utterance(); /* tap = manual wake */
|
|
} else {
|
|
end_utterance(); /* tap = manual end */
|
|
}
|
|
} else if (ev == EV_WAKE) {
|
|
start_utterance();
|
|
} else if (ev == EV_SPEECH_END) {
|
|
end_utterance();
|
|
}
|
|
}
|
|
}
|
|
|
|
void app_on_playback_done(void)
|
|
{
|
|
face_set(FACE_IDLE);
|
|
}
|
|
|
|
#if FACE_LOADTEST
|
|
/* Stream real mic audio upstream for `ms` — exercises the network path (I2S-in
|
|
* DMA + esp-hosted SDIO TX, both touching PSRAM) with valid utterance framing so
|
|
* the gateway keeps the WS open. */
|
|
static void loadtest_stream(int ms)
|
|
{
|
|
if (!gw_connected()) {
|
|
vTaskDelay(pdMS_TO_TICKS(ms));
|
|
return;
|
|
}
|
|
gw_send_text("{\"type\":\"utterance_start\"}");
|
|
audio_capture_start();
|
|
vTaskDelay(pdMS_TO_TICKS(ms));
|
|
audio_capture_stop();
|
|
gw_send_text("{\"type\":\"utterance_end\"}");
|
|
}
|
|
|
|
static void loadtest_task(void *arg)
|
|
{
|
|
(void)arg;
|
|
vTaskDelay(pdMS_TO_TICKS(12000)); /* let boot + Wi-Fi + gateway settle */
|
|
const struct {
|
|
face_state_t st;
|
|
const char *name;
|
|
} LADDER[] = {
|
|
{ FACE_IDLE, "IDLE(4)" }, { FACE_PENSIVE, "PENSIVE(7)" },
|
|
{ FACE_SPEAKING, "SPEAKING(14)" }, { FACE_LISTENING, "LISTENING(16)" },
|
|
{ FACE_RAGE, "RAGE(34)" }, { FACE_EFFORT, "EFFORT(40)" },
|
|
};
|
|
char tag[24];
|
|
static uint8_t netframe[1024] = {0};
|
|
for (;;) {
|
|
ESP_LOGW("loadtest", "=== P1 render-only ladder (NO network) ===");
|
|
for (int i = 0; i < 6; i++) {
|
|
ESP_LOGW("loadtest", "P1: %s", LADDER[i].name);
|
|
face_set(LADDER[i].st);
|
|
snprintf(tag, sizeof(tag), "P1 %s", LADDER[i].name);
|
|
face_status(tag); /* phase label on screen */
|
|
vTaskDelay(pdMS_TO_TICKS(6000));
|
|
}
|
|
ESP_LOGW("loadtest", "=== P2 LIGHT render (IDLE 4) + NETWORK stream — key test ===");
|
|
face_set(FACE_IDLE);
|
|
face_status("P2 net + idle");
|
|
loadtest_stream(8000);
|
|
vTaskDelay(pdMS_TO_TICKS(6000)); /* let any reply drain */
|
|
|
|
ESP_LOGW("loadtest", "=== P3 HEAVY render (EFFORT 40) + NETWORK stream ===");
|
|
face_set(FACE_EFFORT);
|
|
face_status("P3 net + EFFORT");
|
|
loadtest_stream(8000);
|
|
vTaskDelay(pdMS_TO_TICKS(6000));
|
|
|
|
ESP_LOGW("loadtest", "=== P4 HEAVY render (EFFORT 40) + PLAYBACK dma ===");
|
|
face_set(FACE_EFFORT);
|
|
face_status("P4 play + EFFORT");
|
|
for (int i = 0; i < 3; i++) {
|
|
audio_play_gong();
|
|
vTaskDelay(pdMS_TO_TICKS(2500));
|
|
}
|
|
|
|
/* P5: the real-conversation worst case — heavy render + audio playback +
|
|
* network stream ALL AT ONCE, like the SPEAKING state (TTS audio arriving
|
|
* over WS while it plays and the rain runs). Render (P1) and each DMA source
|
|
* alone (P3/P4) were clean at 48 MHz; this stacks them to reproduce the live
|
|
* flicker. utterance framing keeps the WS accepting the frames. */
|
|
ESP_LOGW("loadtest", "=== P5 EFFORT + PLAYBACK + NETWORK (full-reply emulation) ===");
|
|
face_set(FACE_EFFORT);
|
|
face_status("P5 play+net+render");
|
|
gw_send_text("{\"type\":\"utterance_start\"}");
|
|
for (int t = 0; t < 550; t++) { /* ~8s combined load */
|
|
if (!audio_is_playing()) {
|
|
audio_play_gong(); /* I2S-out playback DMA */
|
|
face_set(FACE_EFFORT); /* playback-done flips to IDLE */
|
|
}
|
|
if (gw_connected()) {
|
|
gw_send_bin(netframe, sizeof(netframe)); /* SDIO/mempool DMA */
|
|
}
|
|
vTaskDelay(pdMS_TO_TICKS(15));
|
|
}
|
|
gw_send_text("{\"type\":\"utterance_end\"}");
|
|
vTaskDelay(pdMS_TO_TICKS(4000));
|
|
|
|
ESP_LOGW("loadtest", "=== cycle complete, repeating in 3s ===");
|
|
vTaskDelay(pdMS_TO_TICKS(3000));
|
|
}
|
|
}
|
|
#endif
|
|
|
|
#if SDIO_TX_SELFTEST
|
|
/* Temporary autonomous SDIO-TX stress test: stream mic audio upstream for 40s
|
|
* right after connecting (no user tap needed) to prove the #167 alignment fix.
|
|
* Remove once verified. */
|
|
static void sdio_tx_selftest_task(void *arg)
|
|
{
|
|
(void)arg;
|
|
vTaskDelay(pdMS_TO_TICKS(4000));
|
|
ESP_LOGW("selftest", "SDIO TX STRESS START: streaming mic audio 40s");
|
|
audio_capture_start();
|
|
for (int i = 0; i < 40; i++) {
|
|
vTaskDelay(pdMS_TO_TICKS(1000));
|
|
ESP_LOGW("selftest", "SDIO TX STRESS: alive %ds (no wedge)", i + 1);
|
|
}
|
|
audio_capture_stop();
|
|
ESP_LOGW("selftest", "SDIO TX STRESS SURVIVED 40s — #167 FIX CONFIRMED");
|
|
vTaskDelete(NULL);
|
|
}
|
|
|
|
void sdio_tx_selftest_kick(void)
|
|
{
|
|
static bool started;
|
|
if (!started) {
|
|
started = true;
|
|
xTaskCreate(sdio_tx_selftest_task, "sdiotest", 4096, NULL, 4, NULL);
|
|
}
|
|
}
|
|
#endif
|
|
|
|
void app_main(void)
|
|
{
|
|
ESP_LOGI(TAG, "DeskLock starting");
|
|
|
|
s_events = xQueueCreate(8, sizeof(app_event_t));
|
|
|
|
/* Same config bsp_display_start() uses (TRIPLE_PARTIAL, no rotation) but with a
|
|
* 16 KB LVGL task stack instead of the 8 KB default.
|
|
*
|
|
* The blue flicker's real cause is a DSI framebuffer-read underrun on the PSRAM
|
|
* bus (fixed primarily by the reduced DPI clock in the BSP). This 16 KB stack
|
|
* pairs with LV_INV_BUF_SIZE=128 (top CMakeLists), a SECONDARY bandwidth
|
|
* mitigation: 128 keeps the busy 40-stream rain as small partial redraws instead
|
|
* of collapsing into full-screen redraws, which would pile large PSRAM write
|
|
* bursts onto the DSI's read and re-provoke the underrun. The 128-entry inv
|
|
* buffer enlarges the flush stack frame, and 8 KB overflowed it by ~1 KB. */
|
|
bsp_display_cfg_t disp_cfg = {
|
|
.lv_adapter_cfg = ESP_LV_ADAPTER_DEFAULT_CONFIG(),
|
|
.rotation = ESP_LV_ADAPTER_ROTATE_0,
|
|
.tear_avoid_mode = ESP_LV_ADAPTER_TEAR_AVOID_MODE_TRIPLE_PARTIAL,
|
|
.touch_flags = { .swap_xy = 0, .mirror_x = 0, .mirror_y = 0 },
|
|
};
|
|
disp_cfg.lv_adapter_cfg.task_stack_size = 16 * 1024;
|
|
bsp_display_start_with_config(&disp_cfg);
|
|
|
|
/* Raise the DSI bridge FIFO "almost empty" threshold (hardware default 512)
|
|
* so the bridge demands a DMA refill with more slack still in the FIFO. Under
|
|
* load, esp-hosted's SDIO DMA bursts win the shared PSRAM bus and briefly stall
|
|
* the DSI's continuous framebuffer read; a bigger refill margin lets the FIFO
|
|
* ride out that stall instead of draining to the hardware underrun colour (the
|
|
* blue flash). Symptom mitigation for the bus-arbitration starvation. */
|
|
MIPI_DSI_BRIDGE.raw_buf_almost_empty_thrd.dsi_raw_buf_almost_empty_thrd = 1024;
|
|
|
|
bsp_display_backlight_on();
|
|
face_init();
|
|
|
|
audio_init();
|
|
if (esp_reset_reason() == ESP_RST_POWERON) {
|
|
audio_play_gong(); /* cold boot only — recovery reboots stay silent */
|
|
}
|
|
|
|
xTaskCreate(app_task, "app", 4096, NULL, 5, NULL);
|
|
|
|
#if WIFI_DIAG_MODE
|
|
wifi_diag_start();
|
|
#else
|
|
net_start();
|
|
c6_ota_start(); /* one-shot C6 radio firmware update — remove once proven */
|
|
#endif
|
|
|
|
ESP_LOGI(TAG, "DeskLock up");
|
|
|
|
#if FACE_LOADTEST
|
|
xTaskCreate(loadtest_task, "loadtest", 4096, NULL, 4, NULL);
|
|
#endif
|
|
}
|