Additive gong synthesis: seven inharmonic partials with per-partial decay (fundamental 165Hz rings 2.6s, highs die in 0.2s), a detuned pair for shimmer, 45ms distant attack, and three feedback-comb reflections (95/210/370ms, net gain .93) for a dense stone-space tail. Normalized to -12dBFS at codec volume 58: quiet but voluminous. Synthesized into PSRAM at boot (~4s), plays 5s, blocking is fine for bring-up — audio moves to its own task in phase 2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
169 lines
5.2 KiB
C
169 lines
5.2 KiB
C
/*
|
|
* DeskLock — living-room face and voice for the Tatlock butler.
|
|
*
|
|
* Boot sequence (planned):
|
|
* 1. BSP init: display + LVGL, touch, audio codec (ES8311/ES7210)
|
|
* 2. Wi-Fi up via ESP32-C6 (ESP-Hosted over SDIO)
|
|
* 3. WebSocket connection to the DeskLock gateway
|
|
* 4. Face state machine: idle / listening / thinking / speaking
|
|
*
|
|
* Current stage: bring-up — display + a placeholder face.
|
|
*/
|
|
|
|
#include <math.h>
|
|
#include <stdlib.h>
|
|
|
|
#include "esp_log.h"
|
|
#include "bsp/esp-bsp.h"
|
|
#include "esp_codec_dev.h"
|
|
#include "lvgl.h"
|
|
|
|
static const char *TAG = "desklock";
|
|
|
|
#define GONG_RATE 16000
|
|
#define GONG_SECONDS 5
|
|
#define GONG_SAMPLES (GONG_RATE * GONG_SECONDS)
|
|
#define GONG_VOLUME 58 /* codec volume: discreet, not loud */
|
|
#define GONG_PEAK 8000.0f /* ~-12 dBFS headroom */
|
|
|
|
/* A distant temple gong in a stone space.
|
|
*
|
|
* Inharmonic partials with per-partial decay (lows ring for seconds, highs
|
|
* die fast), a detuned fundamental pair for slow shimmer, a soft 45 ms
|
|
* attack so the strike reads as far away, and three early reflections that
|
|
* stand in for cathedral walls. Fundamental sits at 165 Hz — low enough to
|
|
* feel voluminous, high enough for the bare 2 W speaker to reproduce.
|
|
*/
|
|
static const struct {
|
|
float ratio;
|
|
float amp;
|
|
float tau; /* seconds to 1/e */
|
|
} gong_partials[] = {
|
|
{ 1.000f, 0.34f, 2.6f },
|
|
{ 1.004f, 0.22f, 2.2f }, /* beating partner -> shimmer */
|
|
{ 1.590f, 0.16f, 1.4f },
|
|
{ 2.140f, 0.11f, 0.9f },
|
|
{ 2.760f, 0.08f, 0.55f },
|
|
{ 3.570f, 0.05f, 0.35f },
|
|
{ 4.800f, 0.03f, 0.22f },
|
|
};
|
|
|
|
static const struct {
|
|
int delay_ms;
|
|
float gain;
|
|
} gong_echoes[] = { { 95, 0.45f }, { 210, 0.30f }, { 370, 0.18f } };
|
|
|
|
static void play_boot_gong(void)
|
|
{
|
|
esp_codec_dev_handle_t spk = bsp_audio_codec_speaker_init();
|
|
if (spk == NULL) {
|
|
ESP_LOGW(TAG, "speaker init failed — no gong");
|
|
return;
|
|
}
|
|
esp_codec_dev_sample_info_t fs = {
|
|
.sample_rate = GONG_RATE,
|
|
.channel = 1,
|
|
.bits_per_sample = 16,
|
|
};
|
|
esp_codec_dev_set_out_vol(spk, GONG_VOLUME);
|
|
esp_codec_dev_open(spk, &fs);
|
|
|
|
float *dry = heap_caps_calloc(GONG_SAMPLES, sizeof(float), MALLOC_CAP_SPIRAM);
|
|
int16_t *pcm = heap_caps_malloc(GONG_SAMPLES * sizeof(int16_t), MALLOC_CAP_SPIRAM);
|
|
if (dry == NULL || pcm == NULL) {
|
|
ESP_LOGW(TAG, "no memory for gong");
|
|
goto out;
|
|
}
|
|
|
|
for (size_t p = 0; p < sizeof(gong_partials) / sizeof(gong_partials[0]); p++) {
|
|
float step = 2.0f * (float)M_PI * 165.0f * gong_partials[p].ratio / GONG_RATE;
|
|
float decay = expf(-1.0f / (gong_partials[p].tau * GONG_RATE));
|
|
float env = gong_partials[p].amp;
|
|
float phase = 0.0f;
|
|
for (int i = 0; i < GONG_SAMPLES; i++) {
|
|
phase += step;
|
|
env *= decay;
|
|
dry[i] += sinf(phase) * env;
|
|
}
|
|
}
|
|
|
|
const int attack = GONG_RATE * 45 / 1000; /* distant, not percussive */
|
|
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(gong_echoes) / sizeof(gong_echoes[0]); e++) {
|
|
int j = i - GONG_RATE * gong_echoes[e].delay_ms / 1000;
|
|
if (j >= 0) {
|
|
s += dry[j] * gong_echoes[e].gain;
|
|
}
|
|
}
|
|
const int fade = GONG_RATE * 2 / 5; /* last 400 ms to silence */
|
|
if (i > GONG_SAMPLES - fade) {
|
|
s *= (float)(GONG_SAMPLES - i) / fade;
|
|
}
|
|
dry[i] = s;
|
|
if (fabsf(s) > peak) {
|
|
peak = fabsf(s);
|
|
}
|
|
}
|
|
for (int i = 0; i < GONG_SAMPLES; i++) {
|
|
pcm[i] = (int16_t)(dry[i] * (GONG_PEAK / peak));
|
|
}
|
|
|
|
esp_codec_dev_write(spk, pcm, GONG_SAMPLES * sizeof(int16_t));
|
|
ESP_LOGI(TAG, "boot gong played");
|
|
|
|
out:
|
|
free(dry);
|
|
free(pcm);
|
|
esp_codec_dev_close(spk);
|
|
}
|
|
|
|
static void show_placeholder_face(void)
|
|
{
|
|
bsp_display_lock(0);
|
|
|
|
lv_obj_t *scr = lv_screen_active();
|
|
lv_obj_set_style_bg_color(scr, lv_color_black(), 0);
|
|
|
|
lv_obj_t *eyes = lv_label_create(scr);
|
|
lv_label_set_text(eyes, "- -");
|
|
lv_obj_set_style_text_color(eyes, lv_color_hex(0xADFFC8), 0);
|
|
lv_obj_set_style_text_font(eyes, &lv_font_montserrat_48, 0);
|
|
lv_obj_align(eyes, LV_ALIGN_CENTER, 0, -80);
|
|
|
|
lv_obj_t *mouth = lv_label_create(scr);
|
|
lv_label_set_text(mouth, "\\_/");
|
|
lv_obj_set_style_text_color(mouth, lv_color_hex(0xADFFC8), 0);
|
|
lv_obj_set_style_text_font(mouth, &lv_font_montserrat_48, 0);
|
|
lv_obj_align(mouth, LV_ALIGN_CENTER, 0, 40);
|
|
|
|
lv_obj_t *tag = lv_label_create(scr);
|
|
lv_label_set_text(tag, "DESKLOCK");
|
|
lv_obj_set_style_text_color(tag, lv_color_hex(0x2F7A4B), 0);
|
|
lv_obj_align(tag, LV_ALIGN_CENTER, 0, 160);
|
|
|
|
bsp_display_unlock();
|
|
}
|
|
|
|
void app_main(void)
|
|
{
|
|
ESP_LOGI(TAG, "DeskLock starting");
|
|
|
|
bsp_display_start();
|
|
bsp_display_backlight_on();
|
|
show_placeholder_face();
|
|
play_boot_gong();
|
|
|
|
ESP_LOGI(TAG, "DeskLock up (placeholder face)");
|
|
|
|
/* TODO(face): full LVGL face state machine per docs/architecture.md
|
|
* TODO(net): Wi-Fi via ESP-Hosted (C6), WebSocket to gateway
|
|
* TODO(voice): ES7210 capture upstream, ES8311 playback downstream
|
|
*/
|
|
}
|