feat: NPC engine + BOX-3 voice
- npc_engine: state machine, mood, triggers - tts_client: Piper TTS HTTP + SD fallback - qr_scanner: HMAC-SHA256, debounce - box3_voice: WebSocket voice client - zacus_story_gen_ai: AI story generator Impact: Sprint 2-3 firmware deliverables.
This commit is contained in:
+23
@@ -32,3 +32,26 @@ artifacts/
|
||||
upload_port
|
||||
monitor_port
|
||||
.env
|
||||
|
||||
# Compiled firmware binaries
|
||||
*.elf
|
||||
*.bin
|
||||
*.map
|
||||
*.a
|
||||
|
||||
# PlatformIO library cache
|
||||
.pio/libdeps/
|
||||
.pio/build/
|
||||
|
||||
# macOS duplicates
|
||||
**/* 2
|
||||
**/* 2.*
|
||||
**/sdkconfig [0-9]
|
||||
**/sdkconfig [0-9][0-9]
|
||||
|
||||
# Generated NPC audio (regenerated by tools/tts/)
|
||||
data/hotline_tts/**/*.mp3
|
||||
!data/hotline_tts/manifest.json
|
||||
|
||||
# Local venvs
|
||||
.venv*/
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||
project(zacus-box3-voice)
|
||||
@@ -0,0 +1,56 @@
|
||||
# Zacus BOX-3 Voice Pipeline
|
||||
|
||||
ESP-IDF firmware for ESP32-S3-BOX-3 — voice interface for
|
||||
*Le Mystere du Professeur Zacus* escape room.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- ESP-IDF v5.2+ (with ESP32-S3 support)
|
||||
- Python 3.8+ (for idf.py)
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
# Set target
|
||||
idf.py set-target esp32s3
|
||||
|
||||
# Configure (optional — defaults in sdkconfig.defaults)
|
||||
idf.py menuconfig
|
||||
|
||||
# Build
|
||||
idf.py build
|
||||
|
||||
# Flash (USB-C on BOX-3)
|
||||
idf.py -p /dev/tty.usbmodem* flash monitor
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Use `idf.py menuconfig` > **Zacus BOX-3 Voice Configuration**:
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| WiFi SSID | `zacus-net` | Network for voice bridge |
|
||||
| WiFi Password | *(empty)* | WPA2 password |
|
||||
| Voice Bridge URL | `ws://192.168.4.1:8080/ws/voice` | mascarade WebSocket |
|
||||
| Wake Word | `hi esp` | WakeNet9 trigger phrase |
|
||||
| Speaker Volume | 70 | 0-100 |
|
||||
| Mic Gain | 80 | 0-100 |
|
||||
|
||||
## Hardware
|
||||
|
||||
- **Board**: ESP32-S3-BOX-3 (16MB flash, 8MB PSRAM)
|
||||
- **Codec**: ES8311 (I2C addr 0x18)
|
||||
- **Display**: ILI9341 320x240
|
||||
- **Mic**: onboard MEMS
|
||||
- **Speaker**: onboard 1W with PA
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
[Mic] -> I2S -> WakeNet -> Voice Capture -> WebSocket -> mascarade bridge
|
||||
|
|
||||
[Speaker] <- I2S <- TTS audio <------------- WebSocket -------+
|
||||
|
|
||||
hints engine <--+
|
||||
```
|
||||
@@ -0,0 +1,11 @@
|
||||
dependencies:
|
||||
espressif/esp-box:
|
||||
version: ">=1.2.0"
|
||||
espressif/esp-sr:
|
||||
version: ">=1.4.0"
|
||||
espressif/esp_codec_dev:
|
||||
version: ">=1.1.0"
|
||||
espressif/button:
|
||||
version: ">=3.2.0"
|
||||
espressif/esp_websocket_client:
|
||||
version: ">=1.0.0"
|
||||
@@ -0,0 +1,4 @@
|
||||
idf_component_register(
|
||||
SRCS "main.c" "voice_ws_client.c"
|
||||
INCLUDE_DIRS "."
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
menu "Zacus BOX-3 Voice Configuration"
|
||||
|
||||
config ZACUS_WIFI_SSID
|
||||
string "WiFi SSID"
|
||||
default "zacus-net"
|
||||
help
|
||||
WiFi network name for connecting to the voice bridge.
|
||||
|
||||
config ZACUS_WIFI_PASSWORD
|
||||
string "WiFi Password"
|
||||
default ""
|
||||
help
|
||||
WiFi password. Leave empty for open networks.
|
||||
|
||||
config ZACUS_VOICE_BRIDGE_URL
|
||||
string "Voice Bridge WebSocket URL"
|
||||
default "ws://192.168.0.119:8200/voice/ws"
|
||||
help
|
||||
WebSocket endpoint for the mascarade voice bridge.
|
||||
The bridge routes voice commands to the hints engine.
|
||||
|
||||
config ZACUS_VOICE_TOKEN
|
||||
string "Voice Bridge Auth Token"
|
||||
default ""
|
||||
help
|
||||
Authentication token for the voice bridge.
|
||||
If set, appended as ?token=<value> query parameter.
|
||||
|
||||
config ZACUS_WAKE_WORD
|
||||
string "Wake Word"
|
||||
default "hi esp"
|
||||
help
|
||||
Wake word for ESP-SR WakeNet activation.
|
||||
Supported: "hi esp", "alexa", "hey siri" (WakeNet9).
|
||||
|
||||
config ZACUS_SPEAKER_VOLUME
|
||||
int "Default Speaker Volume (0-100)"
|
||||
default 70
|
||||
range 0 100
|
||||
help
|
||||
Default speaker volume at boot.
|
||||
|
||||
config ZACUS_MIC_GAIN
|
||||
int "Microphone Gain (0-100)"
|
||||
default 80
|
||||
range 0 100
|
||||
help
|
||||
Microphone input gain for speech recognition.
|
||||
|
||||
config ZACUS_ENABLE_DISPLAY
|
||||
bool "Enable LCD Display"
|
||||
default y
|
||||
help
|
||||
Enable the ILI9341 display for status feedback.
|
||||
|
||||
endmenu
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
/*
|
||||
* ESP32-S3-BOX-3 Hardware Pin Configuration
|
||||
* Reference: Espressif ESP-BOX-3 Schematic v1.2
|
||||
*/
|
||||
|
||||
/* ---------- I2S Audio (ES8311 codec) ---------- */
|
||||
#define BOX3_I2S_MCLK 16
|
||||
#define BOX3_I2S_BCLK 9
|
||||
#define BOX3_I2S_WS 45
|
||||
#define BOX3_I2S_DOUT 15 /* Speaker data out */
|
||||
#define BOX3_I2S_DIN 10 /* Microphone data in */
|
||||
#define BOX3_PA_ENABLE 46 /* Power amplifier enable (active high) */
|
||||
|
||||
/* ---------- I2C Bus ---------- */
|
||||
#define BOX3_I2C_SCL 18
|
||||
#define BOX3_I2C_SDA 8
|
||||
#define BOX3_I2C_NUM I2C_NUM_0
|
||||
#define BOX3_I2C_FREQ_HZ 400000
|
||||
|
||||
/* ---------- ES8311 Audio Codec ---------- */
|
||||
#define BOX3_ES8311_ADDR 0x18
|
||||
|
||||
/* ---------- Display (ILI9341) ---------- */
|
||||
#define BOX3_LCD_WIDTH 320
|
||||
#define BOX3_LCD_HEIGHT 240
|
||||
|
||||
/* ---------- Audio Parameters ---------- */
|
||||
#define AUDIO_SAMPLE_RATE 16000
|
||||
#define AUDIO_BITS 16
|
||||
#define AUDIO_CHANNELS 1
|
||||
#define AUDIO_FRAME_MS 20
|
||||
#define AUDIO_FRAME_SAMPLES (AUDIO_SAMPLE_RATE * AUDIO_FRAME_MS / 1000)
|
||||
@@ -0,0 +1,399 @@
|
||||
/*
|
||||
* 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"
|
||||
|
||||
/* BSP header — provided by espressif/esp-box component */
|
||||
#include "bsp/esp-bsp.h"
|
||||
|
||||
static const char *TAG = "zacus-voice";
|
||||
|
||||
/* --------------- Shared state --------------- */
|
||||
|
||||
static i2s_chan_handle_t s_spk_handle = NULL; /* Speaker TX channel */
|
||||
static i2s_chan_handle_t s_mic_handle = NULL; /* Microphone RX channel */
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
/* 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) --------------- */
|
||||
|
||||
static void audio_test_tone(void)
|
||||
{
|
||||
if (!s_spk_handle) {
|
||||
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 using persistent speaker handle */
|
||||
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];
|
||||
size_t bytes_written = 0;
|
||||
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));
|
||||
}
|
||||
i2s_channel_write(s_spk_handle, buffer, chunk * sizeof(int16_t), &bytes_written, portMAX_DELAY);
|
||||
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_handle || len == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
size_t bytes_written = 0;
|
||||
esp_err_t ret = i2s_channel_write(s_spk_handle, data, len,
|
||||
&bytes_written, pdMS_TO_TICKS(200));
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "TTS write to speaker failed: %s", esp_err_to_name(ret));
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------- Speaker init --------------- */
|
||||
|
||||
static esp_err_t speaker_init(void)
|
||||
{
|
||||
/* Enable power amplifier */
|
||||
gpio_set_direction(BOX3_PA_ENABLE, GPIO_MODE_OUTPUT);
|
||||
gpio_set_level(BOX3_PA_ENABLE, 1);
|
||||
|
||||
i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER);
|
||||
ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, &s_spk_handle, NULL));
|
||||
|
||||
i2s_std_config_t std_cfg = {
|
||||
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(AUDIO_SAMPLE_RATE),
|
||||
.slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO),
|
||||
.gpio_cfg = {
|
||||
.mclk = BOX3_I2S_MCLK,
|
||||
.bclk = BOX3_I2S_BCLK,
|
||||
.ws = BOX3_I2S_WS,
|
||||
.dout = BOX3_I2S_DOUT,
|
||||
.din = I2S_GPIO_UNUSED,
|
||||
.invert_flags = {
|
||||
.mclk_inv = false,
|
||||
.bclk_inv = false,
|
||||
.ws_inv = false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
ESP_ERROR_CHECK(i2s_channel_init_std_mode(s_spk_handle, &std_cfg));
|
||||
ESP_ERROR_CHECK(i2s_channel_enable(s_spk_handle));
|
||||
|
||||
ESP_LOGI(TAG, "Speaker I2S initialized (I2S_NUM_0)");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* --------------- Microphone task --------------- */
|
||||
|
||||
static void mic_monitor_task(void *arg)
|
||||
{
|
||||
ESP_LOGI(TAG, "Starting mic capture task...");
|
||||
|
||||
i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_1, I2S_ROLE_MASTER);
|
||||
ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, NULL, &s_mic_handle));
|
||||
|
||||
i2s_std_config_t std_cfg = {
|
||||
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(AUDIO_SAMPLE_RATE),
|
||||
.slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO),
|
||||
.gpio_cfg = {
|
||||
.mclk = I2S_GPIO_UNUSED,
|
||||
.bclk = BOX3_I2S_BCLK,
|
||||
.ws = BOX3_I2S_WS,
|
||||
.dout = I2S_GPIO_UNUSED,
|
||||
.din = BOX3_I2S_DIN,
|
||||
.invert_flags = {
|
||||
.mclk_inv = false,
|
||||
.bclk_inv = false,
|
||||
.ws_inv = false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
ESP_ERROR_CHECK(i2s_channel_init_std_mode(s_mic_handle, &std_cfg));
|
||||
ESP_ERROR_CHECK(i2s_channel_enable(s_mic_handle));
|
||||
|
||||
int16_t buffer[AUDIO_FRAME_SAMPLES];
|
||||
size_t bytes_read = 0;
|
||||
int rms_log_counter = 0;
|
||||
|
||||
while (1) {
|
||||
esp_err_t ret = i2s_channel_read(s_mic_handle, buffer,
|
||||
AUDIO_FRAME_SAMPLES * sizeof(int16_t),
|
||||
&bytes_read, pdMS_TO_TICKS(1000));
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Mic read error: %s", esp_err_to_name(ret));
|
||||
continue;
|
||||
}
|
||||
|
||||
int samples = bytes_read / sizeof(int16_t);
|
||||
|
||||
/* 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) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------- 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();
|
||||
|
||||
/* Play test tone to verify audio output */
|
||||
audio_test_tone();
|
||||
|
||||
/* Start mic capture task (reads I2S mic, streams to bridge when active) */
|
||||
xTaskCreatePinnedToCore(mic_monitor_task, "mic_capture", 4096, NULL, 5, NULL, 1);
|
||||
|
||||
/* 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();
|
||||
|
||||
/* Start voice bridge connection task (waits for WiFi, then connects WS) */
|
||||
xTaskCreate(voice_bridge_task, "voice_bridge", 6144, NULL, 5, NULL);
|
||||
|
||||
/* 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.");
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
/*
|
||||
* voice_ws_client.c — WebSocket client for mascarade voice bridge
|
||||
*
|
||||
* Connects to the voice bridge at ws://<host>:8200/voice/ws,
|
||||
* streams PCM16 audio frames, receives TTS audio and JSON control messages.
|
||||
*
|
||||
* Current version: raw PCM16 (no OPUS encoding).
|
||||
* The bridge supports WAV/PCM fallback so this works out of the box.
|
||||
*
|
||||
* TODO(opus): Add OPUS encode/decode when esp-opus component is available.
|
||||
* - Encode: voice_ws_send_audio() should OPUS-encode before sending
|
||||
* - Decode: ws_event_handler WEBSOCKET_EVENT_DATA binary path should
|
||||
* OPUS-decode before forwarding to audio callback
|
||||
* - Handshake should advertise "codec":"opus" in hello message
|
||||
*/
|
||||
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "freertos/event_groups.h"
|
||||
|
||||
#include "esp_log.h"
|
||||
#include "esp_err.h"
|
||||
#include "esp_websocket_client.h"
|
||||
#include "cJSON.h"
|
||||
|
||||
#include "voice_ws_client.h"
|
||||
#include "board_config.h"
|
||||
|
||||
static const char *TAG = "voice-ws";
|
||||
|
||||
/* --------------- Internal state --------------- */
|
||||
|
||||
static esp_websocket_client_handle_t s_ws_client = NULL;
|
||||
static SemaphoreHandle_t s_state_mutex = NULL;
|
||||
static EventGroupHandle_t s_events = NULL;
|
||||
static voice_state_t s_state = VOICE_STATE_IDLE;
|
||||
static void (*s_audio_cb)(const uint8_t *data, size_t len) = NULL;
|
||||
|
||||
/* Event bits */
|
||||
#define EVT_CONNECTED BIT0
|
||||
#define EVT_HELLO_ACK BIT1
|
||||
#define EVT_DISCONNECTED BIT2
|
||||
|
||||
/* Reconnect parameters */
|
||||
#define RECONNECT_DELAY_MS 5000
|
||||
#define HANDSHAKE_TIMEOUT_MS 5000
|
||||
|
||||
/* --------------- State helpers --------------- */
|
||||
|
||||
static void set_state(voice_state_t new_state)
|
||||
{
|
||||
if (s_state_mutex) {
|
||||
xSemaphoreTake(s_state_mutex, portMAX_DELAY);
|
||||
}
|
||||
s_state = new_state;
|
||||
if (s_state_mutex) {
|
||||
xSemaphoreGive(s_state_mutex);
|
||||
}
|
||||
}
|
||||
|
||||
voice_state_t voice_ws_get_state(void)
|
||||
{
|
||||
voice_state_t st;
|
||||
if (s_state_mutex) {
|
||||
xSemaphoreTake(s_state_mutex, portMAX_DELAY);
|
||||
st = s_state;
|
||||
xSemaphoreGive(s_state_mutex);
|
||||
} else {
|
||||
st = s_state;
|
||||
}
|
||||
return st;
|
||||
}
|
||||
|
||||
void voice_ws_set_audio_callback(void (*cb)(const uint8_t *audio_data, size_t len))
|
||||
{
|
||||
s_audio_cb = cb;
|
||||
}
|
||||
|
||||
/* --------------- JSON helpers --------------- */
|
||||
|
||||
static esp_err_t ws_send_json(const char *json_str)
|
||||
{
|
||||
if (!s_ws_client || !esp_websocket_client_is_connected(s_ws_client)) {
|
||||
ESP_LOGW(TAG, "Cannot send JSON — not connected");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
int len = strlen(json_str);
|
||||
int sent = esp_websocket_client_send_text(s_ws_client, json_str, len, pdMS_TO_TICKS(2000));
|
||||
if (sent < 0) {
|
||||
ESP_LOGE(TAG, "Failed to send JSON (%d bytes)", len);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
ESP_LOGD(TAG, "Sent JSON: %s", json_str);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static void send_hello(void)
|
||||
{
|
||||
cJSON *hello = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(hello, "type", "hello");
|
||||
cJSON_AddStringToObject(hello, "device_id", "zacus-box3");
|
||||
cJSON_AddStringToObject(hello, "codec", "pcm16"); /* TODO(opus): change to "opus" */
|
||||
cJSON_AddNumberToObject(hello, "sample_rate", AUDIO_SAMPLE_RATE);
|
||||
cJSON_AddNumberToObject(hello, "channels", AUDIO_CHANNELS);
|
||||
cJSON_AddNumberToObject(hello, "frame_ms", AUDIO_FRAME_MS);
|
||||
|
||||
char *str = cJSON_PrintUnformatted(hello);
|
||||
if (str) {
|
||||
ESP_LOGI(TAG, "Sending hello: %s", str);
|
||||
ws_send_json(str);
|
||||
free(str);
|
||||
}
|
||||
cJSON_Delete(hello);
|
||||
}
|
||||
|
||||
/* --------------- JSON message handler --------------- */
|
||||
|
||||
static void handle_json_message(const char *data, int len)
|
||||
{
|
||||
cJSON *root = cJSON_ParseWithLength(data, len);
|
||||
if (!root) {
|
||||
ESP_LOGW(TAG, "Failed to parse JSON message");
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON *type = cJSON_GetObjectItem(root, "type");
|
||||
if (!type || !cJSON_IsString(type)) {
|
||||
ESP_LOGW(TAG, "JSON message missing 'type' field");
|
||||
cJSON_Delete(root);
|
||||
return;
|
||||
}
|
||||
|
||||
const char *msg_type = type->valuestring;
|
||||
|
||||
if (strcmp(msg_type, "hello_ack") == 0) {
|
||||
ESP_LOGI(TAG, "Received hello_ack — bridge ready");
|
||||
cJSON *caps = cJSON_GetObjectItem(root, "capabilities");
|
||||
if (caps) {
|
||||
char *caps_str = cJSON_PrintUnformatted(caps);
|
||||
if (caps_str) {
|
||||
ESP_LOGI(TAG, "Bridge capabilities: %s", caps_str);
|
||||
free(caps_str);
|
||||
}
|
||||
}
|
||||
set_state(VOICE_STATE_READY);
|
||||
xEventGroupSetBits(s_events, EVT_HELLO_ACK);
|
||||
|
||||
} else if (strcmp(msg_type, "listen_ack") == 0) {
|
||||
ESP_LOGI(TAG, "Bridge acknowledged listen start");
|
||||
set_state(VOICE_STATE_LISTENING);
|
||||
|
||||
} else if (strcmp(msg_type, "stt") == 0) {
|
||||
cJSON *text = cJSON_GetObjectItem(root, "text");
|
||||
cJSON *is_final = cJSON_GetObjectItem(root, "final");
|
||||
if (text && cJSON_IsString(text)) {
|
||||
ESP_LOGI(TAG, "STT %s: %s",
|
||||
(is_final && cJSON_IsTrue(is_final)) ? "FINAL" : "partial",
|
||||
text->valuestring);
|
||||
}
|
||||
if (is_final && cJSON_IsTrue(is_final)) {
|
||||
set_state(VOICE_STATE_PROCESSING);
|
||||
}
|
||||
|
||||
} else if (strcmp(msg_type, "tts_start") == 0) {
|
||||
ESP_LOGI(TAG, "TTS playback starting");
|
||||
set_state(VOICE_STATE_PLAYING);
|
||||
|
||||
} else if (strcmp(msg_type, "tts_end") == 0) {
|
||||
ESP_LOGI(TAG, "TTS playback complete");
|
||||
set_state(VOICE_STATE_READY);
|
||||
|
||||
} else if (strcmp(msg_type, "error") == 0) {
|
||||
cJSON *msg = cJSON_GetObjectItem(root, "message");
|
||||
ESP_LOGE(TAG, "Bridge error: %s",
|
||||
(msg && cJSON_IsString(msg)) ? msg->valuestring : "unknown");
|
||||
set_state(VOICE_STATE_ERROR);
|
||||
/* Recover to ready after a brief delay */
|
||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||
if (esp_websocket_client_is_connected(s_ws_client)) {
|
||||
set_state(VOICE_STATE_READY);
|
||||
}
|
||||
|
||||
} else if (strcmp(msg_type, "hint") == 0) {
|
||||
cJSON *puzzle = cJSON_GetObjectItem(root, "puzzle");
|
||||
cJSON *level = cJSON_GetObjectItem(root, "level");
|
||||
cJSON *text = cJSON_GetObjectItem(root, "text");
|
||||
ESP_LOGI(TAG, "Hint [puzzle=%s level=%s]: %s",
|
||||
(puzzle && cJSON_IsString(puzzle)) ? puzzle->valuestring : "?",
|
||||
(level && cJSON_IsString(level)) ? level->valuestring : "?",
|
||||
(text && cJSON_IsString(text)) ? text->valuestring : "");
|
||||
|
||||
} else {
|
||||
ESP_LOGD(TAG, "Unhandled message type: %s", msg_type);
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
}
|
||||
|
||||
/* --------------- WebSocket event handler --------------- */
|
||||
|
||||
static void ws_event_handler(void *arg, esp_event_base_t event_base,
|
||||
int32_t event_id, void *event_data)
|
||||
{
|
||||
esp_websocket_event_data_t *data = (esp_websocket_event_data_t *)event_data;
|
||||
|
||||
switch (event_id) {
|
||||
case WEBSOCKET_EVENT_CONNECTED:
|
||||
ESP_LOGI(TAG, "WebSocket connected");
|
||||
set_state(VOICE_STATE_CONNECTING);
|
||||
xEventGroupSetBits(s_events, EVT_CONNECTED);
|
||||
xEventGroupClearBits(s_events, EVT_DISCONNECTED);
|
||||
/* Send hello handshake immediately */
|
||||
send_hello();
|
||||
break;
|
||||
|
||||
case WEBSOCKET_EVENT_DISCONNECTED:
|
||||
ESP_LOGW(TAG, "WebSocket disconnected");
|
||||
set_state(VOICE_STATE_IDLE);
|
||||
xEventGroupSetBits(s_events, EVT_DISCONNECTED);
|
||||
xEventGroupClearBits(s_events, EVT_CONNECTED | EVT_HELLO_ACK);
|
||||
break;
|
||||
|
||||
case WEBSOCKET_EVENT_DATA:
|
||||
if (data->op_code == 0x01) {
|
||||
/* Text frame — JSON control message */
|
||||
if (data->data_ptr && data->data_len > 0) {
|
||||
handle_json_message(data->data_ptr, data->data_len);
|
||||
}
|
||||
} else if (data->op_code == 0x02) {
|
||||
/* Binary frame — TTS audio data (PCM16 or OPUS) */
|
||||
/* TODO(opus): OPUS-decode here before forwarding */
|
||||
if (s_audio_cb && data->data_ptr && data->data_len > 0) {
|
||||
s_audio_cb((const uint8_t *)data->data_ptr, data->data_len);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case WEBSOCKET_EVENT_ERROR:
|
||||
ESP_LOGE(TAG, "WebSocket error");
|
||||
set_state(VOICE_STATE_ERROR);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------- Reconnect task --------------- */
|
||||
|
||||
static void reconnect_task(void *arg)
|
||||
{
|
||||
ESP_LOGI(TAG, "Reconnect monitor started");
|
||||
while (1) {
|
||||
/* Wait for disconnect event */
|
||||
EventBits_t bits = xEventGroupWaitBits(s_events, EVT_DISCONNECTED,
|
||||
pdFALSE, pdFALSE, portMAX_DELAY);
|
||||
if (bits & EVT_DISCONNECTED) {
|
||||
ESP_LOGI(TAG, "Auto-reconnect in %d ms...", RECONNECT_DELAY_MS);
|
||||
vTaskDelay(pdMS_TO_TICKS(RECONNECT_DELAY_MS));
|
||||
|
||||
/* Only reconnect if still disconnected and client exists */
|
||||
if (s_ws_client && !esp_websocket_client_is_connected(s_ws_client)) {
|
||||
ESP_LOGI(TAG, "Attempting reconnect...");
|
||||
esp_websocket_client_start(s_ws_client);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------- Public API --------------- */
|
||||
|
||||
esp_err_t voice_ws_init(void)
|
||||
{
|
||||
if (s_ws_client) {
|
||||
ESP_LOGW(TAG, "Already initialized");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
/* Create synchronization primitives */
|
||||
s_state_mutex = xSemaphoreCreateMutex();
|
||||
if (!s_state_mutex) {
|
||||
ESP_LOGE(TAG, "Failed to create state mutex");
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
s_events = xEventGroupCreate();
|
||||
if (!s_events) {
|
||||
ESP_LOGE(TAG, "Failed to create event group");
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
/* Build URL with optional token query param */
|
||||
char url[256];
|
||||
const char *base_url = CONFIG_ZACUS_VOICE_BRIDGE_URL;
|
||||
|
||||
#ifdef CONFIG_ZACUS_VOICE_TOKEN
|
||||
if (strlen(CONFIG_ZACUS_VOICE_TOKEN) > 0) {
|
||||
snprintf(url, sizeof(url), "%s?token=%s", base_url, CONFIG_ZACUS_VOICE_TOKEN);
|
||||
} else {
|
||||
snprintf(url, sizeof(url), "%s", base_url);
|
||||
}
|
||||
#else
|
||||
snprintf(url, sizeof(url), "%s", base_url);
|
||||
#endif
|
||||
|
||||
ESP_LOGI(TAG, "Voice bridge URL: %s", url);
|
||||
|
||||
/* Configure WebSocket client */
|
||||
esp_websocket_client_config_t ws_cfg = {
|
||||
.uri = url,
|
||||
.buffer_size = 2048,
|
||||
.reconnect_timeout_ms = RECONNECT_DELAY_MS,
|
||||
.network_timeout_ms = 10000,
|
||||
.ping_interval_sec = 15,
|
||||
};
|
||||
|
||||
s_ws_client = esp_websocket_client_init(&ws_cfg);
|
||||
if (!s_ws_client) {
|
||||
ESP_LOGE(TAG, "Failed to init WebSocket client");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
/* Register event handler */
|
||||
esp_err_t ret = esp_websocket_register_events(s_ws_client, WEBSOCKET_EVENT_ANY,
|
||||
ws_event_handler, NULL);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to register WS events: %s", esp_err_to_name(ret));
|
||||
return ret;
|
||||
}
|
||||
|
||||
/* Start reconnect monitor task */
|
||||
xTaskCreate(reconnect_task, "ws_reconnect", 3072, NULL, 3, NULL);
|
||||
|
||||
set_state(VOICE_STATE_IDLE);
|
||||
ESP_LOGI(TAG, "Voice WS client initialized");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t voice_ws_connect(void)
|
||||
{
|
||||
if (!s_ws_client) {
|
||||
ESP_LOGE(TAG, "Not initialized — call voice_ws_init() first");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
set_state(VOICE_STATE_CONNECTING);
|
||||
ESP_LOGI(TAG, "Connecting to voice bridge...");
|
||||
|
||||
esp_err_t ret = esp_websocket_client_start(s_ws_client);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to start WS client: %s", esp_err_to_name(ret));
|
||||
set_state(VOICE_STATE_ERROR);
|
||||
return ret;
|
||||
}
|
||||
|
||||
/* Wait for hello_ack */
|
||||
EventBits_t bits = xEventGroupWaitBits(s_events, EVT_HELLO_ACK,
|
||||
pdFALSE, pdFALSE,
|
||||
pdMS_TO_TICKS(HANDSHAKE_TIMEOUT_MS));
|
||||
if (!(bits & EVT_HELLO_ACK)) {
|
||||
ESP_LOGW(TAG, "Handshake timeout — bridge may not have replied");
|
||||
/* Don't disconnect; the bridge might still come up */
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Voice bridge handshake complete — state=READY");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t voice_ws_disconnect(void)
|
||||
{
|
||||
if (!s_ws_client) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Disconnecting from voice bridge...");
|
||||
|
||||
/* Send a goodbye message (best-effort) */
|
||||
ws_send_json("{\"type\":\"bye\"}");
|
||||
vTaskDelay(pdMS_TO_TICKS(100));
|
||||
|
||||
esp_err_t ret = esp_websocket_client_stop(s_ws_client);
|
||||
set_state(VOICE_STATE_IDLE);
|
||||
return ret;
|
||||
}
|
||||
|
||||
esp_err_t voice_ws_send_audio(const int16_t *pcm, size_t samples)
|
||||
{
|
||||
if (!s_ws_client || !esp_websocket_client_is_connected(s_ws_client)) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
voice_state_t st = voice_ws_get_state();
|
||||
if (st != VOICE_STATE_LISTENING && st != VOICE_STATE_READY) {
|
||||
ESP_LOGD(TAG, "Not in listening state, ignoring audio frame");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
/*
|
||||
* TODO(opus): OPUS encode the PCM16 samples here.
|
||||
* opus_encode(encoder, pcm, samples, opus_buf, opus_buf_size);
|
||||
* Then send opus_buf as binary frame instead of raw PCM.
|
||||
*
|
||||
* For now, send raw PCM16 little-endian — the bridge has WAV/PCM fallback.
|
||||
*/
|
||||
|
||||
size_t byte_len = samples * sizeof(int16_t);
|
||||
int sent = esp_websocket_client_send_bin(s_ws_client, (const char *)pcm,
|
||||
byte_len, pdMS_TO_TICKS(500));
|
||||
if (sent < 0) {
|
||||
ESP_LOGW(TAG, "Failed to send audio frame (%d samples)", (int)samples);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t voice_ws_send_text_query(const char *text)
|
||||
{
|
||||
if (!text || strlen(text) == 0) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
cJSON *msg = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(msg, "type", "text_query");
|
||||
cJSON_AddStringToObject(msg, "text", text);
|
||||
|
||||
char *str = cJSON_PrintUnformatted(msg);
|
||||
esp_err_t ret = ESP_FAIL;
|
||||
if (str) {
|
||||
ret = ws_send_json(str);
|
||||
free(str);
|
||||
}
|
||||
cJSON_Delete(msg);
|
||||
|
||||
if (ret == ESP_OK) {
|
||||
set_state(VOICE_STATE_PROCESSING);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
esp_err_t voice_ws_send_listen_stop(void)
|
||||
{
|
||||
cJSON *msg = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(msg, "type", "listen");
|
||||
cJSON_AddStringToObject(msg, "state", "stop");
|
||||
|
||||
char *str = cJSON_PrintUnformatted(msg);
|
||||
esp_err_t ret = ESP_FAIL;
|
||||
if (str) {
|
||||
ret = ws_send_json(str);
|
||||
free(str);
|
||||
}
|
||||
cJSON_Delete(msg);
|
||||
return ret;
|
||||
}
|
||||
|
||||
esp_err_t voice_ws_abort(void)
|
||||
{
|
||||
cJSON *msg = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(msg, "type", "abort");
|
||||
|
||||
char *str = cJSON_PrintUnformatted(msg);
|
||||
esp_err_t ret = ESP_FAIL;
|
||||
if (str) {
|
||||
ret = ws_send_json(str);
|
||||
free(str);
|
||||
}
|
||||
cJSON_Delete(msg);
|
||||
|
||||
if (ret == ESP_OK) {
|
||||
set_state(VOICE_STATE_READY);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* voice_ws_client.h — WebSocket client for mascarade voice bridge
|
||||
*
|
||||
* Protocol: ws://<host>:8200/voice/ws
|
||||
* - JSON handshake (hello / hello_ack)
|
||||
* - Binary frames: PCM16 16kHz mono 20ms (320 samples, 640 bytes)
|
||||
* - JSON control messages: listen, stt, tts, error, text_query
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef enum {
|
||||
VOICE_STATE_IDLE,
|
||||
VOICE_STATE_CONNECTING,
|
||||
VOICE_STATE_READY,
|
||||
VOICE_STATE_LISTENING,
|
||||
VOICE_STATE_PROCESSING,
|
||||
VOICE_STATE_PLAYING,
|
||||
VOICE_STATE_ERROR
|
||||
} voice_state_t;
|
||||
|
||||
/**
|
||||
* Initialize the WebSocket client (does not connect yet).
|
||||
* Configures the client with URL from Kconfig and registers event handlers.
|
||||
*/
|
||||
esp_err_t voice_ws_init(void);
|
||||
|
||||
/**
|
||||
* Connect to the voice bridge and perform the hello handshake.
|
||||
* Blocks until hello_ack is received or timeout (5s).
|
||||
*/
|
||||
esp_err_t voice_ws_connect(void);
|
||||
|
||||
/**
|
||||
* Disconnect from the voice bridge gracefully.
|
||||
*/
|
||||
esp_err_t voice_ws_disconnect(void);
|
||||
|
||||
/**
|
||||
* Send a PCM16 audio frame to the bridge.
|
||||
* @param pcm Pointer to signed 16-bit PCM samples
|
||||
* @param samples Number of samples (typically AUDIO_FRAME_SAMPLES = 320)
|
||||
*/
|
||||
esp_err_t voice_ws_send_audio(const int16_t *pcm, size_t samples);
|
||||
|
||||
/**
|
||||
* Send a text query (bypass voice, direct text input).
|
||||
*/
|
||||
esp_err_t voice_ws_send_text_query(const char *text);
|
||||
|
||||
/**
|
||||
* Signal the bridge to stop listening.
|
||||
*/
|
||||
esp_err_t voice_ws_send_listen_stop(void);
|
||||
|
||||
/**
|
||||
* Abort current operation (cancel listen/processing).
|
||||
*/
|
||||
esp_err_t voice_ws_abort(void);
|
||||
|
||||
/**
|
||||
* Get current voice pipeline state.
|
||||
*/
|
||||
voice_state_t voice_ws_get_state(void);
|
||||
|
||||
/**
|
||||
* Register callback for incoming audio data (TTS playback).
|
||||
* Called from the WebSocket event task — keep it fast.
|
||||
* @param cb Callback receiving raw PCM16 audio data and byte length.
|
||||
*/
|
||||
void voice_ws_set_audio_callback(void (*cb)(const uint8_t *audio_data, size_t len));
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,7 @@
|
||||
# Name, Type, SubType, Offset, Size, Flags
|
||||
nvs, data, nvs, 0x9000, 0x6000,
|
||||
ota_data, data, ota, 0xf000, 0x2000,
|
||||
phy_init, data, phy, 0x11000, 0x1000,
|
||||
factory, app, factory, 0x20000, 0x400000,
|
||||
ota_0, app, ota_0, 0x420000, 0x400000,
|
||||
spiffs, data, spiffs, 0x820000, 0x400000,
|
||||
|
@@ -0,0 +1,46 @@
|
||||
# Target: ESP32-S3
|
||||
CONFIG_IDF_TARGET="esp32s3"
|
||||
|
||||
# Flash: 16MB
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
|
||||
CONFIG_ESPTOOLPY_FLASHMODE_QIO=y
|
||||
CONFIG_ESPTOOLPY_FLASHFREQ_80M=y
|
||||
|
||||
# Partition table
|
||||
CONFIG_PARTITION_TABLE_CUSTOM=y
|
||||
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
|
||||
|
||||
# PSRAM: Octal 8MB
|
||||
CONFIG_ESP32S3_SPIRAM_SUPPORT=y
|
||||
CONFIG_SPIRAM_MODE_OCT=y
|
||||
CONFIG_SPIRAM_SPEED_80M=y
|
||||
CONFIG_SPIRAM_BOOT_INIT=y
|
||||
CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY=y
|
||||
|
||||
# ESP-SR: WakeNet + MultiNet
|
||||
CONFIG_ESP_SR_WAKENET_ENABLE=y
|
||||
CONFIG_ESP_SR_WAKENET9_HIESP=y
|
||||
CONFIG_ESP_SR_MULTINET_ENABLE=y
|
||||
|
||||
# WiFi
|
||||
CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=16
|
||||
CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=64
|
||||
CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM=64
|
||||
|
||||
# FreeRTOS
|
||||
CONFIG_FREERTOS_HZ=1000
|
||||
CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=4096
|
||||
|
||||
# Logging
|
||||
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
|
||||
|
||||
# Watchdog: extend for model loading
|
||||
CONFIG_ESP_TASK_WDT_TIMEOUT_S=30
|
||||
|
||||
# Enable HTTPS / TLS for voice bridge
|
||||
CONFIG_ESP_TLS_USING_MBEDTLS=y
|
||||
CONFIG_MBEDTLS_DYNAMIC_BUFFER=y
|
||||
|
||||
# Heap: place large buffers in PSRAM
|
||||
CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=4096
|
||||
CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=32768
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Story generation library (Yamale + Jinja2) for Zacus firmware."""
|
||||
|
||||
from .generator import StoryGenerationError
|
||||
|
||||
__all__ = ["StoryGenerationError"]
|
||||
@@ -0,0 +1,155 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .generator import (
|
||||
StoryGenerationError,
|
||||
default_paths,
|
||||
run_generate_bundle,
|
||||
run_generate_cpp,
|
||||
run_sync_screens,
|
||||
run_validate,
|
||||
)
|
||||
|
||||
|
||||
def _path_or_none(value: str | None) -> Path | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return Path(value)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Zacus story generator (Yamale + Jinja2)")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
common = argparse.ArgumentParser(add_help=False)
|
||||
common.add_argument("--spec-dir", default="", help="Story spec directory")
|
||||
common.add_argument("--game-dir", default="", help="Game scenario directory")
|
||||
|
||||
validate_p = sub.add_parser("validate", parents=[common], help="Validate game + story specs")
|
||||
|
||||
cpp_p = sub.add_parser("generate-cpp", parents=[common], help="Generate scenarios_gen/apps_gen C++")
|
||||
cpp_p.add_argument("--out-dir", default="", help="Output directory for generated C++")
|
||||
|
||||
generate_alias = sub.add_parser("generate", parents=[common], help="Alias of generate-cpp")
|
||||
generate_alias.add_argument("--out-dir", default="", help="Output directory for generated C++")
|
||||
|
||||
bundle_p = sub.add_parser("generate-bundle", parents=[common], help="Generate story bundle JSON+sha")
|
||||
bundle_p.add_argument("--out-dir", default="", help="Bundle root directory")
|
||||
bundle_p.add_argument("--archive", default="", help="Optional .tar.gz archive output")
|
||||
|
||||
sync_screens_p = sub.add_parser(
|
||||
"sync-screens",
|
||||
parents=[common],
|
||||
help="Synchronize data/story/screens and legacy_payloads/fs_excluded/screens from palette",
|
||||
)
|
||||
sync_screens_p.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="Check drift only (no writes); non-zero exit on mismatch",
|
||||
)
|
||||
|
||||
deploy_alias = sub.add_parser("deploy", parents=[common], help="Alias of generate-bundle")
|
||||
deploy_alias.add_argument("--out-dir", default="", help="Bundle root directory")
|
||||
deploy_alias.add_argument("--archive", default="", help="Optional .tar.gz archive output")
|
||||
|
||||
all_p = sub.add_parser("all", parents=[common], help="Validate + generate-cpp + generate-bundle")
|
||||
all_p.add_argument("--cpp-out-dir", default="", help="Output directory for generated C++")
|
||||
all_p.add_argument("--bundle-out-dir", default="", help="Bundle root directory")
|
||||
all_p.add_argument("--archive", default="", help="Optional .tar.gz archive output")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
paths = default_paths()
|
||||
spec_dir = _path_or_none(args.spec_dir)
|
||||
game_dir = _path_or_none(args.game_dir)
|
||||
|
||||
try:
|
||||
if args.command == "validate":
|
||||
result = run_validate(paths, spec_dir=spec_dir, game_dir=game_dir)
|
||||
print(
|
||||
f"[story-gen] OK validate scenarios={result['scenario_count']} "
|
||||
f"game_scenarios={result['game_scenario_count']}"
|
||||
)
|
||||
return 0
|
||||
|
||||
if args.command in {"generate-cpp", "generate"}:
|
||||
result = run_generate_cpp(
|
||||
paths,
|
||||
out_dir=_path_or_none(args.out_dir),
|
||||
spec_dir=spec_dir,
|
||||
game_dir=game_dir,
|
||||
)
|
||||
print(
|
||||
f"[story-gen] OK generate-cpp out={result['out_dir']} "
|
||||
f"scenarios={result['scenario_count']} spec_hash={result['spec_hash']}"
|
||||
)
|
||||
return 0
|
||||
|
||||
if args.command in {"generate-bundle", "deploy"}:
|
||||
archive = _path_or_none(args.archive)
|
||||
result = run_generate_bundle(
|
||||
paths,
|
||||
out_dir=_path_or_none(args.out_dir),
|
||||
archive=archive,
|
||||
spec_dir=spec_dir,
|
||||
game_dir=game_dir,
|
||||
)
|
||||
print(
|
||||
f"[story-gen] OK generate-bundle out={result['out_dir']} "
|
||||
f"scenarios={result['scenario_count']} spec_hash={result['spec_hash']}"
|
||||
)
|
||||
if result["archive"] is not None:
|
||||
print(f"[story-gen] archive={result['archive']}")
|
||||
return 0
|
||||
|
||||
if args.command == "sync-screens":
|
||||
result = run_sync_screens(paths, check_only=bool(args.check))
|
||||
mode = "check" if args.check else "write"
|
||||
print(
|
||||
f"[story-gen] OK sync-screens mode={mode} story={result['story_count']} "
|
||||
f"story_written={result['story_written']} legacy_written={result['legacy_written']} "
|
||||
f"palette={result['palette_path']} legacy_dir={result['legacy_dir']}"
|
||||
)
|
||||
return 0
|
||||
|
||||
if args.command == "all":
|
||||
validate = run_validate(paths, spec_dir=spec_dir, game_dir=game_dir)
|
||||
cpp = run_generate_cpp(
|
||||
paths,
|
||||
out_dir=_path_or_none(args.cpp_out_dir),
|
||||
spec_dir=spec_dir,
|
||||
game_dir=game_dir,
|
||||
)
|
||||
bundle = run_generate_bundle(
|
||||
paths,
|
||||
out_dir=_path_or_none(args.bundle_out_dir),
|
||||
archive=_path_or_none(args.archive),
|
||||
spec_dir=spec_dir,
|
||||
game_dir=game_dir,
|
||||
)
|
||||
print(
|
||||
"[story-gen] OK all "
|
||||
f"validate={validate['scenario_count']} cpp={cpp['scenario_count']} "
|
||||
f"bundle={bundle['scenario_count']} spec_hash={cpp['spec_hash']}"
|
||||
)
|
||||
if bundle["archive"] is not None:
|
||||
print(f"[story-gen] archive={bundle['archive']}")
|
||||
return 0
|
||||
|
||||
parser.error(f"Unknown command: {args.command}")
|
||||
return 2
|
||||
except StoryGenerationError as exc:
|
||||
print(f"[story-gen] ERR {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
id: str()
|
||||
version: int(min=1)
|
||||
title: str()
|
||||
theme: str()
|
||||
players: include('players')
|
||||
ages: str()
|
||||
duration_minutes: include('duration')
|
||||
canon: include('canon')
|
||||
stations: list(include('station'), min=1)
|
||||
puzzles: list(include('puzzle'), min=1)
|
||||
solution: include('solution')
|
||||
solution_unique: bool()
|
||||
finale_statement: str(required=False)
|
||||
notes: str(required=False)
|
||||
|
||||
---
|
||||
players:
|
||||
min: int(min=1)
|
||||
max: int(min=1)
|
||||
|
||||
duration:
|
||||
min: int(min=1)
|
||||
max: int(min=1)
|
||||
|
||||
canon:
|
||||
introduction: str(required=False)
|
||||
timeline: list(include('timeline_entry'), min=1)
|
||||
stakes: str(required=False)
|
||||
|
||||
timeline_entry:
|
||||
label: str()
|
||||
note: str()
|
||||
|
||||
station:
|
||||
name: str()
|
||||
focus: str()
|
||||
clue: str()
|
||||
|
||||
puzzle:
|
||||
id: str()
|
||||
type: str()
|
||||
clue: str()
|
||||
effect: str()
|
||||
|
||||
solution:
|
||||
culprit: str()
|
||||
motive: str()
|
||||
method: str()
|
||||
proof: list(str(), min=1)
|
||||
@@ -0,0 +1,2 @@
|
||||
prompt_input: any()
|
||||
current_firmware_snapshot: any(required=False)
|
||||
@@ -0,0 +1,37 @@
|
||||
id: str()
|
||||
version: int(min=1)
|
||||
initial_step: str()
|
||||
estimated_duration_s: int(required=False, min=0)
|
||||
debug_transition_bypass_enabled: bool(required=False)
|
||||
app_bindings: list(include('app_binding'), min=1)
|
||||
steps: list(include('step'), min=1)
|
||||
|
||||
---
|
||||
app_binding:
|
||||
id: str()
|
||||
app: enum('LA_DETECTOR', 'AUDIO_PACK', 'SCREEN_SCENE', 'MP3_GATE', 'WIFI_STACK', 'ESPNOW_STACK', 'QR_UNLOCK_APP')
|
||||
config: include('la_config', required=False)
|
||||
|
||||
la_config:
|
||||
hold_ms: int(required=False, min=100)
|
||||
unlock_event: str(required=False)
|
||||
require_listening: bool(required=False)
|
||||
|
||||
step:
|
||||
step_id: str()
|
||||
screen_scene_id: str()
|
||||
audio_pack_id: str(required=False)
|
||||
actions: list(str(), required=False)
|
||||
apps: list(str(), min=1)
|
||||
mp3_gate_open: bool()
|
||||
transitions: list(include('transition'))
|
||||
|
||||
transition:
|
||||
id: str(required=False)
|
||||
trigger: enum('on_event', 'after_ms', 'immediate')
|
||||
event_type: enum('none', 'unlock', 'audio_done', 'timer', 'serial', 'button', 'espnow', 'action')
|
||||
event_name: str()
|
||||
target_step_id: str()
|
||||
after_ms: int(min=0)
|
||||
priority: int(min=0)
|
||||
debug_only: bool(required=False)
|
||||
@@ -0,0 +1,57 @@
|
||||
// AUTO-GENERATED FILE - DO NOT EDIT
|
||||
// Generated by zacus_story_gen_ai (Yamale + Jinja2)
|
||||
// spec_hash: {{ spec_hash }}
|
||||
// scenarios: {{ scenario_count }}
|
||||
|
||||
#include "apps_gen.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace {
|
||||
constexpr AppBindingDef kGeneratedAppBindings[] = {
|
||||
{% for app in app_entries %} {"{{ app.id }}", {{ app.app_cpp }}},
|
||||
{% endfor %}};
|
||||
{% set la_configs = app_entries | selectattr('la_config') | list %}
|
||||
{% if la_configs %}
|
||||
constexpr LaDetectorAppConfigDef kGeneratedLaConfigs[] = {
|
||||
{% for app in la_configs %} {"{{ app.id }}", true, {{ app.la_config.hold_ms }}U, "{{ app.la_config.unlock_event }}", {{ 'true' if app.la_config.require_listening else 'false' }}},
|
||||
{% endfor %}};
|
||||
{% endif %}
|
||||
} // namespace
|
||||
|
||||
const AppBindingDef* generatedAppBindingById(const char* id) {
|
||||
if (id == nullptr || id[0] == '\0') {
|
||||
return nullptr;
|
||||
}
|
||||
for (const AppBindingDef& binding : kGeneratedAppBindings) {
|
||||
if (binding.id != nullptr && strcmp(binding.id, id) == 0) {
|
||||
return &binding;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
uint8_t generatedAppBindingCount() {
|
||||
return static_cast<uint8_t>(sizeof(kGeneratedAppBindings) / sizeof(kGeneratedAppBindings[0]));
|
||||
}
|
||||
|
||||
const char* generatedAppBindingIdAt(uint8_t index) {
|
||||
if (index >= generatedAppBindingCount()) {
|
||||
return nullptr;
|
||||
}
|
||||
return kGeneratedAppBindings[index].id;
|
||||
}
|
||||
|
||||
const LaDetectorAppConfigDef* generatedLaDetectorConfigByBindingId(const char* id) {
|
||||
if (id == nullptr || id[0] == '\0') {
|
||||
return nullptr;
|
||||
}
|
||||
{% if la_configs %}
|
||||
for (const LaDetectorAppConfigDef& cfg : kGeneratedLaConfigs) {
|
||||
if (cfg.bindingId != nullptr && strcmp(cfg.bindingId, id) == 0) {
|
||||
return &cfg;
|
||||
}
|
||||
}
|
||||
{% endif %}
|
||||
return nullptr;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// AUTO-GENERATED FILE - DO NOT EDIT
|
||||
// Generated by zacus_story_gen_ai (Yamale + Jinja2)
|
||||
// spec_hash: {{ spec_hash }}
|
||||
// scenarios: {{ scenario_count }}
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include "../core/scenario_def.h"
|
||||
|
||||
struct LaDetectorAppConfigDef {
|
||||
const char* bindingId;
|
||||
bool hasConfig;
|
||||
uint32_t holdMs;
|
||||
const char* unlockEvent;
|
||||
bool requireListening;
|
||||
};
|
||||
|
||||
const AppBindingDef* generatedAppBindingById(const char* id);
|
||||
uint8_t generatedAppBindingCount();
|
||||
const char* generatedAppBindingIdAt(uint8_t index);
|
||||
const LaDetectorAppConfigDef* generatedLaDetectorConfigByBindingId(const char* id);
|
||||
@@ -0,0 +1,78 @@
|
||||
// AUTO-GENERATED FILE - DO NOT EDIT
|
||||
// Generated by zacus_story_gen_ai (Yamale + Jinja2)
|
||||
// spec_hash: {{ spec_hash }}
|
||||
// scenarios: {{ scenario_count }}
|
||||
|
||||
#include "scenarios_gen.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace {
|
||||
{% for scenario in scenarios %}
|
||||
{% for step in scenario.steps %}
|
||||
{% if step.actions %}
|
||||
constexpr const char* {{ step.prefix }}Actions[] = {
|
||||
{% for action in step.actions %} "{{ action }}",
|
||||
{% endfor %}};
|
||||
{% endif %}
|
||||
{% if step.apps %}
|
||||
constexpr const char* {{ step.prefix }}Apps[] = {
|
||||
{% for app_id in step.apps %} "{{ app_id }}",
|
||||
{% endfor %}};
|
||||
{% endif %}
|
||||
{% if step.transitions %}
|
||||
constexpr TransitionDef {{ step.prefix }}Transitions[] = {
|
||||
{% for tr in step.transitions %} {"{{ tr.id }}", {{ tr.trigger_cpp }}, {{ tr.event_cpp }}, "{{ tr.event_name }}", {{ tr.after_ms }}U, "{{ tr.target_step_id }}", {{ tr.priority }}U, {{ tr.debug_only }}},
|
||||
{% endfor %}};
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
constexpr StepDef kScenario{{ scenario.index }}Steps[] = {
|
||||
{% for step in scenario.steps %} {"{{ step.id }}", {{ "{" }}{% if step.screen_scene_id == 'nullptr' %}nullptr{% else %}"{{ step.screen_scene_id }}"{% endif %}, {% if step.audio_pack_id == 'nullptr' %}nullptr{% else %}"{{ step.audio_pack_id }}"{% endif %}, {% if step.actions %}{{ step.prefix }}Actions{% else %}nullptr{% endif %}, {{ step.actions|length }}U, {% if step.apps %}{{ step.prefix }}Apps{% else %}nullptr{% endif %}, {{ step.apps|length }}U{{ "}" }}, {% if step.transitions %}{{ step.prefix }}Transitions{% else %}nullptr{% endif %}, {{ step.transitions|length }}U, {{ step.mp3_gate_open }}},
|
||||
{% endfor %}};
|
||||
|
||||
constexpr ScenarioDef kScenario{{ scenario.index }} = {
|
||||
"{{ scenario.id }}",
|
||||
{{ scenario.version }}U,
|
||||
kScenario{{ scenario.index }}Steps,
|
||||
{{ scenario.steps|length }}U,
|
||||
"{{ scenario.initial_step }}",
|
||||
};
|
||||
|
||||
{% endfor %}
|
||||
constexpr const ScenarioDef* kGeneratedScenarios[] = {
|
||||
{% for scenario in scenarios %} &kScenario{{ scenario.index }},
|
||||
{% endfor %}};
|
||||
|
||||
} // namespace
|
||||
|
||||
const ScenarioDef* generatedScenarioById(const char* id) {
|
||||
if (id == nullptr || id[0] == '\0') {
|
||||
return generatedScenarioDefault();
|
||||
}
|
||||
for (const ScenarioDef* scenario : kGeneratedScenarios) {
|
||||
if (scenario != nullptr && scenario->id != nullptr && strcmp(scenario->id, id) == 0) {
|
||||
return scenario;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const ScenarioDef* generatedScenarioDefault() {
|
||||
return (generatedScenarioCount() == 0U) ? nullptr : kGeneratedScenarios[0];
|
||||
}
|
||||
|
||||
uint8_t generatedScenarioCount() {
|
||||
return static_cast<uint8_t>(sizeof(kGeneratedScenarios) / sizeof(kGeneratedScenarios[0]));
|
||||
}
|
||||
|
||||
const char* generatedScenarioIdAt(uint8_t index) {
|
||||
if (index >= generatedScenarioCount()) {
|
||||
return nullptr;
|
||||
}
|
||||
return kGeneratedScenarios[index]->id;
|
||||
}
|
||||
|
||||
const char* generatedScenarioSpecHash() {
|
||||
return "{{ spec_hash }}";
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// AUTO-GENERATED FILE - DO NOT EDIT
|
||||
// Generated by zacus_story_gen_ai (Yamale + Jinja2)
|
||||
// spec_hash: {{ spec_hash }}
|
||||
// scenarios: {{ scenario_count }}
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include "../core/scenario_def.h"
|
||||
|
||||
const ScenarioDef* generatedScenarioById(const char* id);
|
||||
const ScenarioDef* generatedScenarioDefault();
|
||||
uint8_t generatedScenarioCount();
|
||||
const char* generatedScenarioIdAt(uint8_t index);
|
||||
const char* generatedScenarioSpecHash();
|
||||
@@ -0,0 +1,95 @@
|
||||
#pragma once
|
||||
/**
|
||||
* @file story_hal.h
|
||||
* @brief Hardware Abstraction Layer for the story engine.
|
||||
*
|
||||
* These interfaces decouple the portable story logic from
|
||||
* platform-specific APIs (Arduino, ESP-IDF, POSIX, etc.).
|
||||
* Each platform provides its own implementation.
|
||||
*/
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
namespace zacus_hal {
|
||||
|
||||
/// Filesystem abstraction — replaces LittleFS / SPIFFS / VFS.
|
||||
class IStoryStorage {
|
||||
public:
|
||||
virtual ~IStoryStorage() = default;
|
||||
|
||||
/// Check if a file exists at the given path.
|
||||
virtual bool exists(const char* path) = 0;
|
||||
|
||||
/// Read entire file into buffer. Returns bytes read, 0 on failure.
|
||||
virtual size_t readFile(const char* path, uint8_t* buf, size_t bufSize) = 0;
|
||||
|
||||
/// Write buffer to file. Returns true on success.
|
||||
virtual bool writeFile(const char* path, const uint8_t* data, size_t len) = 0;
|
||||
|
||||
/// List files in directory. Calls callback for each entry.
|
||||
/// callback(name, sizeBytes) — return false to stop iteration.
|
||||
virtual void listDir(const char* dirPath,
|
||||
bool (*callback)(const char* name, size_t sizeBytes, void* ctx),
|
||||
void* ctx) = 0;
|
||||
|
||||
/// Filesystem info.
|
||||
virtual bool fsInfo(uint32_t* totalBytes, uint32_t* usedBytes) = 0;
|
||||
};
|
||||
|
||||
/// Logging abstraction — replaces Serial.printf / ESP_LOGx.
|
||||
class IStoryLogger {
|
||||
public:
|
||||
virtual ~IStoryLogger() = default;
|
||||
|
||||
virtual void info(const char* tag, const char* fmt, ...) = 0;
|
||||
virtual void warn(const char* tag, const char* fmt, ...) = 0;
|
||||
virtual void error(const char* tag, const char* fmt, ...) = 0;
|
||||
virtual void debug(const char* tag, const char* fmt, ...) = 0;
|
||||
};
|
||||
|
||||
/// Timer abstraction — replaces millis() / esp_timer_get_time().
|
||||
class IStoryTimer {
|
||||
public:
|
||||
virtual ~IStoryTimer() = default;
|
||||
|
||||
/// Current time in milliseconds since boot.
|
||||
virtual uint32_t millis() = 0;
|
||||
|
||||
/// Delay (non-blocking if possible on the platform).
|
||||
virtual void delayMs(uint32_t ms) = 0;
|
||||
};
|
||||
|
||||
/// Audio playback abstraction — replaces audio_manager.
|
||||
class IStoryAudio {
|
||||
public:
|
||||
virtual ~IStoryAudio() = default;
|
||||
|
||||
/// Play an audio file (path on filesystem or URL).
|
||||
virtual bool play(const char* source, uint8_t volume = 80, bool loop = false) = 0;
|
||||
|
||||
/// Stop current playback.
|
||||
virtual void stop() = 0;
|
||||
|
||||
/// Check if audio is currently playing.
|
||||
virtual bool isPlaying() = 0;
|
||||
|
||||
/// Set master volume (0-100).
|
||||
virtual void setVolume(uint8_t volume) = 0;
|
||||
};
|
||||
|
||||
/// Global HAL instance — set once at startup.
|
||||
struct StoryHAL {
|
||||
IStoryStorage* storage = nullptr;
|
||||
IStoryLogger* logger = nullptr;
|
||||
IStoryTimer* timer = nullptr;
|
||||
IStoryAudio* audio = nullptr;
|
||||
};
|
||||
|
||||
/// Set the global HAL. Must be called before any story engine use.
|
||||
void setHAL(const StoryHAL& hal);
|
||||
|
||||
/// Get the global HAL.
|
||||
const StoryHAL& getHAL();
|
||||
|
||||
} // namespace zacus_hal
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* @file hal_arduino.cpp
|
||||
* @brief Arduino/PlatformIO implementation of the Story HAL.
|
||||
*
|
||||
* Compile this only when building with Arduino framework.
|
||||
* For ESP-IDF, use hal_espidf.cpp instead.
|
||||
*/
|
||||
#ifdef ARDUINO
|
||||
|
||||
#include "zacus_story_portable/story_hal.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <LittleFS.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
|
||||
namespace zacus_hal {
|
||||
|
||||
// --- Arduino Storage (LittleFS) ---
|
||||
|
||||
class ArduinoStorage final : public IStoryStorage {
|
||||
public:
|
||||
bool exists(const char* path) override {
|
||||
return LittleFS.exists(path);
|
||||
}
|
||||
|
||||
size_t readFile(const char* path, uint8_t* buf, size_t bufSize) override {
|
||||
File f = LittleFS.open(path, "r");
|
||||
if (!f) return 0;
|
||||
size_t read = f.read(buf, bufSize);
|
||||
f.close();
|
||||
return read;
|
||||
}
|
||||
|
||||
bool writeFile(const char* path, const uint8_t* data, size_t len) override {
|
||||
File f = LittleFS.open(path, "w");
|
||||
if (!f) return false;
|
||||
size_t written = f.write(data, len);
|
||||
f.close();
|
||||
return written == len;
|
||||
}
|
||||
|
||||
void listDir(const char* dirPath,
|
||||
bool (*callback)(const char* name, size_t sizeBytes, void* ctx),
|
||||
void* ctx) override {
|
||||
File root = LittleFS.open(dirPath);
|
||||
if (!root || !root.isDirectory()) return;
|
||||
File file = root.openNextFile();
|
||||
while (file) {
|
||||
if (!callback(file.name(), file.size(), ctx)) break;
|
||||
file = root.openNextFile();
|
||||
}
|
||||
}
|
||||
|
||||
bool fsInfo(uint32_t* totalBytes, uint32_t* usedBytes) override {
|
||||
if (totalBytes) *totalBytes = LittleFS.totalBytes();
|
||||
if (usedBytes) *usedBytes = LittleFS.usedBytes();
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Arduino Logger (Serial) ---
|
||||
|
||||
class ArduinoLogger final : public IStoryLogger {
|
||||
public:
|
||||
void info(const char* tag, const char* fmt, ...) override {
|
||||
va_list args;
|
||||
va_start(args, fmt);
|
||||
Serial.printf("[I][%s] ", tag);
|
||||
vprintf(fmt, args);
|
||||
Serial.println();
|
||||
va_end(args);
|
||||
}
|
||||
|
||||
void warn(const char* tag, const char* fmt, ...) override {
|
||||
va_list args;
|
||||
va_start(args, fmt);
|
||||
Serial.printf("[W][%s] ", tag);
|
||||
vprintf(fmt, args);
|
||||
Serial.println();
|
||||
va_end(args);
|
||||
}
|
||||
|
||||
void error(const char* tag, const char* fmt, ...) override {
|
||||
va_list args;
|
||||
va_start(args, fmt);
|
||||
Serial.printf("[E][%s] ", tag);
|
||||
vprintf(fmt, args);
|
||||
Serial.println();
|
||||
va_end(args);
|
||||
}
|
||||
|
||||
void debug(const char* tag, const char* fmt, ...) override {
|
||||
va_list args;
|
||||
va_start(args, fmt);
|
||||
Serial.printf("[D][%s] ", tag);
|
||||
vprintf(fmt, args);
|
||||
Serial.println();
|
||||
va_end(args);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Arduino Timer (millis) ---
|
||||
|
||||
class ArduinoTimer final : public IStoryTimer {
|
||||
public:
|
||||
uint32_t millis() override {
|
||||
return ::millis();
|
||||
}
|
||||
|
||||
void delayMs(uint32_t ms) override {
|
||||
::delay(ms);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Arduino Audio (stub — real impl in audio_manager) ---
|
||||
|
||||
class ArduinoAudioStub final : public IStoryAudio {
|
||||
public:
|
||||
bool play(const char*, uint8_t, bool) override { return false; }
|
||||
void stop() override {}
|
||||
bool isPlaying() override { return false; }
|
||||
void setVolume(uint8_t) override {}
|
||||
};
|
||||
|
||||
// --- Singleton instances ---
|
||||
|
||||
static ArduinoStorage s_storage;
|
||||
static ArduinoLogger s_logger;
|
||||
static ArduinoTimer s_timer;
|
||||
static ArduinoAudioStub s_audio;
|
||||
|
||||
/// Call this in setup() to initialize the Arduino HAL.
|
||||
void initArduinoHAL() {
|
||||
StoryHAL hal;
|
||||
hal.storage = &s_storage;
|
||||
hal.logger = &s_logger;
|
||||
hal.timer = &s_timer;
|
||||
hal.audio = &s_audio;
|
||||
setHAL(hal);
|
||||
}
|
||||
|
||||
} // namespace zacus_hal
|
||||
|
||||
#endif // ARDUINO
|
||||
@@ -0,0 +1,15 @@
|
||||
#include "zacus_story_portable/story_hal.h"
|
||||
|
||||
namespace zacus_hal {
|
||||
|
||||
static StoryHAL g_hal = {};
|
||||
|
||||
void setHAL(const StoryHAL& hal) {
|
||||
g_hal = hal;
|
||||
}
|
||||
|
||||
const StoryHAL& getHAL() {
|
||||
return g_hal;
|
||||
}
|
||||
|
||||
} // namespace zacus_hal
|
||||
@@ -0,0 +1,112 @@
|
||||
// npc_engine.h - Professor Zacus NPC decision engine.
|
||||
// Lightweight state machine: trigger rules, mood system, phrase selection.
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define NPC_MAX_SCENES 12
|
||||
#define NPC_MAX_HINT_LEVEL 3
|
||||
#define NPC_PHRASE_MAX_LEN 200
|
||||
#define NPC_STUCK_TIMEOUT_MS (3UL * 60UL * 1000UL)
|
||||
#define NPC_FAST_THRESHOLD_PCT 50
|
||||
#define NPC_SLOW_THRESHOLD_PCT 150
|
||||
#define NPC_QR_DEBOUNCE_MS 30000
|
||||
|
||||
typedef enum {
|
||||
NPC_MOOD_NEUTRAL = 0,
|
||||
NPC_MOOD_IMPRESSED,
|
||||
NPC_MOOD_WORRIED,
|
||||
NPC_MOOD_AMUSED,
|
||||
NPC_MOOD_COUNT
|
||||
} npc_mood_t;
|
||||
|
||||
typedef enum {
|
||||
NPC_TRIGGER_NONE = 0,
|
||||
NPC_TRIGGER_HINT_REQUEST,
|
||||
NPC_TRIGGER_STUCK_TIMER,
|
||||
NPC_TRIGGER_QR_SCANNED,
|
||||
NPC_TRIGGER_WRONG_ACTION,
|
||||
NPC_TRIGGER_FAST_PROGRESS,
|
||||
NPC_TRIGGER_SLOW_PROGRESS,
|
||||
NPC_TRIGGER_SCENE_TRANSITION,
|
||||
NPC_TRIGGER_GAME_START,
|
||||
NPC_TRIGGER_GAME_END,
|
||||
NPC_TRIGGER_COUNT
|
||||
} npc_trigger_t;
|
||||
|
||||
typedef enum {
|
||||
NPC_AUDIO_NONE = 0,
|
||||
NPC_AUDIO_LIVE_TTS,
|
||||
NPC_AUDIO_SD_CONTEXTUAL,
|
||||
NPC_AUDIO_SD_GENERIC
|
||||
} npc_audio_source_t;
|
||||
|
||||
typedef struct {
|
||||
uint8_t current_scene;
|
||||
uint8_t current_step;
|
||||
uint32_t scene_start_ms;
|
||||
uint32_t total_elapsed_ms;
|
||||
uint8_t hints_given[NPC_MAX_SCENES];
|
||||
uint8_t qr_scanned_count;
|
||||
uint8_t failed_attempts;
|
||||
bool phone_off_hook;
|
||||
bool tower_reachable;
|
||||
npc_mood_t mood;
|
||||
uint32_t last_qr_scan_ms;
|
||||
uint32_t expected_scene_duration_ms;
|
||||
} npc_state_t;
|
||||
|
||||
typedef struct {
|
||||
npc_trigger_t trigger;
|
||||
npc_audio_source_t audio_source;
|
||||
char phrase_text[NPC_PHRASE_MAX_LEN];
|
||||
char sd_path[128];
|
||||
npc_mood_t resulting_mood;
|
||||
} npc_decision_t;
|
||||
|
||||
/// Initialize NPC state to defaults.
|
||||
void npc_init(npc_state_t* state);
|
||||
|
||||
/// Reset NPC state for a new game session.
|
||||
void npc_reset(npc_state_t* state);
|
||||
|
||||
/// Evaluate trigger rules and produce a decision.
|
||||
/// Returns true if NPC wants to speak.
|
||||
bool npc_evaluate(const npc_state_t* state, uint32_t now_ms, npc_decision_t* out);
|
||||
|
||||
/// Notify NPC of a scene change.
|
||||
void npc_on_scene_change(npc_state_t* state, uint8_t new_scene,
|
||||
uint32_t expected_duration_ms, uint32_t now_ms);
|
||||
|
||||
/// Notify NPC of a QR scan result.
|
||||
void npc_on_qr_scan(npc_state_t* state, bool valid, uint32_t now_ms);
|
||||
|
||||
/// Notify NPC that player picked up / hung up phone.
|
||||
void npc_on_phone_hook(npc_state_t* state, bool off_hook);
|
||||
|
||||
/// Notify NPC of hint request (phone picked up while stuck).
|
||||
void npc_on_hint_request(npc_state_t* state, uint32_t now_ms);
|
||||
|
||||
/// Notify NPC of Tower TTS reachability change.
|
||||
void npc_on_tower_status(npc_state_t* state, bool reachable);
|
||||
|
||||
/// Update mood based on progress ratio (elapsed_ms / expected_ms).
|
||||
void npc_update_mood(npc_state_t* state, uint32_t now_ms);
|
||||
|
||||
/// Get the current hint level for a scene (0 = no hints given, max 3).
|
||||
uint8_t npc_hint_level(const npc_state_t* state, uint8_t scene);
|
||||
|
||||
/// Build SD card fallback path for a given trigger + scene.
|
||||
/// Writes to out_path, returns true if a valid path was built.
|
||||
bool npc_build_sd_path(char* out_path, size_t capacity,
|
||||
uint8_t scene, npc_trigger_t trigger,
|
||||
npc_mood_t mood, uint8_t variant);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,68 @@
|
||||
// qr_scanner.h - QR decode task with ZACUS protocol, debounce, anti-cheat.
|
||||
// Wraps the existing ui::QrScanController with NPC-level logic.
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define QR_PREFIX "ZACUS:"
|
||||
#define QR_PREFIX_LEN 6
|
||||
#define QR_SCENE_ID_MAX 32
|
||||
#define QR_EVENT_ID_MAX 32
|
||||
#define QR_CHECKSUM_LEN 4
|
||||
#define QR_DEBOUNCE_MS 30000
|
||||
#define QR_HMAC_KEY "zacus-escape-2026"
|
||||
#define QR_MAX_HISTORY 16
|
||||
|
||||
typedef enum {
|
||||
QR_VALID = 0,
|
||||
QR_INVALID_FORMAT,
|
||||
QR_INVALID_CHECKSUM,
|
||||
QR_WRONG_SCENE,
|
||||
QR_DEBOUNCED,
|
||||
QR_ALREADY_SCANNED
|
||||
} qr_validation_t;
|
||||
|
||||
typedef struct {
|
||||
char scene_id[QR_SCENE_ID_MAX];
|
||||
char event_id[QR_EVENT_ID_MAX];
|
||||
char checksum[QR_CHECKSUM_LEN + 1];
|
||||
qr_validation_t status;
|
||||
uint32_t scanned_at_ms;
|
||||
} qr_decode_result_t;
|
||||
|
||||
/// Initialize QR scanner subsystem.
|
||||
void qr_npc_init(void);
|
||||
|
||||
/// Parse a raw QR payload string into structured result.
|
||||
/// Does NOT validate scene or checksum yet.
|
||||
bool qr_npc_parse(const char* payload, qr_decode_result_t* out);
|
||||
|
||||
/// Validate a parsed QR result against current game state.
|
||||
/// current_scene_id: the scene the player is currently in.
|
||||
/// now_ms: current time for debounce check.
|
||||
qr_validation_t qr_npc_validate(const qr_decode_result_t* decoded,
|
||||
const char* current_scene_id,
|
||||
uint32_t now_ms);
|
||||
|
||||
/// Compute HMAC-SHA256 checksum (truncated to 4 hex chars).
|
||||
/// Input: "SCENE_ID:EVENT_ID", output: 4 hex chars in out_checksum.
|
||||
void qr_npc_compute_checksum(const char* scene_id, const char* event_id,
|
||||
char out_checksum[5]);
|
||||
|
||||
/// Record a scan in history (for anti-cheat / dedup).
|
||||
void qr_npc_record_scan(const char* scene_id, const char* event_id, uint32_t now_ms);
|
||||
|
||||
/// Check if a specific QR was already scanned.
|
||||
bool qr_npc_was_scanned(const char* scene_id, const char* event_id);
|
||||
|
||||
/// Get total valid scans count.
|
||||
uint8_t qr_npc_scan_count(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,63 @@
|
||||
// tts_client.h - HTTP client for Piper TTS on Tower:8001 with SD fallback.
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define TTS_TOWER_IP "192.168.0.120"
|
||||
#define TTS_TOWER_PORT 8001
|
||||
#define TTS_API_PATH "/api/tts"
|
||||
#define TTS_VOICE "tom-medium"
|
||||
#define TTS_TIMEOUT_MS 2000
|
||||
#define TTS_HEALTH_INTERVAL_MS 30000
|
||||
#define TTS_MAX_TEXT_LEN 200
|
||||
#define TTS_WAV_BUF_SIZE (64 * 1024)
|
||||
|
||||
typedef enum {
|
||||
TTS_RESULT_OK = 0,
|
||||
TTS_RESULT_TIMEOUT,
|
||||
TTS_RESULT_HTTP_ERROR,
|
||||
TTS_RESULT_ALLOC_FAIL,
|
||||
TTS_RESULT_TOWER_DOWN,
|
||||
TTS_RESULT_TEXT_TOO_LONG
|
||||
} tts_result_t;
|
||||
|
||||
typedef struct {
|
||||
bool tower_reachable;
|
||||
uint32_t last_health_check_ms;
|
||||
uint32_t last_latency_ms;
|
||||
uint32_t total_requests;
|
||||
uint32_t total_failures;
|
||||
} tts_stats_t;
|
||||
|
||||
/// Initialize TTS client (call once from setup).
|
||||
void tts_init(void);
|
||||
|
||||
/// Check Tower TTS health (non-blocking, HEAD request).
|
||||
/// Updates internal reachability state.
|
||||
/// Returns true if Tower responded within TTS_TIMEOUT_MS.
|
||||
bool tts_check_health(uint32_t now_ms);
|
||||
|
||||
/// Periodic health check — only pings if TTS_HEALTH_INTERVAL_MS elapsed.
|
||||
void tts_health_tick(uint32_t now_ms);
|
||||
|
||||
/// Query last known Tower reachability.
|
||||
bool tts_is_tower_reachable(void);
|
||||
|
||||
/// Request TTS synthesis. Blocking call (up to TTS_TIMEOUT_MS).
|
||||
/// On success, writes WAV data to out_buf and sets *out_len.
|
||||
/// Caller must allocate out_buf of at least TTS_WAV_BUF_SIZE bytes (use PSRAM).
|
||||
tts_result_t tts_synthesize(const char* text, uint8_t* out_buf,
|
||||
size_t buf_capacity, size_t* out_len);
|
||||
|
||||
/// Get TTS client statistics.
|
||||
tts_stats_t tts_get_stats(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,198 @@
|
||||
// npc_engine.cpp - Professor Zacus NPC decision engine implementation.
|
||||
#include "npc/npc_engine.h"
|
||||
#include <cstring>
|
||||
#include <cstdio>
|
||||
|
||||
// Scene ID strings for SD path generation (must match zacus_v2.yaml step order).
|
||||
static const char* const kSceneIds[] = {
|
||||
"SCENE_U_SON_PROTO",
|
||||
"SCENE_LA_DETECTOR",
|
||||
"SCENE_WIN_ETAPE1",
|
||||
"SCENE_WARNING",
|
||||
"SCENE_LEFOU_DETECTOR",
|
||||
"SCENE_WIN_ETAPE2",
|
||||
"SCENE_QR_DETECTOR",
|
||||
"SCENE_FINAL_WIN"
|
||||
};
|
||||
static const uint8_t kSceneCount = sizeof(kSceneIds) / sizeof(kSceneIds[0]);
|
||||
|
||||
static const char* const kTriggerDirs[] = {
|
||||
[NPC_TRIGGER_NONE] = "generic",
|
||||
[NPC_TRIGGER_HINT_REQUEST] = "indice",
|
||||
[NPC_TRIGGER_STUCK_TIMER] = "indice",
|
||||
[NPC_TRIGGER_QR_SCANNED] = "felicitations",
|
||||
[NPC_TRIGGER_WRONG_ACTION] = "attention",
|
||||
[NPC_TRIGGER_FAST_PROGRESS] = "fausse_piste",
|
||||
[NPC_TRIGGER_SLOW_PROGRESS] = "adaptation",
|
||||
[NPC_TRIGGER_SCENE_TRANSITION] = "transition",
|
||||
[NPC_TRIGGER_GAME_START] = "ambiance",
|
||||
[NPC_TRIGGER_GAME_END] = "ambiance",
|
||||
};
|
||||
|
||||
static const char* const kMoodSuffixes[] = {
|
||||
[NPC_MOOD_NEUTRAL] = "neutral",
|
||||
[NPC_MOOD_IMPRESSED] = "impressed",
|
||||
[NPC_MOOD_WORRIED] = "worried",
|
||||
[NPC_MOOD_AMUSED] = "amused",
|
||||
};
|
||||
|
||||
void npc_init(npc_state_t* state) {
|
||||
if (state == NULL) return;
|
||||
memset(state, 0, sizeof(*state));
|
||||
state->mood = NPC_MOOD_NEUTRAL;
|
||||
}
|
||||
|
||||
void npc_reset(npc_state_t* state) {
|
||||
npc_init(state);
|
||||
}
|
||||
|
||||
void npc_on_scene_change(npc_state_t* state, uint8_t new_scene,
|
||||
uint32_t expected_duration_ms, uint32_t now_ms) {
|
||||
if (state == NULL) return;
|
||||
state->current_scene = new_scene;
|
||||
state->scene_start_ms = now_ms;
|
||||
state->expected_scene_duration_ms = expected_duration_ms;
|
||||
state->failed_attempts = 0;
|
||||
}
|
||||
|
||||
void npc_on_qr_scan(npc_state_t* state, bool valid, uint32_t now_ms) {
|
||||
if (state == NULL) return;
|
||||
if (valid) {
|
||||
state->qr_scanned_count++;
|
||||
} else {
|
||||
state->failed_attempts++;
|
||||
}
|
||||
state->last_qr_scan_ms = now_ms;
|
||||
}
|
||||
|
||||
void npc_on_phone_hook(npc_state_t* state, bool off_hook) {
|
||||
if (state == NULL) return;
|
||||
state->phone_off_hook = off_hook;
|
||||
}
|
||||
|
||||
void npc_on_hint_request(npc_state_t* state, uint32_t now_ms) {
|
||||
if (state == NULL) return;
|
||||
uint8_t scene = state->current_scene;
|
||||
if (scene < NPC_MAX_SCENES && state->hints_given[scene] < NPC_MAX_HINT_LEVEL) {
|
||||
state->hints_given[scene]++;
|
||||
}
|
||||
(void)now_ms;
|
||||
}
|
||||
|
||||
void npc_on_tower_status(npc_state_t* state, bool reachable) {
|
||||
if (state == NULL) return;
|
||||
state->tower_reachable = reachable;
|
||||
}
|
||||
|
||||
void npc_update_mood(npc_state_t* state, uint32_t now_ms) {
|
||||
if (state == NULL || state->expected_scene_duration_ms == 0) return;
|
||||
uint32_t elapsed = now_ms - state->scene_start_ms;
|
||||
uint32_t expected = state->expected_scene_duration_ms;
|
||||
uint32_t pct = (elapsed * 100U) / expected;
|
||||
|
||||
if (state->failed_attempts >= 3) {
|
||||
state->mood = NPC_MOOD_AMUSED;
|
||||
} else if (pct < NPC_FAST_THRESHOLD_PCT) {
|
||||
state->mood = NPC_MOOD_IMPRESSED;
|
||||
} else if (pct > NPC_SLOW_THRESHOLD_PCT) {
|
||||
state->mood = NPC_MOOD_WORRIED;
|
||||
} else {
|
||||
state->mood = NPC_MOOD_NEUTRAL;
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t npc_hint_level(const npc_state_t* state, uint8_t scene) {
|
||||
if (state == NULL || scene >= NPC_MAX_SCENES) return 0;
|
||||
return state->hints_given[scene];
|
||||
}
|
||||
|
||||
bool npc_build_sd_path(char* out_path, size_t capacity,
|
||||
uint8_t scene, npc_trigger_t trigger,
|
||||
npc_mood_t mood, uint8_t variant) {
|
||||
if (out_path == NULL || capacity < 16) return false;
|
||||
|
||||
const char* scene_id = (scene < kSceneCount) ? kSceneIds[scene] : "npc";
|
||||
const char* trigger_dir = (trigger < NPC_TRIGGER_COUNT)
|
||||
? kTriggerDirs[trigger] : "generic";
|
||||
const char* mood_str = (mood < NPC_MOOD_COUNT)
|
||||
? kMoodSuffixes[mood] : "neutral";
|
||||
|
||||
// Per-scene triggers: /hotline_tts/{scene_id}/{trigger}_{mood}_{variant}.mp3
|
||||
// Generic NPC: /hotline_tts/npc/{trigger}_{mood}_{variant}.mp3
|
||||
bool is_scene_specific = (trigger != NPC_TRIGGER_GAME_START
|
||||
&& trigger != NPC_TRIGGER_GAME_END
|
||||
&& trigger != NPC_TRIGGER_NONE);
|
||||
|
||||
int written;
|
||||
if (is_scene_specific && scene < kSceneCount) {
|
||||
written = snprintf(out_path, capacity,
|
||||
"/hotline_tts/%s/%s_%s_%u.mp3",
|
||||
scene_id, trigger_dir, mood_str, (unsigned)variant);
|
||||
} else {
|
||||
written = snprintf(out_path, capacity,
|
||||
"/hotline_tts/npc/%s_%s_%u.mp3",
|
||||
trigger_dir, mood_str, (unsigned)variant);
|
||||
}
|
||||
return (written > 0 && (size_t)written < capacity);
|
||||
}
|
||||
|
||||
bool npc_evaluate(const npc_state_t* state, uint32_t now_ms, npc_decision_t* out) {
|
||||
if (state == NULL || out == NULL) return false;
|
||||
memset(out, 0, sizeof(*out));
|
||||
|
||||
uint32_t scene_elapsed = now_ms - state->scene_start_ms;
|
||||
uint32_t expected = state->expected_scene_duration_ms;
|
||||
|
||||
// Priority 1: Hint request (phone off hook while stuck)
|
||||
if (state->phone_off_hook && scene_elapsed > NPC_STUCK_TIMEOUT_MS) {
|
||||
uint8_t level = npc_hint_level(state, state->current_scene);
|
||||
out->trigger = NPC_TRIGGER_HINT_REQUEST;
|
||||
out->resulting_mood = state->mood;
|
||||
npc_build_sd_path(out->sd_path, sizeof(out->sd_path),
|
||||
state->current_scene, NPC_TRIGGER_HINT_REQUEST,
|
||||
state->mood, level);
|
||||
out->audio_source = state->tower_reachable
|
||||
? NPC_AUDIO_LIVE_TTS : NPC_AUDIO_SD_CONTEXTUAL;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Priority 2: Stuck timer (proactive, no phone needed)
|
||||
if (scene_elapsed > NPC_STUCK_TIMEOUT_MS
|
||||
&& npc_hint_level(state, state->current_scene) == 0) {
|
||||
out->trigger = NPC_TRIGGER_STUCK_TIMER;
|
||||
out->resulting_mood = NPC_MOOD_WORRIED;
|
||||
npc_build_sd_path(out->sd_path, sizeof(out->sd_path),
|
||||
state->current_scene, NPC_TRIGGER_STUCK_TIMER,
|
||||
NPC_MOOD_WORRIED, 0);
|
||||
out->audio_source = state->tower_reachable
|
||||
? NPC_AUDIO_LIVE_TTS : NPC_AUDIO_SD_CONTEXTUAL;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Priority 3: Fast progress detection
|
||||
if (expected > 0 && scene_elapsed > 0
|
||||
&& (scene_elapsed * 100U / expected) < NPC_FAST_THRESHOLD_PCT) {
|
||||
out->trigger = NPC_TRIGGER_FAST_PROGRESS;
|
||||
out->resulting_mood = NPC_MOOD_IMPRESSED;
|
||||
npc_build_sd_path(out->sd_path, sizeof(out->sd_path),
|
||||
state->current_scene, NPC_TRIGGER_FAST_PROGRESS,
|
||||
NPC_MOOD_IMPRESSED, 0);
|
||||
out->audio_source = state->tower_reachable
|
||||
? NPC_AUDIO_LIVE_TTS : NPC_AUDIO_SD_CONTEXTUAL;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Priority 4: Slow progress detection
|
||||
if (expected > 0 && (scene_elapsed * 100U / expected) > NPC_SLOW_THRESHOLD_PCT) {
|
||||
out->trigger = NPC_TRIGGER_SLOW_PROGRESS;
|
||||
out->resulting_mood = NPC_MOOD_WORRIED;
|
||||
npc_build_sd_path(out->sd_path, sizeof(out->sd_path),
|
||||
state->current_scene, NPC_TRIGGER_SLOW_PROGRESS,
|
||||
NPC_MOOD_WORRIED, 0);
|
||||
out->audio_source = state->tower_reachable
|
||||
? NPC_AUDIO_LIVE_TTS : NPC_AUDIO_SD_CONTEXTUAL;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// qr_scanner.cpp - ZACUS QR protocol parser, validator, anti-cheat.
|
||||
#include "npc/qr_scanner.h"
|
||||
#include <cstring>
|
||||
#include <cstdio>
|
||||
#include <mbedtls/md.h>
|
||||
|
||||
typedef struct {
|
||||
char scene_id[QR_SCENE_ID_MAX];
|
||||
char event_id[QR_EVENT_ID_MAX];
|
||||
uint32_t at_ms;
|
||||
} qr_history_entry_t;
|
||||
|
||||
static qr_history_entry_t s_history[QR_MAX_HISTORY];
|
||||
static uint8_t s_history_count = 0;
|
||||
static uint32_t s_last_scan_ms = 0;
|
||||
static char s_last_payload[QR_SCENE_ID_MAX + QR_EVENT_ID_MAX + 8] = {0};
|
||||
|
||||
void qr_npc_init(void) {
|
||||
memset(s_history, 0, sizeof(s_history));
|
||||
s_history_count = 0;
|
||||
s_last_scan_ms = 0;
|
||||
s_last_payload[0] = '\0';
|
||||
}
|
||||
|
||||
bool qr_npc_parse(const char* payload, qr_decode_result_t* out) {
|
||||
if (payload == NULL || out == NULL) return false;
|
||||
memset(out, 0, sizeof(*out));
|
||||
|
||||
// Expected: ZACUS:{scene_id}:{event_id}:{checksum}
|
||||
if (strncmp(payload, QR_PREFIX, QR_PREFIX_LEN) != 0) {
|
||||
out->status = QR_INVALID_FORMAT;
|
||||
return false;
|
||||
}
|
||||
|
||||
const char* p = payload + QR_PREFIX_LEN;
|
||||
|
||||
// Parse scene_id
|
||||
const char* sep1 = strchr(p, ':');
|
||||
if (sep1 == NULL) { out->status = QR_INVALID_FORMAT; return false; }
|
||||
size_t scene_len = (size_t)(sep1 - p);
|
||||
if (scene_len == 0 || scene_len >= QR_SCENE_ID_MAX) {
|
||||
out->status = QR_INVALID_FORMAT; return false;
|
||||
}
|
||||
memcpy(out->scene_id, p, scene_len);
|
||||
out->scene_id[scene_len] = '\0';
|
||||
|
||||
// Parse event_id
|
||||
const char* p2 = sep1 + 1;
|
||||
const char* sep2 = strchr(p2, ':');
|
||||
if (sep2 == NULL) { out->status = QR_INVALID_FORMAT; return false; }
|
||||
size_t event_len = (size_t)(sep2 - p2);
|
||||
if (event_len == 0 || event_len >= QR_EVENT_ID_MAX) {
|
||||
out->status = QR_INVALID_FORMAT; return false;
|
||||
}
|
||||
memcpy(out->event_id, p2, event_len);
|
||||
out->event_id[event_len] = '\0';
|
||||
|
||||
// Parse checksum (4 hex chars)
|
||||
const char* p3 = sep2 + 1;
|
||||
size_t ck_len = strlen(p3);
|
||||
if (ck_len != QR_CHECKSUM_LEN) {
|
||||
out->status = QR_INVALID_FORMAT; return false;
|
||||
}
|
||||
memcpy(out->checksum, p3, QR_CHECKSUM_LEN);
|
||||
out->checksum[QR_CHECKSUM_LEN] = '\0';
|
||||
|
||||
out->status = QR_VALID;
|
||||
return true;
|
||||
}
|
||||
|
||||
void qr_npc_compute_checksum(const char* scene_id, const char* event_id,
|
||||
char out_checksum[5]) {
|
||||
if (scene_id == NULL || event_id == NULL || out_checksum == NULL) return;
|
||||
|
||||
char input[QR_SCENE_ID_MAX + QR_EVENT_ID_MAX + 2];
|
||||
snprintf(input, sizeof(input), "%s:%s", scene_id, event_id);
|
||||
|
||||
uint8_t hmac[32];
|
||||
mbedtls_md_context_t ctx;
|
||||
mbedtls_md_init(&ctx);
|
||||
const mbedtls_md_info_t* info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
|
||||
mbedtls_md_setup(&ctx, info, 1);
|
||||
mbedtls_md_hmac_starts(&ctx, (const uint8_t*)QR_HMAC_KEY, strlen(QR_HMAC_KEY));
|
||||
mbedtls_md_hmac_update(&ctx, (const uint8_t*)input, strlen(input));
|
||||
mbedtls_md_hmac_finish(&ctx, hmac);
|
||||
mbedtls_md_free(&ctx);
|
||||
|
||||
// Truncate to first 2 bytes = 4 hex chars
|
||||
snprintf(out_checksum, 5, "%02X%02X", hmac[0], hmac[1]);
|
||||
}
|
||||
|
||||
qr_validation_t qr_npc_validate(const qr_decode_result_t* decoded,
|
||||
const char* current_scene_id,
|
||||
uint32_t now_ms) {
|
||||
if (decoded == NULL || decoded->status != QR_VALID) {
|
||||
return QR_INVALID_FORMAT;
|
||||
}
|
||||
|
||||
// Checksum verification
|
||||
char expected[5];
|
||||
qr_npc_compute_checksum(decoded->scene_id, decoded->event_id, expected);
|
||||
if (strncmp(expected, decoded->checksum, QR_CHECKSUM_LEN) != 0) {
|
||||
return QR_INVALID_CHECKSUM;
|
||||
}
|
||||
|
||||
// Scene check: QR only valid for its designated scene
|
||||
if (current_scene_id != NULL
|
||||
&& strcmp(decoded->scene_id, current_scene_id) != 0) {
|
||||
return QR_WRONG_SCENE;
|
||||
}
|
||||
|
||||
// Already scanned check
|
||||
if (qr_npc_was_scanned(decoded->scene_id, decoded->event_id)) {
|
||||
return QR_ALREADY_SCANNED;
|
||||
}
|
||||
|
||||
// Debounce: same raw payload within QR_DEBOUNCE_MS
|
||||
char key[QR_SCENE_ID_MAX + QR_EVENT_ID_MAX + 2];
|
||||
snprintf(key, sizeof(key), "%s:%s", decoded->scene_id, decoded->event_id);
|
||||
if (strcmp(key, s_last_payload) == 0
|
||||
&& (now_ms - s_last_scan_ms) < QR_DEBOUNCE_MS) {
|
||||
return QR_DEBOUNCED;
|
||||
}
|
||||
|
||||
return QR_VALID;
|
||||
}
|
||||
|
||||
void qr_npc_record_scan(const char* scene_id, const char* event_id, uint32_t now_ms) {
|
||||
if (scene_id == NULL || event_id == NULL) return;
|
||||
|
||||
// Update debounce state
|
||||
snprintf(s_last_payload, sizeof(s_last_payload), "%s:%s", scene_id, event_id);
|
||||
s_last_scan_ms = now_ms;
|
||||
|
||||
// Add to history (ring buffer)
|
||||
if (s_history_count < QR_MAX_HISTORY) {
|
||||
qr_history_entry_t* entry = &s_history[s_history_count++];
|
||||
strncpy(entry->scene_id, scene_id, QR_SCENE_ID_MAX - 1);
|
||||
strncpy(entry->event_id, event_id, QR_EVENT_ID_MAX - 1);
|
||||
entry->at_ms = now_ms;
|
||||
}
|
||||
}
|
||||
|
||||
bool qr_npc_was_scanned(const char* scene_id, const char* event_id) {
|
||||
if (scene_id == NULL || event_id == NULL) return false;
|
||||
for (uint8_t i = 0; i < s_history_count; i++) {
|
||||
if (strcmp(s_history[i].scene_id, scene_id) == 0
|
||||
&& strcmp(s_history[i].event_id, event_id) == 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t qr_npc_scan_count(void) {
|
||||
return s_history_count;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// tts_client.cpp - Piper TTS HTTP client for ESP32.
|
||||
#include "npc/tts_client.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <HTTPClient.h>
|
||||
#include <WiFi.h>
|
||||
#include <cstring>
|
||||
#include <cstdio>
|
||||
|
||||
static tts_stats_t s_stats = {};
|
||||
static char s_url[128] = {0};
|
||||
|
||||
void tts_init(void) {
|
||||
memset(&s_stats, 0, sizeof(s_stats));
|
||||
snprintf(s_url, sizeof(s_url), "http://%s:%d%s",
|
||||
TTS_TOWER_IP, TTS_TOWER_PORT, TTS_API_PATH);
|
||||
Serial.printf("[TTS] init url=%s\n", s_url);
|
||||
}
|
||||
|
||||
bool tts_check_health(uint32_t now_ms) {
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
s_stats.tower_reachable = false;
|
||||
s_stats.last_health_check_ms = now_ms;
|
||||
return false;
|
||||
}
|
||||
|
||||
HTTPClient http;
|
||||
http.setTimeout(TTS_TIMEOUT_MS);
|
||||
|
||||
char health_url[128];
|
||||
snprintf(health_url, sizeof(health_url), "http://%s:%d/",
|
||||
TTS_TOWER_IP, TTS_TOWER_PORT);
|
||||
|
||||
bool ok = false;
|
||||
if (http.begin(health_url)) {
|
||||
uint32_t t0 = millis();
|
||||
int code = http.sendRequest("HEAD");
|
||||
s_stats.last_latency_ms = millis() - t0;
|
||||
ok = (code >= 200 && code < 400);
|
||||
http.end();
|
||||
}
|
||||
|
||||
s_stats.tower_reachable = ok;
|
||||
s_stats.last_health_check_ms = now_ms;
|
||||
Serial.printf("[TTS] health: %s (%lu ms)\n",
|
||||
ok ? "OK" : "DOWN", (unsigned long)s_stats.last_latency_ms);
|
||||
return ok;
|
||||
}
|
||||
|
||||
void tts_health_tick(uint32_t now_ms) {
|
||||
if (now_ms - s_stats.last_health_check_ms >= TTS_HEALTH_INTERVAL_MS) {
|
||||
tts_check_health(now_ms);
|
||||
}
|
||||
}
|
||||
|
||||
bool tts_is_tower_reachable(void) {
|
||||
return s_stats.tower_reachable;
|
||||
}
|
||||
|
||||
tts_result_t tts_synthesize(const char* text, uint8_t* out_buf,
|
||||
size_t buf_capacity, size_t* out_len) {
|
||||
if (text == NULL || out_buf == NULL || out_len == NULL) {
|
||||
return TTS_RESULT_ALLOC_FAIL;
|
||||
}
|
||||
*out_len = 0;
|
||||
|
||||
size_t text_len = strlen(text);
|
||||
if (text_len > TTS_MAX_TEXT_LEN) {
|
||||
return TTS_RESULT_TEXT_TOO_LONG;
|
||||
}
|
||||
if (!s_stats.tower_reachable) {
|
||||
return TTS_RESULT_TOWER_DOWN;
|
||||
}
|
||||
|
||||
s_stats.total_requests++;
|
||||
|
||||
HTTPClient http;
|
||||
http.setTimeout(TTS_TIMEOUT_MS);
|
||||
|
||||
if (!http.begin(s_url)) {
|
||||
s_stats.total_failures++;
|
||||
return TTS_RESULT_HTTP_ERROR;
|
||||
}
|
||||
|
||||
http.addHeader("Content-Type", "application/json");
|
||||
|
||||
// Build JSON body: {"text":"...","voice":"tom-medium","format":"wav"}
|
||||
char body[TTS_MAX_TEXT_LEN + 64];
|
||||
snprintf(body, sizeof(body),
|
||||
"{\"text\":\"%s\",\"voice\":\"%s\",\"format\":\"wav\"}",
|
||||
text, TTS_VOICE);
|
||||
|
||||
uint32_t t0 = millis();
|
||||
int code = http.POST(body);
|
||||
s_stats.last_latency_ms = millis() - t0;
|
||||
|
||||
if (code != 200) {
|
||||
Serial.printf("[TTS] POST failed: %d (%lu ms)\n", code,
|
||||
(unsigned long)s_stats.last_latency_ms);
|
||||
http.end();
|
||||
s_stats.total_failures++;
|
||||
if (s_stats.last_latency_ms >= TTS_TIMEOUT_MS) {
|
||||
return TTS_RESULT_TIMEOUT;
|
||||
}
|
||||
return TTS_RESULT_HTTP_ERROR;
|
||||
}
|
||||
|
||||
int content_len = http.getSize();
|
||||
if (content_len <= 0 || (size_t)content_len > buf_capacity) {
|
||||
Serial.printf("[TTS] bad content_len: %d (cap %u)\n",
|
||||
content_len, (unsigned)buf_capacity);
|
||||
http.end();
|
||||
s_stats.total_failures++;
|
||||
return TTS_RESULT_ALLOC_FAIL;
|
||||
}
|
||||
|
||||
WiFiClient* stream = http.getStreamPtr();
|
||||
size_t total_read = 0;
|
||||
while (total_read < (size_t)content_len) {
|
||||
int avail = stream->available();
|
||||
if (avail <= 0) {
|
||||
if (!stream->connected()) break;
|
||||
delay(1);
|
||||
continue;
|
||||
}
|
||||
int to_read = (avail > 1024) ? 1024 : avail;
|
||||
if (total_read + (size_t)to_read > buf_capacity) {
|
||||
to_read = (int)(buf_capacity - total_read);
|
||||
}
|
||||
int r = stream->read(out_buf + total_read, to_read);
|
||||
if (r > 0) total_read += (size_t)r;
|
||||
}
|
||||
|
||||
http.end();
|
||||
*out_len = total_read;
|
||||
Serial.printf("[TTS] OK: %u bytes, %lu ms\n",
|
||||
(unsigned)total_read, (unsigned long)s_stats.last_latency_ms);
|
||||
return TTS_RESULT_OK;
|
||||
}
|
||||
|
||||
tts_stats_t tts_get_stats(void) {
|
||||
return s_stats;
|
||||
}
|
||||
Reference in New Issue
Block a user