43dc1478b1
The BOX-3 touch gamebook had several blocking bugs that made the voiced+foley story packs unusable. This fixes them end to end. - Stack overflow on story start: gb_play_task kept a 2 KB buffer on its 4 KB stack and rebooted the board. Buffers are now static (the task is a singleton) and the stack is 6 KB. - No sound at all: the ES8311 codec was never initialised — main.c wrote raw I2S to a muted DAC. Output (and the mic) now go through the BSP esp_codec_dev (bsp_audio_codec_speaker/microphone_init, esp_codec_dev_write/read). The power amplifier (GPIO46) is also forced on and volume set to 100, as the es8311 pa_pin handling did not drive it in practice. - First passage silent: gb_play re-mounted the SD that the gamebook had already mounted; the failed second bsp_sdcard_mount tore down the VFS so the first fopen failed. Now we open the file first and only mount if that fails. A short silence lead-in also primes the codec/PA ramp. - No accents: the gamebook used ASCII-only lv_font_montserrat_14/24. Added custom Montserrat fonts (font_fr_14/24, Latin-1 + œŒŸ « » ’ …, uncompressed so they render without LV_USE_FONT_COMPRESSED). - Mic/voice streaming gated behind CONFIG_BOX3_VOICE_STREAMING (default off): as a gamebook the BOX-3 needs no mic, and the constant codec reads + bridge reconnects interfered with playback and flooded the log. plip_virtual + cmd_exec handle types switched from i2s_chan_handle_t to esp_codec_dev_handle_t accordingly.
532 lines
20 KiB
C
532 lines
20 KiB
C
/*
|
|
* Zacus BOX-3 Voice — ESP-IDF Application Entry Point
|
|
*
|
|
* ESP32-S3-BOX-3 voice pipeline for "Le Mystere du Professeur Zacus"
|
|
* escape room game. Handles wake-word detection, voice capture,
|
|
* WebSocket streaming to the mascarade voice bridge, and speaker output.
|
|
*/
|
|
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
#include <math.h>
|
|
|
|
#include "freertos/FreeRTOS.h"
|
|
#include "freertos/task.h"
|
|
|
|
#include "esp_log.h"
|
|
#include "esp_err.h"
|
|
#include "esp_system.h"
|
|
#include "esp_wifi.h"
|
|
#include "esp_event.h"
|
|
#include "nvs_flash.h"
|
|
|
|
#include "driver/i2s_std.h"
|
|
#include "driver/gpio.h"
|
|
|
|
#include "board_config.h"
|
|
#include "voice_ws_client.h"
|
|
#include "scenario_server.h"
|
|
#include "plip_virtual.h"
|
|
#include "plip_ui.h"
|
|
#include "gamebook.h"
|
|
#include "stimulus.h"
|
|
#include "scenario_mesh.h"
|
|
#include "cmd_exec.h"
|
|
|
|
/* BSP header — provided by espressif/esp-box component */
|
|
#include "bsp/esp-bsp.h"
|
|
|
|
static const char *TAG = "zacus-voice";
|
|
|
|
/* --------------- Shared state --------------- */
|
|
|
|
/* Audio now goes through the ES8311 (speaker) / ES7210 (mic) codecs via the
|
|
* BSP + esp_codec_dev. bsp_audio_init() owns the single shared I2S port; we
|
|
* must NOT create our own raw I2S channels (that left the ES8311 DAC unpowered
|
|
* and the speaker silent). */
|
|
static esp_codec_dev_handle_t s_spk_codec = NULL; /* ES8311 output (DAC) */
|
|
static esp_codec_dev_handle_t s_mic_codec = NULL; /* ES7210 input (ADC) */
|
|
static volatile bool s_wifi_connected = false;
|
|
static volatile bool s_voice_streaming = false;
|
|
|
|
/* --------------- Forward declarations --------------- */
|
|
|
|
static esp_err_t wifi_init_sta(void);
|
|
static void audio_test_tone(void);
|
|
static void mic_monitor_task(void *arg);
|
|
static void tts_audio_callback(const uint8_t *data, size_t len);
|
|
|
|
/* --------------- WiFi event handler --------------- */
|
|
|
|
static void wifi_event_handler(void *arg, esp_event_base_t event_base,
|
|
int32_t event_id, void *event_data)
|
|
{
|
|
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) {
|
|
esp_wifi_connect();
|
|
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
|
|
ESP_LOGW(TAG, "WiFi disconnected, retrying...");
|
|
vTaskDelay(pdMS_TO_TICKS(2000));
|
|
esp_wifi_connect();
|
|
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
|
|
ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data;
|
|
ESP_LOGI(TAG, "Connected — IP: " IPSTR, IP2STR(&event->ip_info.ip));
|
|
s_wifi_connected = true;
|
|
}
|
|
}
|
|
|
|
/* --------------- WiFi station init --------------- */
|
|
|
|
static esp_err_t wifi_init_sta(void)
|
|
{
|
|
ESP_ERROR_CHECK(esp_netif_init());
|
|
ESP_ERROR_CHECK(esp_event_loop_create_default());
|
|
esp_netif_create_default_wifi_sta();
|
|
|
|
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
|
|
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
|
|
|
|
ESP_ERROR_CHECK(esp_event_handler_instance_register(
|
|
WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL, NULL));
|
|
ESP_ERROR_CHECK(esp_event_handler_instance_register(
|
|
IP_EVENT, IP_EVENT_STA_GOT_IP, &wifi_event_handler, NULL, NULL));
|
|
|
|
wifi_config_t wifi_config = {
|
|
.sta = {
|
|
.ssid = CONFIG_ZACUS_WIFI_SSID,
|
|
.password = CONFIG_ZACUS_WIFI_PASSWORD,
|
|
.threshold.authmode = WIFI_AUTH_WPA2_PSK,
|
|
.channel = CONFIG_ZACUS_WIFI_CHANNEL, /* co-channel master for ESP-NOW relay (0 = auto) */
|
|
},
|
|
};
|
|
|
|
/* Allow open networks if no password configured */
|
|
if (strlen(CONFIG_ZACUS_WIFI_PASSWORD) == 0) {
|
|
wifi_config.sta.threshold.authmode = WIFI_AUTH_OPEN;
|
|
}
|
|
|
|
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
|
|
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
|
|
ESP_ERROR_CHECK(esp_wifi_start());
|
|
|
|
ESP_LOGI(TAG, "WiFi STA started, connecting to '%s'...", CONFIG_ZACUS_WIFI_SSID);
|
|
return ESP_OK;
|
|
}
|
|
|
|
/* --------------- Test tone (440 Hz sine) --------------- */
|
|
|
|
// Play a single tone on the speaker (blocking). Public so the stimulus
|
|
// generator can sequence a melody for the master's microphone.
|
|
void audio_play_tone(float frequency, int duration_ms)
|
|
{
|
|
if (!s_spk_codec || duration_ms <= 0) return;
|
|
const int total_samples = AUDIO_SAMPLE_RATE * duration_ms / 1000;
|
|
const float amplitude = 16000.0f;
|
|
int16_t buffer[256];
|
|
int sample_idx = 0;
|
|
while (sample_idx < total_samples) {
|
|
int chunk = (total_samples - sample_idx < 256)
|
|
? (total_samples - sample_idx) : 256;
|
|
for (int i = 0; i < chunk; i++) {
|
|
float t = (float)(sample_idx + i) / (float)AUDIO_SAMPLE_RATE;
|
|
buffer[i] = (int16_t)(amplitude * sinf(2.0f * M_PI * frequency * t));
|
|
}
|
|
esp_codec_dev_write(s_spk_codec, buffer, chunk * sizeof(int16_t));
|
|
sample_idx += chunk;
|
|
}
|
|
}
|
|
|
|
static void audio_test_tone(void)
|
|
{
|
|
if (!s_spk_codec) {
|
|
ESP_LOGW(TAG, "Speaker not initialized, skipping test tone");
|
|
return;
|
|
}
|
|
|
|
ESP_LOGI(TAG, "Playing 440 Hz test tone (1 second)...");
|
|
|
|
/* Generate 1 second of 440 Hz sine wave through the ES8311 codec */
|
|
const int duration_ms = 1000;
|
|
const int total_samples = AUDIO_SAMPLE_RATE * duration_ms / 1000;
|
|
const float frequency = 440.0f;
|
|
const float amplitude = 16000.0f;
|
|
|
|
int16_t buffer[256];
|
|
int sample_idx = 0;
|
|
|
|
while (sample_idx < total_samples) {
|
|
int chunk = (total_samples - sample_idx < 256) ? (total_samples - sample_idx) : 256;
|
|
for (int i = 0; i < chunk; i++) {
|
|
float t = (float)(sample_idx + i) / (float)AUDIO_SAMPLE_RATE;
|
|
buffer[i] = (int16_t)(amplitude * sinf(2.0f * M_PI * frequency * t));
|
|
}
|
|
esp_codec_dev_write(s_spk_codec, buffer, chunk * sizeof(int16_t));
|
|
sample_idx += chunk;
|
|
}
|
|
|
|
vTaskDelay(pdMS_TO_TICKS(100)); /* Let buffer drain */
|
|
ESP_LOGI(TAG, "Test tone complete");
|
|
}
|
|
|
|
/* --------------- TTS audio callback --------------- */
|
|
|
|
static void tts_audio_callback(const uint8_t *data, size_t len)
|
|
{
|
|
/*
|
|
* Called from WebSocket event context with incoming TTS PCM16 data.
|
|
* Write directly to the speaker I2S channel.
|
|
*/
|
|
if (!s_spk_codec || len == 0) {
|
|
return;
|
|
}
|
|
|
|
int ret = esp_codec_dev_write(s_spk_codec, (void *)data, (int)len);
|
|
if (ret != ESP_CODEC_DEV_OK) {
|
|
ESP_LOGW(TAG, "TTS write to speaker failed: %d", ret);
|
|
}
|
|
}
|
|
|
|
/* --------------- Speaker init --------------- */
|
|
|
|
static esp_err_t speaker_init(void)
|
|
{
|
|
/* Init the ES8311 output codec via the BSP. This also runs bsp_audio_init()
|
|
* (shared duplex I2S port) + bsp_i2c_init() and configures the power
|
|
* amplifier (BSP_POWER_AMP_IO = GPIO46). The ES8311 DAC is configured over
|
|
* I2C here — without this the speaker stays muted no matter what we push on
|
|
* I2S. */
|
|
s_spk_codec = bsp_audio_codec_speaker_init();
|
|
if (!s_spk_codec) {
|
|
ESP_LOGE(TAG, "bsp_audio_codec_speaker_init failed — speaker unavailable");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
/* All speaker audio in this app is 16 kHz / 16-bit / mono PCM. */
|
|
esp_codec_dev_sample_info_t fs = {
|
|
.bits_per_sample = AUDIO_BITS,
|
|
.channel = AUDIO_CHANNELS,
|
|
.channel_mask = 0,
|
|
.sample_rate = AUDIO_SAMPLE_RATE,
|
|
.mclk_multiple = 0,
|
|
};
|
|
int ret = esp_codec_dev_open(s_spk_codec, &fs);
|
|
if (ret != ESP_CODEC_DEV_OK) {
|
|
ESP_LOGE(TAG, "esp_codec_dev_open (speaker) failed: %d", ret);
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
/* Output volume 0..100. */
|
|
esp_codec_dev_set_out_vol(s_spk_codec, 100);
|
|
|
|
/* Belt-and-suspenders: the es8311 driver is configured with pa_pin=GPIO46
|
|
* (pa_reverted=false) and should raise it on open, but the speaker stays
|
|
* silent in practice — force the power amplifier on explicitly. */
|
|
gpio_set_direction(BOX3_PA_ENABLE, GPIO_MODE_OUTPUT);
|
|
gpio_set_level(BOX3_PA_ENABLE, 1);
|
|
|
|
ESP_LOGI(TAG, "Speaker codec (ES8311) initialized @ %d Hz, vol=100, PA on",
|
|
AUDIO_SAMPLE_RATE);
|
|
return ESP_OK;
|
|
}
|
|
|
|
/* --------------- Microphone task --------------- */
|
|
|
|
static void mic_monitor_task(void *arg)
|
|
{
|
|
ESP_LOGI(TAG, "Starting mic capture task...");
|
|
|
|
/* Init the ES7210 input codec via the BSP. It shares the same I2S port
|
|
* (already created by speaker_init -> bsp_audio_init); creating a separate
|
|
* raw I2S channel here would conflict with that shared port. */
|
|
s_mic_codec = bsp_audio_codec_microphone_init();
|
|
if (!s_mic_codec) {
|
|
ESP_LOGE(TAG, "bsp_audio_codec_microphone_init failed — mic unavailable");
|
|
vTaskDelete(NULL);
|
|
return;
|
|
}
|
|
|
|
esp_codec_dev_sample_info_t fs = {
|
|
.bits_per_sample = AUDIO_BITS,
|
|
.channel = AUDIO_CHANNELS,
|
|
.channel_mask = 0,
|
|
.sample_rate = AUDIO_SAMPLE_RATE,
|
|
.mclk_multiple = 0,
|
|
};
|
|
int oret = esp_codec_dev_open(s_mic_codec, &fs);
|
|
if (oret != ESP_CODEC_DEV_OK) {
|
|
ESP_LOGE(TAG, "esp_codec_dev_open (mic) failed: %d", oret);
|
|
vTaskDelete(NULL);
|
|
return;
|
|
}
|
|
/* ES7210 input gain (dB). 30 dB is a sane default for the BOX-3 mic array. */
|
|
esp_codec_dev_set_in_gain(s_mic_codec, 30.0f);
|
|
|
|
int16_t buffer[AUDIO_FRAME_SAMPLES];
|
|
int rms_log_counter = 0;
|
|
|
|
while (1) {
|
|
int ret = esp_codec_dev_read(s_mic_codec, buffer,
|
|
AUDIO_FRAME_SAMPLES * sizeof(int16_t));
|
|
if (ret != ESP_CODEC_DEV_OK) {
|
|
ESP_LOGW(TAG, "Mic read error: %d", ret);
|
|
vTaskDelay(pdMS_TO_TICKS(20));
|
|
continue;
|
|
}
|
|
|
|
int samples = AUDIO_FRAME_SAMPLES;
|
|
|
|
/* If voice streaming is active, send PCM to bridge */
|
|
if (s_voice_streaming) {
|
|
voice_state_t st = voice_ws_get_state();
|
|
if (st == VOICE_STATE_LISTENING || st == VOICE_STATE_READY) {
|
|
voice_ws_send_audio(buffer, samples);
|
|
}
|
|
}
|
|
|
|
/* Log RMS periodically (every ~500ms = 25 frames at 20ms) */
|
|
rms_log_counter++;
|
|
if (rms_log_counter >= 25) {
|
|
rms_log_counter = 0;
|
|
double sum_sq = 0.0;
|
|
for (int i = 0; i < samples; i++) {
|
|
sum_sq += (double)buffer[i] * (double)buffer[i];
|
|
}
|
|
double rms = sqrt(sum_sq / samples);
|
|
float db = 20.0f * log10f((float)rms + 1.0f);
|
|
ESP_LOGI(TAG, "Mic RMS: %.1f (~%.1f dB) streaming=%s",
|
|
rms, db, s_voice_streaming ? "yes" : "no");
|
|
}
|
|
}
|
|
}
|
|
|
|
/* --------------- Voice bridge connection task --------------- */
|
|
|
|
static void voice_bridge_task(void *arg)
|
|
{
|
|
/* Wait for WiFi to be connected */
|
|
while (!s_wifi_connected) {
|
|
vTaskDelay(pdMS_TO_TICKS(500));
|
|
}
|
|
ESP_LOGI(TAG, "WiFi connected — initializing voice bridge...");
|
|
|
|
/* Small delay for network stack to settle */
|
|
vTaskDelay(pdMS_TO_TICKS(1000));
|
|
|
|
esp_err_t ret = voice_ws_init();
|
|
if (ret != ESP_OK) {
|
|
ESP_LOGE(TAG, "Voice WS init failed: %s", esp_err_to_name(ret));
|
|
vTaskDelete(NULL);
|
|
return;
|
|
}
|
|
|
|
/* Set TTS audio callback to play through speaker */
|
|
voice_ws_set_audio_callback(tts_audio_callback);
|
|
|
|
ret = voice_ws_connect();
|
|
if (ret == ESP_OK) {
|
|
ESP_LOGI(TAG, "Voice bridge connected and ready!");
|
|
/* Enable streaming — for now always on (wake-word stub).
|
|
* TODO: Gate this behind actual wake-word detection from ESP-SR.
|
|
* For prototype: use BOX-3 BOOT button (GPIO0) to toggle streaming. */
|
|
s_voice_streaming = true;
|
|
ESP_LOGI(TAG, "Voice streaming enabled (continuous mode)");
|
|
} else if (ret == ESP_ERR_TIMEOUT) {
|
|
ESP_LOGW(TAG, "Bridge handshake timed out — will keep retrying via reconnect");
|
|
/* Streaming will be enabled when hello_ack arrives */
|
|
s_voice_streaming = true;
|
|
} else {
|
|
ESP_LOGE(TAG, "Voice bridge connection failed: %s", esp_err_to_name(ret));
|
|
}
|
|
|
|
vTaskDelete(NULL);
|
|
}
|
|
|
|
/* --------------- Button handler (BOOT button = GPIO0) --------------- */
|
|
|
|
static void button_task(void *arg)
|
|
{
|
|
/* Use BOOT button (GPIO0) to toggle voice streaming on/off.
|
|
* This is a wake-word placeholder — press to start/stop listening.
|
|
*/
|
|
gpio_set_direction(GPIO_NUM_0, GPIO_MODE_INPUT);
|
|
gpio_set_pull_mode(GPIO_NUM_0, GPIO_PULLUP_ONLY);
|
|
|
|
bool last_state = true; /* Pull-up: idle = high */
|
|
|
|
while (1) {
|
|
bool current = gpio_get_level(GPIO_NUM_0);
|
|
|
|
/* Detect falling edge (button press) */
|
|
if (last_state && !current) {
|
|
/* Virtual PLIP hook first: while ringing or off-hook the BOOT
|
|
* button is the hook switch (pickup/hangup), not the streaming
|
|
* toggle. */
|
|
if (plip_virtual_button_press()) {
|
|
vTaskDelay(pdMS_TO_TICKS(300));
|
|
last_state = current;
|
|
continue;
|
|
}
|
|
|
|
s_voice_streaming = !s_voice_streaming;
|
|
ESP_LOGI(TAG, "BOOT button pressed — streaming %s",
|
|
s_voice_streaming ? "ON" : "OFF");
|
|
|
|
if (!s_voice_streaming) {
|
|
voice_ws_send_listen_stop();
|
|
}
|
|
|
|
/* Simple debounce */
|
|
vTaskDelay(pdMS_TO_TICKS(300));
|
|
}
|
|
|
|
last_state = current;
|
|
vTaskDelay(pdMS_TO_TICKS(50));
|
|
}
|
|
}
|
|
|
|
/* --------------- ESP-NOW CMD text callback --------------- */
|
|
|
|
// Called from the scenario_mesh text worker task for every CMD/EVT frame
|
|
// received via ESP-NOW. On the BOX-3 we only care about CMD (master→us).
|
|
static void on_mesh_text(uint8_t kind, const uint8_t src_mac[6], const char *text)
|
|
{
|
|
if (kind == SCENARIO_MESH_TEXT_CMD) {
|
|
ESP_LOGI(TAG, "ESP-NOW CMD from %02x:%02x:%02x:%02x:%02x:%02x: %.80s",
|
|
src_mac[0], src_mac[1], src_mac[2],
|
|
src_mac[3], src_mac[4], src_mac[5], text);
|
|
esp_err_t err = cmd_exec_handle(text, strlen(text));
|
|
if (err != ESP_OK && err != ESP_ERR_NOT_SUPPORTED) {
|
|
ESP_LOGW(TAG, "cmd_exec_handle: %s", esp_err_to_name(err));
|
|
}
|
|
} else {
|
|
// EVT arriving at the BOX-3 (unusual — master echoing back).
|
|
ESP_LOGI(TAG, "ESP-NOW EVT from %02x:%02x:%02x:%02x:%02x:%02x: %.80s",
|
|
src_mac[0], src_mac[1], src_mac[2],
|
|
src_mac[3], src_mac[4], src_mac[5], text);
|
|
}
|
|
}
|
|
|
|
/* --------------- Application entry point --------------- */
|
|
|
|
void app_main(void)
|
|
{
|
|
ESP_LOGI(TAG, "============================================");
|
|
ESP_LOGI(TAG, " Zacus BOX-3 Voice Pipeline");
|
|
ESP_LOGI(TAG, " Board: ESP32-S3-BOX-3");
|
|
ESP_LOGI(TAG, " IDF: %s", esp_get_idf_version());
|
|
ESP_LOGI(TAG, " Free heap: %lu bytes", esp_get_free_heap_size());
|
|
ESP_LOGI(TAG, "============================================");
|
|
|
|
/* Initialize NVS (required for WiFi) */
|
|
esp_err_t ret = nvs_flash_init();
|
|
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
|
|
ESP_ERROR_CHECK(nvs_flash_erase());
|
|
ret = nvs_flash_init();
|
|
}
|
|
ESP_ERROR_CHECK(ret);
|
|
|
|
/* Initialize BSP (display, codec, buttons) */
|
|
ESP_LOGI(TAG, "Initializing BSP...");
|
|
bsp_display_start();
|
|
ESP_LOGI(TAG, "Display initialized (%dx%d)", BOX3_LCD_WIDTH, BOX3_LCD_HEIGHT);
|
|
|
|
/* Initialize persistent speaker output (used for test tone + TTS playback) */
|
|
speaker_init();
|
|
|
|
/* Pass speaker codec handle to CMD executor for real WAV playback */
|
|
cmd_exec_set_spk_handle(s_spk_codec);
|
|
|
|
/* Play test tone to verify audio output */
|
|
audio_test_tone();
|
|
|
|
#if CONFIG_BOX3_VOICE_STREAMING
|
|
/* Start mic capture task (reads I2S mic, streams to bridge when active).
|
|
* Off by default: as a touch gamebook the BOX-3 needs no mic, and the
|
|
* continuous codec reads + bridge reconnects interfered with the speaker
|
|
* (the first narration clip came out silent) and spammed the log. */
|
|
xTaskCreatePinnedToCore(mic_monitor_task, "mic_capture", 4096, NULL, 5, NULL, 1);
|
|
#endif
|
|
|
|
/* Start button handler (BOOT button toggles voice streaming) */
|
|
xTaskCreate(button_task, "button", 2048, NULL, 4, NULL);
|
|
|
|
/* Connect to WiFi */
|
|
ESP_LOGI(TAG, "Connecting to WiFi...");
|
|
wifi_init_sta();
|
|
|
|
#if CONFIG_BOX3_VOICE_STREAMING
|
|
/* Start voice bridge connection task (waits for WiFi, then connects WS) */
|
|
xTaskCreate(voice_bridge_task, "voice_bridge", 6144, NULL, 5, NULL);
|
|
#endif
|
|
|
|
/* Start the scenario hot-load HTTP server (POST /game/scenario).
|
|
* httpd_start binds to all netifs — works as soon as the WiFi STA has an IP. */
|
|
if (scenario_server_start() != ESP_OK) {
|
|
ESP_LOGW(TAG, "scenario_server_start failed — IR hot-load unavailable");
|
|
} else {
|
|
/* Phone-less PLIP annex: same REST contract as PLIP_FIRMWARE
|
|
* (POST /ring /stop /play, GET /status), ring on the speaker,
|
|
* BOOT button as the virtual hook switch. */
|
|
if (plip_virtual_init(scenario_server_handle(), s_spk_codec) != ESP_OK) {
|
|
ESP_LOGW(TAG, "plip_virtual_init failed — virtual phone unavailable");
|
|
} else if (plip_ui_init() != ESP_OK) {
|
|
/* REST/ESP-NOW phone still works headless if the UI fails. */
|
|
ESP_LOGW(TAG, "plip_ui_init failed — on-screen phone unavailable");
|
|
}
|
|
|
|
/* Stimulus generator: BOX-3 shows QR / plays melody for the Freenove
|
|
* master's camera + mic (POST /stim/qr, POST /stim/melody). */
|
|
if (stimulus_init() == ESP_OK) {
|
|
stimulus_register_routes(scenario_server_handle());
|
|
} else {
|
|
ESP_LOGW(TAG, "stimulus_init failed — QR/melody generator off");
|
|
}
|
|
|
|
/* Touch gamebook: if a story pack is on the SD (/sdcard/gamebook/),
|
|
* take over the screen with a tap-to-choose "livre dont tu es le
|
|
* héros". No-op (and the phone UI stays) when no pack is present. */
|
|
gamebook_init();
|
|
}
|
|
|
|
/* Start the ESP-NOW receiver so the master can relay scenarios to us even
|
|
* when WiFi is unreachable (battery / RF-noise fallback per the spec). The
|
|
* reassembled IR is funnelled through the exact same scenario_apply_buffer()
|
|
* path the HTTP handler uses. esp_wifi_start() already ran in
|
|
* wifi_init_sta(), so esp_now_init() inside has its prerequisite. */
|
|
esp_err_t mesh_err = scenario_mesh_init(scenario_apply_buffer);
|
|
if (mesh_err != ESP_OK) {
|
|
ESP_LOGW(TAG, "scenario_mesh_init failed: %s — ESP-NOW IR relay unavailable",
|
|
esp_err_to_name(mesh_err));
|
|
} else {
|
|
ESP_LOGI(TAG, "ESP-NOW scenario receiver active");
|
|
/* Register CMD text callback so the master can drive screen / audio / leds
|
|
* via POST /game/espnow/cmd {peer, command:<json>} (D5 contract). */
|
|
esp_err_t tcb_err = scenario_mesh_set_text_cb(on_mesh_text);
|
|
if (tcb_err != ESP_OK) {
|
|
ESP_LOGW(TAG, "scenario_mesh_set_text_cb: %s — CMD executor unavailable",
|
|
esp_err_to_name(tcb_err));
|
|
} else {
|
|
ESP_LOGI(TAG, "ESP-NOW CMD executor registered (op: screen/play/evt/led)");
|
|
}
|
|
}
|
|
|
|
/* TODO: Initialize ESP-SR WakeNet for wake-word detection
|
|
* - Load WakeNet9 model ("hi esp" or custom)
|
|
* - Feed audio frames from mic to WakeNet
|
|
* - On wake-word detection, set s_voice_streaming = true
|
|
* - Replace BOOT button toggle with wake-word trigger
|
|
*/
|
|
|
|
/* TODO: Game logic integration
|
|
* - Subscribe to game state events (puzzle solved, time updates)
|
|
* - Display status on ILI9341 (puzzle progress, timer)
|
|
* - LED ring feedback for voice activity
|
|
*/
|
|
|
|
/* TODO: OTA update support
|
|
* - Check for firmware updates on boot
|
|
* - Use ota_0 partition for rollback safety
|
|
*/
|
|
|
|
ESP_LOGI(TAG, "Initialization complete. Press BOOT button to toggle voice streaming.");
|
|
}
|