TOUCH CRASH (the blue screen): app_on_touch ran a blocking WebSocket send (up to 5s) directly in the LVGL touch callback, stalling the MIPI-DSI flush into a garbage/blue frame + task-watchdog reboot on every tap. Now touch_cb only gives a semaphore; a dedicated app_task does the blocking sends, audio, and face changes off the render thread. WS send timeout cut 5s to 1.5s as belt-and-braces. RAIN = REACHABILITY (user request): rain now falls only while the gateway WebSocket is live (gated on gw_connected in rain_tick). It drains gracefully on disconnect, resumes on reconnect, a genuine glanceable reachable signal. Idle density bumped 2 to 4 so connected-idle reads distinctly from disconnected-black. CALM THE WEDGE CHURN (user request): transient wifi/WS drops no longer slam to the x_x error face or a CONNECTING banner. Boot goes straight to the calm idle face (dry until connected). Only a sustained 30s+ outage escalates to x_x (clock_cb); the ~15s wedge-recovery just shows a brief rain pause. Two fixes from adversarial concurrency review before flashing: - persistent single capture task (was xTaskCreate per utterance; a rapid re-tap or WS-stop-vs-app-start race could put two readers on one mic/I2S handle and corrupt the codec) - reset s_talking on disconnect (app_on_disconnect) so the first tap after a mid-utterance drop starts fresh, not the stop branch Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
217 lines
5.9 KiB
C
217 lines
5.9 KiB
C
/* Audio: gong synthesis, mic capture -> gateway, gateway PCM -> speaker. */
|
|
|
|
#include <math.h>
|
|
#include <string.h>
|
|
|
|
#include "esp_heap_caps.h"
|
|
#include "esp_log.h"
|
|
#include "freertos/FreeRTOS.h"
|
|
#include "freertos/queue.h"
|
|
#include "freertos/task.h"
|
|
#include "bsp/esp-bsp.h"
|
|
#include "esp_codec_dev.h"
|
|
|
|
#include "desklock.h"
|
|
|
|
static const char *TAG = "audio";
|
|
|
|
#define RATE 16000
|
|
#define CAPTURE_CHUNK 3200 /* 100 ms */
|
|
#define REPLY_MAX (RATE * 2 * 60) /* 60 s of reply audio */
|
|
|
|
#define GONG_SECONDS 5
|
|
#define GONG_SAMPLES (RATE * GONG_SECONDS)
|
|
#define GONG_VOLUME 75
|
|
#define GONG_PEAK 14000.0f
|
|
|
|
static esp_codec_dev_handle_t s_spk;
|
|
static esp_codec_dev_handle_t s_mic;
|
|
static int16_t *s_gong;
|
|
|
|
static uint8_t *s_reply;
|
|
static volatile size_t s_reply_len;
|
|
static volatile bool s_playing;
|
|
static volatile bool s_capturing;
|
|
|
|
typedef enum { JOB_GONG, JOB_REPLY } job_t;
|
|
static QueueHandle_t s_jobs;
|
|
static TaskHandle_t s_capture_task;
|
|
|
|
/* --- gong: see docs/architecture.md "Sound signature" --- */
|
|
|
|
static const struct {
|
|
float ratio, amp, tau;
|
|
} PARTIALS[] = {
|
|
{ 1.000f, 0.30f, 2.6f },
|
|
{ 1.004f, 0.22f, 2.2f },
|
|
{ 1.590f, 0.22f, 1.4f },
|
|
{ 2.140f, 0.16f, 0.9f },
|
|
{ 2.760f, 0.10f, 0.55f },
|
|
{ 3.570f, 0.05f, 0.35f },
|
|
{ 4.800f, 0.03f, 0.22f },
|
|
};
|
|
static const struct {
|
|
int delay_ms;
|
|
float gain;
|
|
} ECHOES[] = { { 95, 0.45f }, { 210, 0.30f }, { 370, 0.18f } };
|
|
|
|
static void synth_gong(void)
|
|
{
|
|
float *dry = heap_caps_calloc(GONG_SAMPLES, sizeof(float), MALLOC_CAP_SPIRAM);
|
|
s_gong = heap_caps_malloc(GONG_SAMPLES * sizeof(int16_t), MALLOC_CAP_SPIRAM);
|
|
if (dry == NULL || s_gong == NULL) {
|
|
free(dry);
|
|
return;
|
|
}
|
|
for (size_t p = 0; p < sizeof(PARTIALS) / sizeof(PARTIALS[0]); p++) {
|
|
float step = 2.0f * (float)M_PI * 220.0f * PARTIALS[p].ratio / RATE;
|
|
float decay = expf(-1.0f / (PARTIALS[p].tau * RATE));
|
|
float env = PARTIALS[p].amp, phase = 0.0f;
|
|
for (int i = 0; i < GONG_SAMPLES; i++) {
|
|
phase += step;
|
|
env *= decay;
|
|
dry[i] += sinf(phase) * env;
|
|
if ((i & 0x3FFF) == 0) {
|
|
vTaskDelay(1); /* keep IDLE0 fed; synth is not latency-critical */
|
|
}
|
|
}
|
|
}
|
|
const int attack = RATE * 45 / 1000;
|
|
for (int i = 0; i < attack; i++) {
|
|
dry[i] *= (float)i / attack;
|
|
}
|
|
float peak = 1e-6f;
|
|
for (int i = 0; i < GONG_SAMPLES; i++) {
|
|
float s = dry[i];
|
|
for (size_t e = 0; e < sizeof(ECHOES) / sizeof(ECHOES[0]); e++) {
|
|
int j = i - RATE * ECHOES[e].delay_ms / 1000;
|
|
if (j >= 0) {
|
|
s += dry[j] * ECHOES[e].gain;
|
|
}
|
|
}
|
|
const int fade = RATE * 2 / 5;
|
|
if (i > GONG_SAMPLES - fade) {
|
|
s *= (float)(GONG_SAMPLES - i) / fade;
|
|
}
|
|
dry[i] = s;
|
|
if (fabsf(s) > peak) {
|
|
peak = fabsf(s);
|
|
}
|
|
if ((i & 0x3FFF) == 0) {
|
|
vTaskDelay(1);
|
|
}
|
|
}
|
|
for (int i = 0; i < GONG_SAMPLES; i++) {
|
|
s_gong[i] = (int16_t)(dry[i] * (GONG_PEAK / peak));
|
|
}
|
|
free(dry);
|
|
}
|
|
|
|
/* --- playback worker --- */
|
|
|
|
static void audio_task(void *arg)
|
|
{
|
|
(void)arg;
|
|
synth_gong();
|
|
ESP_LOGI(TAG, "gong ready");
|
|
job_t job;
|
|
while (xQueueReceive(s_jobs, &job, portMAX_DELAY) == pdTRUE) {
|
|
if (job == JOB_GONG && s_gong != NULL) {
|
|
esp_codec_dev_write(s_spk, s_gong, GONG_SAMPLES * sizeof(int16_t));
|
|
} else if (job == JOB_REPLY) {
|
|
s_playing = true;
|
|
esp_codec_dev_write(s_spk, s_reply, s_reply_len);
|
|
s_playing = false;
|
|
app_on_playback_done();
|
|
}
|
|
}
|
|
}
|
|
|
|
/* One persistent capture task, created once. It parks on a notification until
|
|
* capture is requested, then streams mic -> gateway while s_capturing holds.
|
|
* This makes capture single-instance: no create/delete restart race, so a rapid
|
|
* re-tap (or a WS-disconnect stop racing an app_task start) can never put two
|
|
* readers on the one mic/I2S handle. */
|
|
static void capture_task(void *arg)
|
|
{
|
|
(void)arg;
|
|
uint8_t *chunk = heap_caps_malloc(CAPTURE_CHUNK, MALLOC_CAP_DEFAULT);
|
|
for (;;) {
|
|
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
|
|
while (s_capturing) {
|
|
if (esp_codec_dev_read(s_mic, chunk, CAPTURE_CHUNK) == ESP_CODEC_DEV_OK) {
|
|
gw_send_bin(chunk, CAPTURE_CHUNK);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void audio_init(void)
|
|
{
|
|
esp_codec_dev_sample_info_t fs = {
|
|
.sample_rate = RATE,
|
|
.channel = 1,
|
|
.bits_per_sample = 16,
|
|
};
|
|
s_spk = bsp_audio_codec_speaker_init();
|
|
if (s_spk != NULL) {
|
|
esp_codec_dev_set_out_vol(s_spk, GONG_VOLUME);
|
|
esp_codec_dev_open(s_spk, &fs);
|
|
}
|
|
s_mic = bsp_audio_codec_microphone_init();
|
|
if (s_mic != NULL) {
|
|
esp_codec_dev_set_in_gain(s_mic, 30.0f);
|
|
esp_codec_dev_open(s_mic, &fs);
|
|
}
|
|
s_reply = heap_caps_malloc(REPLY_MAX, MALLOC_CAP_SPIRAM);
|
|
s_jobs = xQueueCreate(4, sizeof(job_t));
|
|
xTaskCreate(audio_task, "audio", 4096, NULL, 5, NULL);
|
|
xTaskCreate(capture_task, "capture", 4096, NULL, 6, &s_capture_task);
|
|
ESP_LOGI(TAG, "audio up (spk=%d mic=%d)", s_spk != NULL, s_mic != NULL);
|
|
}
|
|
|
|
void audio_play_gong(void)
|
|
{
|
|
job_t job = JOB_GONG;
|
|
xQueueSend(s_jobs, &job, 0);
|
|
}
|
|
|
|
void audio_capture_start(void)
|
|
{
|
|
if (s_mic == NULL || s_capturing || s_capture_task == NULL) {
|
|
return;
|
|
}
|
|
s_capturing = true;
|
|
xTaskNotifyGive(s_capture_task); /* wake the persistent task's inner loop */
|
|
}
|
|
|
|
void audio_capture_stop(void)
|
|
{
|
|
s_capturing = false;
|
|
}
|
|
|
|
void audio_playback_begin(void)
|
|
{
|
|
s_reply_len = 0;
|
|
}
|
|
|
|
void audio_playback_feed(const uint8_t *data, size_t len)
|
|
{
|
|
if (s_reply == NULL || s_reply_len + len > REPLY_MAX) {
|
|
return;
|
|
}
|
|
memcpy(s_reply + s_reply_len, data, len);
|
|
s_reply_len += len;
|
|
}
|
|
|
|
void audio_playback_end(void)
|
|
{
|
|
job_t job = JOB_REPLY;
|
|
xQueueSend(s_jobs, &job, 0);
|
|
}
|
|
|
|
bool audio_is_playing(void)
|
|
{
|
|
return s_playing;
|
|
}
|