diff --git a/.gitignore b/.gitignore index 737863c..20f8d59 100644 --- a/.gitignore +++ b/.gitignore @@ -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*/ diff --git a/box3_voice/CMakeLists.txt b/box3_voice/CMakeLists.txt new file mode 100644 index 0000000..6d966db --- /dev/null +++ b/box3_voice/CMakeLists.txt @@ -0,0 +1,4 @@ +cmake_minimum_required(VERSION 3.16) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(zacus-box3-voice) diff --git a/box3_voice/README.md b/box3_voice/README.md new file mode 100644 index 0000000..09c652c --- /dev/null +++ b/box3_voice/README.md @@ -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 <--+ +``` diff --git a/box3_voice/idf_component.yml b/box3_voice/idf_component.yml new file mode 100644 index 0000000..024c900 --- /dev/null +++ b/box3_voice/idf_component.yml @@ -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" diff --git a/box3_voice/main/CMakeLists.txt b/box3_voice/main/CMakeLists.txt new file mode 100644 index 0000000..7180a0d --- /dev/null +++ b/box3_voice/main/CMakeLists.txt @@ -0,0 +1,4 @@ +idf_component_register( + SRCS "main.c" "voice_ws_client.c" + INCLUDE_DIRS "." +) diff --git a/box3_voice/main/Kconfig.projbuild b/box3_voice/main/Kconfig.projbuild new file mode 100644 index 0000000..f8d6376 --- /dev/null +++ b/box3_voice/main/Kconfig.projbuild @@ -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= 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 diff --git a/box3_voice/main/board_config.h b/box3_voice/main/board_config.h new file mode 100644 index 0000000..78a3a39 --- /dev/null +++ b/box3_voice/main/board_config.h @@ -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) diff --git a/box3_voice/main/main.c b/box3_voice/main/main.c new file mode 100644 index 0000000..c5ac122 --- /dev/null +++ b/box3_voice/main/main.c @@ -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 +#include +#include + +#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."); +} diff --git a/box3_voice/main/voice_ws_client.c b/box3_voice/main/voice_ws_client.c new file mode 100644 index 0000000..aad0109 --- /dev/null +++ b/box3_voice/main/voice_ws_client.c @@ -0,0 +1,480 @@ +/* + * voice_ws_client.c — WebSocket client for mascarade voice bridge + * + * Connects to the voice bridge at ws://: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 +#include + +#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; +} diff --git a/box3_voice/main/voice_ws_client.h b/box3_voice/main/voice_ws_client.h new file mode 100644 index 0000000..23b5d68 --- /dev/null +++ b/box3_voice/main/voice_ws_client.h @@ -0,0 +1,83 @@ +/* + * voice_ws_client.h — WebSocket client for mascarade voice bridge + * + * Protocol: ws://: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 +#include +#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 diff --git a/box3_voice/partitions.csv b/box3_voice/partitions.csv new file mode 100644 index 0000000..f005f82 --- /dev/null +++ b/box3_voice/partitions.csv @@ -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, diff --git a/box3_voice/sdkconfig.defaults b/box3_voice/sdkconfig.defaults new file mode 100644 index 0000000..d57a974 --- /dev/null +++ b/box3_voice/sdkconfig.defaults @@ -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 diff --git a/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/__init__.py b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/__init__.py new file mode 100644 index 0000000..4b014fd --- /dev/null +++ b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/__init__.py @@ -0,0 +1,5 @@ +"""Story generation library (Yamale + Jinja2) for Zacus firmware.""" + +from .generator import StoryGenerationError + +__all__ = ["StoryGenerationError"] diff --git a/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/cli.py b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/cli.py new file mode 100644 index 0000000..61c58fb --- /dev/null +++ b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/cli.py @@ -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()) diff --git a/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/generator.py b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/generator.py new file mode 100644 index 0000000..72e26d2 --- /dev/null +++ b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/generator.py @@ -0,0 +1,1954 @@ +from __future__ import annotations + +import copy +import hashlib +import json +import tarfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +try: + import yaml +except Exception: # pragma: no cover - dependency error surfaced at runtime + yaml = None + +try: + import yamale + from yamale.yamale_error import YamaleError +except Exception: # pragma: no cover - dependency error surfaced at runtime + yamale = None + YamaleError = Exception + +try: + from jinja2 import Environment, FileSystemLoader +except Exception: # pragma: no cover - dependency error surfaced at runtime + Environment = None + FileSystemLoader = None + + +ALLOWED_TRIGGER = {"on_event", "after_ms", "immediate"} +ALLOWED_EVENT = {"none", "unlock", "audio_done", "timer", "serial", "button", "espnow", "action"} +ALLOWED_APP = { + "LA_DETECTOR", + "AUDIO_PACK", + "SCREEN_SCENE", + "MP3_GATE", + "WIFI_STACK", + "ESPNOW_STACK", + "QR_UNLOCK_APP", +} + +EVENT_CPP = { + "none": "StoryEventType::kNone", + "unlock": "StoryEventType::kUnlock", + "audio_done": "StoryEventType::kAudioDone", + "timer": "StoryEventType::kTimer", + "serial": "StoryEventType::kSerial", + "button": "StoryEventType::kButton", + "espnow": "StoryEventType::kEspNow", + "action": "StoryEventType::kAction", +} +TRIGGER_CPP = { + "on_event": "StoryTransitionTrigger::kOnEvent", + "after_ms": "StoryTransitionTrigger::kAfterMs", + "immediate": "StoryTransitionTrigger::kImmediate", +} +APP_CPP = { + "LA_DETECTOR": "StoryAppType::kLaDetector", + "AUDIO_PACK": "StoryAppType::kAudioPack", + "SCREEN_SCENE": "StoryAppType::kScreenScene", + "MP3_GATE": "StoryAppType::kMp3Gate", + "WIFI_STACK": "StoryAppType::kWifiStack", + "ESPNOW_STACK": "StoryAppType::kEspNowStack", + "QR_UNLOCK_APP": "StoryAppType::kQrUnlockApp", +} + +ACTION_FILE_ALIASES = { + # Keep long action IDs in YAML/runtime while allowing short LittleFS filenames. + "ACTION_QR_CODE_SCANNER_START": "ACTION_QR_SCAN_START", + "ACTION_SET_BOOT_MEDIA_MANAGER": "ACTION_BOOT_MEDIA_MGR", +} + +DEFAULT_SCENE_PROFILE: dict[str, Any] = { + "title": "MISSION", + "subtitle": "", + "symbol": "RUN", + "effect": "pulse", + "effect_speed_ms": 680, + "theme": {"bg": "#07132A", "accent": "#2A76FF", "text": "#E8F1FF"}, + "transition": {"effect": "fade", "duration_ms": 240}, + "timeline": [ + { + "at_ms": 0, + "effect": "pulse", + "speed_ms": 680, + "theme": {"bg": "#07132A", "accent": "#2A76FF", "text": "#E8F1FF"}, + }, + { + "at_ms": 1400, + "effect": "blink", + "speed_ms": 360, + "theme": {"bg": "#0B1F3C", "accent": "#5A99FF", "text": "#F1F7FF"}, + }, + ], +} + +SCENE_PROFILES: dict[str, dict[str, Any]] = { + "SCENE_LOCKED": { + "title": "VERROUILLE", + "subtitle": "Attente de debloquage", + "symbol": "LOCK", + "effect": "pulse", + "effect_speed_ms": 680, + "theme": {"bg": "#08152D", "accent": "#3E8DFF", "text": "#F5F8FF"}, + "transition": {"effect": "fade", "duration_ms": 260}, + "timeline": [ + { + "at_ms": 0, + "effect": "pulse", + "speed_ms": 680, + "theme": {"bg": "#08152D", "accent": "#3E8DFF", "text": "#F5F8FF"}, + }, + { + "at_ms": 1400, + "effect": "blink", + "speed_ms": 420, + "theme": {"bg": "#0A1E3A", "accent": "#74B0FF", "text": "#F8FBFF"}, + }, + ], + }, + "SCENE_BROKEN": { + "title": "PROTO U-SON", + "subtitle": "Signal brouille", + "symbol": "ALERT", + "effect": "blink", + "effect_speed_ms": 180, + "theme": {"bg": "#2A0508", "accent": "#FF4A45", "text": "#FFF1F1"}, + "transition": {"effect": "glitch", "duration_ms": 160}, + "timeline": [ + { + "at_ms": 0, + "effect": "blink", + "speed_ms": 180, + "theme": {"bg": "#2A0508", "accent": "#FF4A45", "text": "#FFF1F1"}, + }, + { + "at_ms": 760, + "effect": "blink", + "speed_ms": 140, + "theme": {"bg": "#33070C", "accent": "#FF7A75", "text": "#FFF6F5"}, + }, + ], + }, + "SCENE_LA_DETECTOR": { + "title": "DETECTION", + "subtitle": "Balayage en cours", + "symbol": "SCAN", + "effect": "scan", + "effect_speed_ms": 960, + "theme": {"bg": "#041F1B", "accent": "#2CE5A6", "text": "#EFFFF8"}, + "transition": {"effect": "slide_up", "duration_ms": 220}, + "timeline": [ + { + "at_ms": 0, + "effect": "scan", + "speed_ms": 960, + "theme": {"bg": "#041F1B", "accent": "#2CE5A6", "text": "#EFFFF8"}, + }, + { + "at_ms": 1500, + "effect": "pulse", + "speed_ms": 620, + "theme": {"bg": "#062923", "accent": "#63F0C3", "text": "#F3FFFB"}, + }, + ], + }, + "SCENE_SEARCH": { + "title": "RECHERCHE", + "subtitle": "Analyse des indices", + "symbol": "SCAN", + "effect": "scan", + "effect_speed_ms": 920, + "theme": {"bg": "#05261F", "accent": "#35E7B0", "text": "#EFFFF8"}, + "transition": {"effect": "glitch", "duration_ms": 230}, + "timeline": [ + { + "at_ms": 0, + "effect": "scan", + "speed_ms": 920, + "theme": {"bg": "#05261F", "accent": "#35E7B0", "text": "#EFFFF8"}, + }, + { + "at_ms": 1600, + "effect": "wave", + "speed_ms": 520, + "theme": {"bg": "#07322A", "accent": "#67F0C4", "text": "#F2FFF9"}, + }, + { + "at_ms": 3000, + "effect": "scan", + "speed_ms": 820, + "theme": {"bg": "#05261F", "accent": "#35E7B0", "text": "#EFFFF8"}, + }, + ], + }, + "SCENE_CAMERA_SCAN": { + "title": "ZACUS QR VALIDATION", + "subtitle": "Scanne le QR final", + "symbol": "QR", + "effect": "none", + "effect_speed_ms": 0, + "theme": {"bg": "#102040", "accent": "#5CA3FF", "text": "#F3F7FF"}, + "transition": {"effect": "fade", "duration_ms": 180}, + "timeline": [ + { + "at_ms": 0, + "effect": "none", + "speed_ms": 0, + "theme": {"bg": "#102040", "accent": "#5CA3FF", "text": "#F3F7FF"}, + }, + ], + }, + "SCENE_MEDIA_MANAGER": { + "title": "MEDIA MANAGER", + "subtitle": "PHOTO / MP3 / STORY", + "symbol": "READY", + "effect": "radar", + "effect_speed_ms": 620, + "theme": {"bg": "#081A34", "accent": "#8BC4FF", "text": "#EAF6FF"}, + "transition": {"effect": "fade", "duration_ms": 220}, + "timeline": [ + { + "at_ms": 0, + "effect": "radar", + "speed_ms": 620, + "theme": {"bg": "#081A34", "accent": "#8BC4FF", "text": "#EAF6FF"}, + }, + { + "at_ms": 1200, + "effect": "pulse", + "speed_ms": 520, + "theme": {"bg": "#0C2448", "accent": "#A6D4FF", "text": "#F5FAFF"}, + }, + ], + }, + "SCENE_PHOTO_MANAGER": { + "title": "PHOTO MANAGER", + "subtitle": "Capture JPEG", + "symbol": "SCAN", + "effect": "none", + "effect_speed_ms": 0, + "theme": {"bg": "#0B1A2E", "accent": "#86CCFF", "text": "#EEF6FF"}, + "transition": {"effect": "fade", "duration_ms": 200}, + "timeline": [ + { + "at_ms": 0, + "effect": "none", + "speed_ms": 0, + "theme": {"bg": "#0B1A2E", "accent": "#86CCFF", "text": "#EEF6FF"}, + }, + ], + }, + "SCENE_SIGNAL_SPIKE": { + "title": "PIC DE SIGNAL", + "subtitle": "Interference soudaine detectee", + "symbol": "ALERT", + "effect": "wave", + "effect_speed_ms": 260, + "theme": {"bg": "#24090C", "accent": "#FF6A52", "text": "#FFF2EB"}, + "transition": {"effect": "glitch", "duration_ms": 170}, + "timeline": [ + { + "at_ms": 0, + "effect": "wave", + "speed_ms": 260, + "theme": {"bg": "#24090C", "accent": "#FF6A52", "text": "#FFF2EB"}, + }, + { + "at_ms": 700, + "effect": "blink", + "speed_ms": 180, + "theme": {"bg": "#2F1014", "accent": "#FF8C73", "text": "#FFF8F5"}, + }, + { + "at_ms": 1400, + "effect": "wave", + "speed_ms": 320, + "theme": {"bg": "#24090C", "accent": "#FF6A52", "text": "#FFF2EB"}, + }, + ], + }, + "SCENE_MEDIA_ARCHIVE": { + "title": "ARCHIVES MEDIA", + "subtitle": "Photos et enregistrements sauvegardes", + "symbol": "READY", + "effect": "radar", + "effect_speed_ms": 760, + "theme": {"bg": "#0D1A34", "accent": "#7CB1FF", "text": "#EEF4FF"}, + "transition": {"effect": "fade", "duration_ms": 240}, + "timeline": [ + { + "at_ms": 0, + "effect": "radar", + "speed_ms": 760, + "theme": {"bg": "#0D1A34", "accent": "#7CB1FF", "text": "#EEF4FF"}, + }, + { + "at_ms": 1000, + "effect": "pulse", + "speed_ms": 620, + "theme": {"bg": "#132245", "accent": "#9CC7FF", "text": "#F7FAFF"}, + }, + { + "at_ms": 2000, + "effect": "radar", + "speed_ms": 760, + "theme": {"bg": "#0D1A34", "accent": "#7CB1FF", "text": "#EEF4FF"}, + }, + ], + }, + "SCENE_WIN": { + "title": "VICTOIRE", + "subtitle": "Etape validee", + "symbol": "WIN", + "effect": "celebrate", + "effect_speed_ms": 420, + "theme": {"bg": "#231038", "accent": "#F4CB4A", "text": "#FFF8E2"}, + "transition": {"effect": "zoom", "duration_ms": 280}, + "timeline": [ + { + "at_ms": 0, + "effect": "celebrate", + "speed_ms": 420, + "theme": {"bg": "#231038", "accent": "#F4CB4A", "text": "#FFF8E2"}, + }, + { + "at_ms": 1000, + "effect": "blink", + "speed_ms": 240, + "theme": {"bg": "#341A4D", "accent": "#FFE083", "text": "#FFFDF3"}, + }, + ], + }, + "SCENE_WIN_ETAPE": { + "title": "VICTOIRE", + "subtitle": "Etape validee", + "symbol": "WIN", + "effect": "celebrate", + "effect_speed_ms": 420, + "theme": {"bg": "#231038", "accent": "#F4CB4A", "text": "#FFF8E2"}, + "transition": {"effect": "zoom", "duration_ms": 280}, + "timeline": [ + { + "at_ms": 0, + "effect": "celebrate", + "speed_ms": 420, + "theme": {"bg": "#231038", "accent": "#F4CB4A", "text": "#FFF8E2"}, + }, + { + "at_ms": 1000, + "effect": "blink", + "speed_ms": 240, + "theme": {"bg": "#341A4D", "accent": "#FFE083", "text": "#FFFDF3"}, + }, + ], + }, + "SCENE_REWARD": { + "title": "RECOMPENSE", + "subtitle": "Indice debloque", + "symbol": "WIN", + "effect": "celebrate", + "effect_speed_ms": 420, + "theme": {"bg": "#2A103E", "accent": "#F9D860", "text": "#FFF9E6"}, + "transition": {"effect": "zoom", "duration_ms": 300}, + "timeline": [ + { + "at_ms": 0, + "effect": "celebrate", + "speed_ms": 420, + "theme": {"bg": "#2A103E", "accent": "#F9D860", "text": "#FFF9E6"}, + }, + { + "at_ms": 1200, + "effect": "pulse", + "speed_ms": 280, + "theme": {"bg": "#3E1A52", "accent": "#FFD97D", "text": "#FFFDF2"}, + }, + ], + }, + "SCENE_READY": { + "title": "PRET", + "subtitle": "Scenario termine", + "symbol": "READY", + "effect": "wave", + "effect_speed_ms": 560, + "theme": {"bg": "#0F2A12", "accent": "#6CD96B", "text": "#EDFFED"}, + "transition": {"effect": "fade", "duration_ms": 220}, + "timeline": [ + { + "at_ms": 0, + "effect": "wave", + "speed_ms": 560, + "theme": {"bg": "#0F2A12", "accent": "#6CD96B", "text": "#EDFFED"}, + }, + { + "at_ms": 1500, + "effect": "radar", + "speed_ms": 740, + "theme": {"bg": "#133517", "accent": "#9EE49D", "text": "#F4FFF4"}, + }, + ], + }, + "SCENE_U_SON_PROTO": { + "title": "PROTO U-SON", + "subtitle": "Signal brouille", + "symbol": "ALERT", + "effect": "blink", + "effect_speed_ms": 180, + "theme": {"bg": "#2A0508", "accent": "#FF4A45", "text": "#FFF1F1"}, + "transition": {"effect": "glitch", "duration_ms": 160}, + "timeline": [ + { + "at_ms": 0, + "effect": "blink", + "speed_ms": 180, + "theme": {"bg": "#2A0508", "accent": "#FF4A45", "text": "#FFF1F1"}, + }, + { + "at_ms": 900, + "effect": "scan", + "speed_ms": 520, + "theme": {"bg": "#3A0A10", "accent": "#FF7873", "text": "#FFF7F7"}, + }, + ], + }, + "SCENE_WARNING": { + "title": "ALERTE", + "subtitle": "Signal anormal", + "symbol": "WARN", + "effect": "blink", + "effect_speed_ms": 240, + "theme": {"bg": "#261209", "accent": "#FF9A4A", "text": "#FFF2E6"}, + "transition": {"effect": "fade", "duration_ms": 200}, + "timeline": [ + { + "at_ms": 0, + "effect": "blink", + "speed_ms": 240, + "theme": {"bg": "#261209", "accent": "#FF9A4A", "text": "#FFF2E6"}, + }, + { + "at_ms": 1400, + "effect": "pulse", + "speed_ms": 520, + "theme": {"bg": "#31170C", "accent": "#FFC071", "text": "#FFF8EF"}, + }, + ], + }, + "SCENE_LEFOU_DETECTOR": { + "title": "DETECTEUR LEFOU", + "subtitle": "Analyse en cours", + "symbol": "AUDIO", + "effect": "wave", + "effect_speed_ms": 460, + "theme": {"bg": "#071B1A", "accent": "#46E6C8", "text": "#E9FFF9"}, + "transition": {"effect": "zoom", "duration_ms": 250}, + "timeline": [ + { + "at_ms": 0, + "effect": "wave", + "speed_ms": 460, + "theme": {"bg": "#071B1A", "accent": "#46E6C8", "text": "#E9FFF9"}, + }, + { + "at_ms": 1200, + "effect": "radar", + "speed_ms": 620, + "theme": {"bg": "#0A2523", "accent": "#7FF2DA", "text": "#F2FFFC"}, + }, + ], + }, + "SCENE_WIN_ETAPE1": { + "title": "WIN ETAPE 1", + "subtitle": "Validation distante", + "symbol": "WIN", + "effect": "celebrate", + "effect_speed_ms": 360, + "theme": {"bg": "#1E0F32", "accent": "#F5C64A", "text": "#FFF8E4"}, + "transition": {"effect": "zoom", "duration_ms": 280}, + "timeline": [ + { + "at_ms": 0, + "effect": "celebrate", + "speed_ms": 360, + "theme": {"bg": "#1E0F32", "accent": "#F5C64A", "text": "#FFF8E4"}, + }, + { + "at_ms": 1200, + "effect": "pulse", + "speed_ms": 260, + "theme": {"bg": "#2A1645", "accent": "#FFD97A", "text": "#FFFDF3"}, + }, + ], + }, + "SCENE_WIN_ETAPE2": { + "title": "WIN ETAPE 2", + "subtitle": "ACK en attente", + "symbol": "WIN", + "effect": "celebrate", + "effect_speed_ms": 340, + "theme": {"bg": "#220F3A", "accent": "#FFCE62", "text": "#FFF8EA"}, + "transition": {"effect": "zoom", "duration_ms": 280}, + "timeline": [ + { + "at_ms": 0, + "effect": "celebrate", + "speed_ms": 340, + "theme": {"bg": "#220F3A", "accent": "#FFCE62", "text": "#FFF8EA"}, + }, + { + "at_ms": 1200, + "effect": "pulse", + "speed_ms": 260, + "theme": {"bg": "#2E1850", "accent": "#FFE18E", "text": "#FFFDF5"}, + }, + ], + }, + "SCENE_QR_DETECTOR": { + "title": "ZACUS QR VALIDATION", + "subtitle": "Scan du QR final", + "symbol": "QR", + "effect": "none", + "effect_speed_ms": 0, + "theme": {"bg": "#102040", "accent": "#5CA3FF", "text": "#F3F7FF"}, + "transition": {"effect": "fade", "duration_ms": 180}, + "timeline": [ + { + "at_ms": 0, + "effect": "none", + "speed_ms": 0, + "theme": {"bg": "#102040", "accent": "#5CA3FF", "text": "#F3F7FF"}, + }, + { + "at_ms": 1600, + "effect": "pulse", + "speed_ms": 520, + "theme": {"bg": "#142A52", "accent": "#8EC1FF", "text": "#FCFEFF"}, + }, + ], + }, + "SCENE_FINAL_WIN": { + "title": "FINAL WIN", + "subtitle": "Mission accomplie", + "symbol": "WIN", + "effect": "celebrate", + "effect_speed_ms": 320, + "theme": {"bg": "#1C0C2E", "accent": "#FFCC5C", "text": "#FFF7E4"}, + "transition": {"effect": "fade", "duration_ms": 240}, + "timeline": [ + { + "at_ms": 0, + "effect": "celebrate", + "speed_ms": 320, + "theme": {"bg": "#1C0C2E", "accent": "#FFCC5C", "text": "#FFF7E4"}, + }, + { + "at_ms": 1400, + "effect": "blink", + "speed_ms": 220, + "theme": {"bg": "#2A1642", "accent": "#FFE18D", "text": "#FFFDF3"}, + }, + ], + }, +} + +SCENE_ALIASES: dict[str, str] = { + "SCENE_LA_DETECT": "SCENE_LA_DETECTOR", + "SCENE_U_SON": "SCENE_U_SON_PROTO", + "SCENE_LE_FOU_DETECTOR": "SCENE_LEFOU_DETECTOR", +} + +CANONICAL_SCREEN_SCENE_IDS: tuple[str, ...] = ( + "SCENE_LOCKED", + "SCENE_BROKEN", + "SCENE_U_SON_PROTO", + "SCENE_SEARCH", + "SCENE_LA_DETECTOR", + "SCENE_LEFOU_DETECTOR", + "SCENE_WARNING", + "SCENE_CAMERA_SCAN", + "SCENE_QR_DETECTOR", + "SCENE_SIGNAL_SPIKE", + "SCENE_REWARD", + "SCENE_WIN_ETAPE1", + "SCENE_WIN_ETAPE2", + "SCENE_FINAL_WIN", + "SCENE_MEDIA_ARCHIVE", + "SCENE_READY", + "SCENE_WIN", + "SCENE_WINNER", + "SCENE_FIREWORKS", + "SCENE_WIN_ETAPE", + "SCENE_MP3_PLAYER", + "SCENE_MEDIA_MANAGER", + "SCENE_PHOTO_MANAGER", +) + +LEGACY_SCREEN_ALIASES: dict[str, str] = { + "SCENE_LA_DETECT": "SCENE_LA_DETECTOR", + "SCENE_U_SON": "SCENE_U_SON_PROTO", + "SCENE_LE_FOU_DETECTOR": "SCENE_LEFOU_DETECTOR", + "SCENE_LOCK": "SCENE_LOCKED", + "LOCKED": "SCENE_LOCKED", + "LOCK": "SCENE_LOCKED", + "SCENE_AUDIO_PLAYER": "SCENE_MP3_PLAYER", + "SCENE_MP3": "SCENE_MP3_PLAYER", +} + +_ACTIVE_SCENE_PROFILES: dict[str, dict[str, Any]] | None = None + + +def _canonical_scene_id(scene_id: str) -> str: + return SCENE_ALIASES.get(scene_id, scene_id) + + +def _scene_slug(scene_id: str) -> str: + slug = scene_id + if slug.startswith("SCENE_"): + slug = slug[6:] + return slug.lower() + +DEFAULT_TEXT_OPTIONS: dict[str, Any] = { + "show_title": False, + "show_subtitle": True, + "show_symbol": True, + "title_case": "upper", + "subtitle_case": "raw", + "title_align": "top", + "subtitle_align": "bottom", +} + +DEFAULT_FRAMING_OPTIONS: dict[str, Any] = { + "preset": "center", + "x_offset": 0, + "y_offset": 0, + "scale_pct": 100, +} + +DEFAULT_SCROLL_OPTIONS: dict[str, Any] = { + "mode": "none", + "speed_ms": 4200, + "pause_ms": 900, + "loop": True, +} + +DEFAULT_DEMO_OPTIONS: dict[str, Any] = { + "mode": "standard", + "particle_count": 4, + "strobe_level": 65, +} + +SCREEN_EFFECT_CHOICES = {"none", "pulse", "scan", "radar", "wave", "blink", "celebrate"} +SCREEN_EFFECT_ALIASES = { + "steady": "none", + "glitch": "blink", + "reward": "celebrate", + "sonar": "radar", +} +TRANSITION_EFFECT_CHOICES = {"none", "fade", "slide_left", "slide_right", "slide_up", "slide_down", "zoom", "glitch"} +TRANSITION_EFFECT_ALIASES = { + "crossfade": "fade", + "left": "slide_left", + "right": "slide_right", + "up": "slide_up", + "down": "slide_down", + "zoom_in": "zoom", + "flash": "glitch", + "wipe": "slide_left", + "camera_flash": "glitch", +} +TEXT_CASE_CHOICES = {"raw", "upper", "lower"} +TEXT_ALIGN_CHOICES = {"top", "center", "bottom"} +FRAMING_PRESET_CHOICES = {"center", "focus_top", "focus_bottom", "split"} +SCROLL_MODE_CHOICES = {"none", "marquee"} +SCROLL_MODE_ALIASES = {"ticker": "marquee", "crawl": "marquee"} +DEMO_MODE_CHOICES = {"standard", "cinematic", "arcade"} + + +class StoryGenerationError(RuntimeError): + """Raised when validation or generation fails.""" + + +@dataclass +class StoryPaths: + fw_root: Path + repo_root: Path + game_scenarios_dir: Path + story_specs_dir: Path + story_data_dir: Path + generated_cpp_dir: Path + bundle_root: Path + + +@dataclass +class ValidationIssue: + file: str + field: str + reason: str + + def format(self) -> str: + return f"{self.file}: {self.field}: {self.reason}" + + +def _require_deps() -> None: + missing = [] + if yaml is None: + missing.append("PyYAML") + if yamale is None: + missing.append("yamale") + if Environment is None or FileSystemLoader is None: + missing.append("Jinja2") + if missing: + joined = ", ".join(missing) + raise StoryGenerationError( + f"Missing dependencies: {joined}. Install with: pip install pyyaml yamale Jinja2" + ) + + +def find_fw_root() -> Path: + start = Path(__file__).resolve() + for candidate in [start, *start.parents]: + if (candidate / "platformio.ini").exists(): + return candidate + raise StoryGenerationError("Cannot resolve firmware root (platformio.ini not found)") + + +def default_paths() -> StoryPaths: + fw_root = find_fw_root() + repo_root = fw_root.parents[1] + return StoryPaths( + fw_root=fw_root, + repo_root=repo_root, + game_scenarios_dir=repo_root / "game" / "scenarios", + story_specs_dir=fw_root / "docs" / "protocols" / "story_specs" / "scenarios", + story_data_dir=fw_root / "data" / "story", + generated_cpp_dir=fw_root / "hardware" / "libs" / "story" / "src" / "generated", + bundle_root=fw_root / "artifacts" / "story_fs" / "deploy", + ) + + +def _schema_path(name: str) -> Path: + return Path(__file__).resolve().parent / "schemas" / name + + +def _template_dir() -> Path: + return Path(__file__).resolve().parent / "templates" + + +def _list_yaml_files(path: Path) -> list[Path]: + if path.is_file() and path.suffix.lower() in {".yml", ".yaml"}: + return [path] + if not path.exists(): + return [] + return sorted([p for p in path.glob("*.y*ml") if p.is_file()]) + + +def _validate_yamale(schema_path: Path, files: list[Path]) -> None: + if not files: + raise StoryGenerationError(f"No YAML files found for schema: {schema_path.name}") + schema = yamale.make_schema(str(schema_path)) + errors: list[str] = [] + for file_path in files: + data = yamale.make_data(str(file_path)) + try: + yamale.validate(schema, data, strict=True) + except YamaleError as exc: + for result in exc.results: + for msg in result.errors: + errors.append(f"{file_path}: {msg}") + if errors: + raise StoryGenerationError("Yamale validation failed:\n" + "\n".join(errors)) + + +def _load_yaml(path: Path) -> dict[str, Any]: + obj = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(obj, dict): + raise StoryGenerationError(f"YAML root must be a mapping: {path}") + return obj + + +def _active_scene_profiles() -> dict[str, dict[str, Any]]: + return _ACTIVE_SCENE_PROFILES if _ACTIVE_SCENE_PROFILES is not None else SCENE_PROFILES + + +def _build_default_scene_profiles() -> dict[str, dict[str, Any]]: + profiles: dict[str, dict[str, Any]] = {} + for scene_id in CANONICAL_SCREEN_SCENE_IDS: + base = SCENE_PROFILES.get(scene_id) + if base is None: + fallback = copy.deepcopy(DEFAULT_SCENE_PROFILE) + fallback["title"] = scene_id.replace("SCENE_", "").replace("_", " ") + fallback["subtitle"] = "" + fallback["symbol"] = "RUN" + fallback["effect"] = "pulse" + base = fallback + profiles[scene_id] = copy.deepcopy(base) + return profiles + + +def _deep_merge_dict(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + for key, value in override.items(): + if isinstance(value, dict) and isinstance(base.get(key), dict): + base[key] = _deep_merge_dict(dict(base[key]), value) + else: + base[key] = copy.deepcopy(value) + return base + + +def _palette_path(paths: StoryPaths) -> Path: + return paths.story_data_dir / "palette" / "screens_palette_v3.yaml" + + +def _load_scene_profiles(paths: StoryPaths) -> dict[str, dict[str, Any]]: + profiles = _build_default_scene_profiles() + palette_path = _palette_path(paths) + if not palette_path.exists(): + return profiles + + payload = _load_yaml(palette_path) + scenes_payload = payload.get("scenes") + if not isinstance(scenes_payload, dict): + raise StoryGenerationError(f"Invalid palette file {palette_path}: missing mapping 'scenes'") + + missing: list[str] = [] + for scene_id in CANONICAL_SCREEN_SCENE_IDS: + override = scenes_payload.get(scene_id) + if not isinstance(override, dict): + missing.append(scene_id) + continue + profiles[scene_id] = _deep_merge_dict(copy.deepcopy(profiles[scene_id]), override) + + if missing: + raise StoryGenerationError( + f"Palette {palette_path} missing canonical scene profiles: {', '.join(sorted(missing))}" + ) + + for scene_id, override in scenes_payload.items(): + if not isinstance(scene_id, str) or not isinstance(override, dict): + continue + if scene_id in profiles: + continue + fallback = _build_default_scene_profiles().get(scene_id, copy.deepcopy(DEFAULT_SCENE_PROFILE)) + profiles[scene_id] = _deep_merge_dict(copy.deepcopy(fallback), override) + + return profiles + + +def _activate_scene_profiles(paths: StoryPaths) -> dict[str, dict[str, Any]]: + global _ACTIVE_SCENE_PROFILES + _ACTIVE_SCENE_PROFILES = _load_scene_profiles(paths) + return _ACTIVE_SCENE_PROFILES + + +def _normalize_scene_id(value: Any, issues: list[ValidationIssue], source: str) -> str: + scene_id = str(value).strip() if isinstance(value, str) else "" + if not scene_id: + return "" + scene_id = _canonical_scene_id(scene_id) + if scene_id not in _active_scene_profiles(): + issues.append(ValidationIssue(source, "screen_scene_id", f"unknown scene id '{scene_id}'")) + return "" + return scene_id + + +def _normalize_story_specs(files: list[Path]) -> list[dict[str, Any]]: + issues: list[ValidationIssue] = [] + scenarios: list[dict[str, Any]] = [] + ids: set[str] = set() + + for file_path in files: + raw = _load_yaml(file_path) + sid = str(raw.get("id", "")).strip() + if not sid: + issues.append(ValidationIssue(str(file_path), "id", "missing id")) + continue + if sid in ids: + issues.append(ValidationIssue(str(file_path), "id", f"duplicate id '{sid}'")) + continue + ids.add(sid) + + version = raw.get("version", 0) + if not isinstance(version, int): + issues.append(ValidationIssue(str(file_path), "version", "must be int")) + continue + + initial_step = str(raw.get("initial_step", "")).strip() + bindings_raw = raw.get("app_bindings", []) + steps_raw = raw.get("steps", []) + if not isinstance(bindings_raw, list) or not isinstance(steps_raw, list): + issues.append(ValidationIssue(str(file_path), "root", "app_bindings and steps must be lists")) + continue + + bindings: list[dict[str, Any]] = [] + binding_ids: set[str] = set() + for idx, binding in enumerate(bindings_raw): + if not isinstance(binding, dict): + issues.append(ValidationIssue(str(file_path), f"app_bindings[{idx}]", "must be mapping")) + continue + bid = str(binding.get("id", "")).strip() + app = str(binding.get("app", "")).strip() + if not bid: + issues.append(ValidationIssue(str(file_path), f"app_bindings[{idx}].id", "missing id")) + continue + if app not in ALLOWED_APP: + issues.append( + ValidationIssue(str(file_path), f"app_bindings[{idx}].app", f"invalid app '{app}'") + ) + continue + if bid in binding_ids: + issues.append(ValidationIssue(str(file_path), f"app_bindings[{idx}].id", f"duplicate '{bid}'")) + continue + binding_ids.add(bid) + + config = binding.get("config") if isinstance(binding.get("config"), dict) else None + if app == "LA_DETECTOR": + hold_ms = 3000 + unlock_event = "UNLOCK" + require_listening = True + if config is not None: + hold_raw = config.get("hold_ms", hold_ms) + if isinstance(hold_raw, int): + hold_ms = hold_raw + unlock_raw = config.get("unlock_event", unlock_event) + if isinstance(unlock_raw, str) and unlock_raw.strip(): + unlock_event = unlock_raw.strip() + listen_raw = config.get("require_listening", require_listening) + if isinstance(listen_raw, bool): + require_listening = listen_raw + config = { + "hold_ms": hold_ms, + "unlock_event": unlock_event, + "require_listening": require_listening, + } + else: + config = None + + bindings.append({"id": bid, "app": app, "config": config}) + + steps: list[dict[str, Any]] = [] + step_ids: set[str] = set() + for sidx, step in enumerate(steps_raw): + if not isinstance(step, dict): + issues.append(ValidationIssue(str(file_path), f"steps[{sidx}]", "must be mapping")) + continue + step_id = str(step.get("step_id", "")).strip() + if not step_id: + issues.append(ValidationIssue(str(file_path), f"steps[{sidx}].step_id", "missing step_id")) + continue + if step_id in step_ids: + issues.append(ValidationIssue(str(file_path), f"steps[{sidx}].step_id", f"duplicate '{step_id}'")) + continue + step_ids.add(step_id) + + apps = [str(v).strip() for v in (step.get("apps") or []) if str(v).strip()] + for app_id in apps: + if app_id not in binding_ids: + issues.append( + ValidationIssue( + str(file_path), + f"steps[{sidx}].apps", + f"unknown app binding '{app_id}'", + ) + ) + + transitions_norm: list[dict[str, Any]] = [] + for tidx, transition in enumerate(step.get("transitions") or []): + if not isinstance(transition, dict): + issues.append( + ValidationIssue(str(file_path), f"steps[{sidx}].transitions[{tidx}]", "must be mapping") + ) + continue + trigger = str(transition.get("trigger", "on_event")).strip().lower() + event_type = str(transition.get("event_type", "none")).strip().lower() + if event_type == "esp_now": + event_type = "espnow" + event_name = str(transition.get("event_name", "")).strip() + target_step_id = str(transition.get("target_step_id", "")).strip() + after_ms = transition.get("after_ms", 0) + priority = transition.get("priority", 0) + debug_only = bool(transition.get("debug_only", False)) + tr_id = str(transition.get("id", "")).strip() + if not tr_id: + tr_id = f"TR_{step_id}_{tidx + 1}" + if trigger not in ALLOWED_TRIGGER: + issues.append( + ValidationIssue( + str(file_path), + f"steps[{sidx}].transitions[{tidx}].trigger", + f"invalid trigger '{trigger}'", + ) + ) + if event_type not in ALLOWED_EVENT: + issues.append( + ValidationIssue( + str(file_path), + f"steps[{sidx}].transitions[{tidx}].event_type", + f"invalid event_type '{event_type}'", + ) + ) + if not isinstance(after_ms, int): + issues.append( + ValidationIssue( + str(file_path), + f"steps[{sidx}].transitions[{tidx}].after_ms", + "must be int", + ) + ) + after_ms = 0 + if not isinstance(priority, int): + issues.append( + ValidationIssue( + str(file_path), + f"steps[{sidx}].transitions[{tidx}].priority", + "must be int", + ) + ) + priority = 0 + + transitions_norm.append( + { + "id": tr_id, + "trigger": trigger, + "event_type": event_type, + "event_name": event_name, + "target_step_id": target_step_id, + "after_ms": after_ms, + "priority": priority, + "debug_only": debug_only, + } + ) + + raw_scene_id = str(step.get("screen_scene_id", "")).strip() + normalized_scene_id = _normalize_scene_id(raw_scene_id, issues, str(file_path)) + actions = [str(v).strip() for v in (step.get("actions") or []) if str(v).strip()] + steps.append( + { + "step_id": step_id, + "screen_scene_id": normalized_scene_id, + "audio_pack_id": str(step.get("audio_pack_id", "")).strip(), + "actions": actions, + "apps": apps, + "mp3_gate_open": bool(step.get("mp3_gate_open", False)), + "transitions": transitions_norm, + } + ) + + if initial_step and initial_step not in step_ids: + issues.append(ValidationIssue(str(file_path), "initial_step", f"unknown step '{initial_step}'")) + + for step in steps: + for transition in step["transitions"]: + if transition["target_step_id"] and transition["target_step_id"] not in step_ids: + issues.append( + ValidationIssue( + str(file_path), + f"steps[{step['step_id']}].transitions[{transition['id']}].target_step_id", + f"unknown target '{transition['target_step_id']}'", + ) + ) + + scenarios.append( + { + "id": sid, + "version": version, + "estimated_duration_s": int(raw.get("estimated_duration_s", 0) or 0), + "debug_transition_bypass_enabled": bool(raw.get("debug_transition_bypass_enabled", False)), + "initial_step": initial_step, + "app_bindings": bindings, + "steps": steps, + "source": str(file_path), + } + ) + + if issues: + formatted = "\n".join(issue.format() for issue in issues) + raise StoryGenerationError("Story semantic validation failed:\n" + formatted) + + scenarios.sort(key=lambda item: item["id"]) + return scenarios + + +def _validate_game_scenarios(game_scenario_files: list[Path]) -> list[str]: + ids: list[str] = [] + for file_path in game_scenario_files: + doc = _load_yaml(file_path) + sid = str(doc.get("id", "")).strip() + if sid: + ids.append(sid) + return sorted(ids) + + +def _classify_game_yaml(file_path: Path) -> str: + doc = _load_yaml(file_path) + has_runtime = bool(doc.get("initial_step")) and isinstance(doc.get("steps"), list) + has_narrative = bool(doc.get("title")) and isinstance(doc.get("stations"), list) + has_template = isinstance(doc.get("prompt_input"), dict) + has_workbench = isinstance(doc.get("meta"), dict) and isinstance(doc.get("scenes"), list) + if has_workbench and not has_runtime and not has_narrative and not has_template: + return "workbench" + if has_workbench and (has_runtime or has_narrative or has_template): + raise StoryGenerationError( + f"Ambiguous game scenario type in {file_path}: workbench mixed with runtime/narrative/template keys detected" + ) + if has_template and not has_runtime and not has_narrative: + return "template" + if has_runtime and has_narrative: + raise StoryGenerationError(f"Ambiguous game scenario type in {file_path}: both runtime and narrative keys detected") + if has_runtime: + return "runtime" + if has_narrative: + return "narrative" + raise StoryGenerationError(f"Unknown game scenario format in {file_path}: cannot detect runtime/narrative/template") + + +def _canonical_yaml_payload(path: Path) -> str: + data = _load_yaml(path) + return json.dumps(data, sort_keys=True, separators=(",", ":")) + + +def _validate_runtime_mirror(spec_files: list[Path], runtime_game_files: list[Path]) -> None: + spec_by_id: dict[str, Path] = {} + for spec_file in spec_files: + scenario_id = str(_load_yaml(spec_file).get("id", "")).strip() + if scenario_id: + spec_by_id[scenario_id] = spec_file + + runtime_by_id: dict[str, Path] = {} + for runtime_file in runtime_game_files: + scenario_id = str(_load_yaml(runtime_file).get("id", "")).strip() + if scenario_id: + runtime_by_id[scenario_id] = runtime_file + + if "DEFAULT" not in spec_by_id or "DEFAULT" not in runtime_by_id: + raise StoryGenerationError("Strict mirror requires DEFAULT runtime YAML in both docs/protocols and game/scenarios") + + for scenario_id, runtime_file in runtime_by_id.items(): + spec_file = spec_by_id.get(scenario_id) + if spec_file is None: + continue + if _canonical_yaml_payload(runtime_file) != _canonical_yaml_payload(spec_file): + raise StoryGenerationError( + "Runtime YAML mirror mismatch for scenario " + f"{scenario_id}: {runtime_file} != {spec_file}" + ) + + +def _build_runtime_story_file_set(spec_files: list[Path], runtime_game_files: list[Path]) -> list[Path]: + selected: dict[str, Path] = {} + for spec_file in spec_files: + scenario_id = str(_load_yaml(spec_file).get("id", "")).strip() + if scenario_id: + selected[scenario_id] = spec_file + for runtime_file in runtime_game_files: + scenario_id = str(_load_yaml(runtime_file).get("id", "")).strip() + if scenario_id: + selected[scenario_id] = runtime_file + return [selected[key] for key in sorted(selected.keys())] + + +def _sha_hex(payload: bytes) -> str: + return hashlib.sha256(payload).hexdigest() + + +def _story_spec_hash(scenarios: list[dict[str, Any]]) -> str: + canonical = json.dumps(scenarios, sort_keys=True, separators=(",", ":")) + return _sha_hex(canonical.encode("utf-8"))[:12] + + +def _create_cpp_context(scenarios: list[dict[str, Any]], spec_hash: str) -> dict[str, Any]: + normalized: list[dict[str, Any]] = [] + for sidx, scenario in enumerate(scenarios): + step_entries: list[dict[str, Any]] = [] + for tidx, step in enumerate(scenario["steps"]): + prefix = f"kSc{sidx}St{tidx}" + transitions: list[dict[str, Any]] = [] + for transition in step["transitions"]: + transitions.append( + { + "id": transition["id"], + "trigger_cpp": TRIGGER_CPP.get(transition["trigger"], "StoryTransitionTrigger::kOnEvent"), + "event_cpp": EVENT_CPP.get(transition["event_type"], "StoryEventType::kNone"), + "event_name": transition["event_name"], + "after_ms": max(0, int(transition["after_ms"])), + "target_step_id": transition["target_step_id"], + "priority": max(0, int(transition["priority"])), + "debug_only": "true" if transition.get("debug_only", False) else "false", + } + ) + step_entries.append( + { + "prefix": prefix, + "id": step["step_id"], + "screen_scene_id": step["screen_scene_id"] or "nullptr", + "audio_pack_id": step["audio_pack_id"] or "nullptr", + "actions": step["actions"], + "apps": step["apps"], + "transitions": transitions, + "mp3_gate_open": "true" if step["mp3_gate_open"] else "false", + } + ) + normalized.append( + { + "index": sidx, + "id": scenario["id"], + "version": scenario["version"], + "initial_step": scenario["initial_step"], + "steps": step_entries, + } + ) + + app_bindings: dict[str, dict[str, Any]] = {} + for scenario in scenarios: + for binding in scenario["app_bindings"]: + app_bindings[binding["id"]] = binding + app_entries = [ + { + "id": app_id, + "app_cpp": APP_CPP[binding["app"]], + "la_config": binding.get("config") if binding["app"] == "LA_DETECTOR" else None, + } + for app_id, binding in sorted(app_bindings.items(), key=lambda item: item[0]) + ] + + return { + "spec_hash": spec_hash, + "scenario_count": len(scenarios), + "scenarios": normalized, + "app_entries": app_entries, + } + + +def _render_template(template_name: str, context: dict[str, Any]) -> str: + env = Environment(loader=FileSystemLoader(str(_template_dir())), trim_blocks=True, lstrip_blocks=True) + template = env.get_template(template_name) + return template.render(**context).rstrip() + "\n" + + +def _write_text(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def generate_cpp_files(scenarios: list[dict[str, Any]], out_dir: Path) -> str: + spec_hash = _story_spec_hash(scenarios) + context = _create_cpp_context(scenarios, spec_hash) + _write_text(out_dir / "scenarios_gen.h", _render_template("scenarios_gen.h.j2", context)) + _write_text(out_dir / "scenarios_gen.cpp", _render_template("scenarios_gen.cpp.j2", context)) + _write_text(out_dir / "apps_gen.h", _render_template("apps_gen.h.j2", context)) + _write_text(out_dir / "apps_gen.cpp", _render_template("apps_gen.cpp.j2", context)) + return spec_hash + + +def _json_compact(payload: dict[str, Any]) -> bytes: + return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +def _write_json_with_checksum(root: Path, rel_path: str, payload: dict[str, Any]) -> None: + blob = _json_compact(payload) + out_path = root / rel_path + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_bytes(blob) + (out_path.with_suffix(".sha256")).write_text(_sha_hex(blob) + "\n", encoding="utf-8") + + +def _resource_slug(resource_id: str, prefix: str) -> str: + slug = resource_id + if slug.startswith(prefix): + slug = slug[len(prefix) :] + return slug.lower() + + +def _resource_candidates(resource_root: Path, resource_type: str, resource_id: str) -> list[Path]: + candidates: list[Path] = [] + base = resource_root / resource_type + candidates.append(base / f"{resource_id}.json") + candidates.append(base / f"{resource_id.lower()}.json") + + if resource_type == "screens": + slug = _resource_slug(resource_id, "SCENE_") + candidates.append(base / f"{slug}.json") + elif resource_type == "audio": + slug = _resource_slug(resource_id, "PACK_") + candidates.append(base / f"{slug}.json") + elif resource_type == "actions": + alias = ACTION_FILE_ALIASES.get(resource_id) + if alias: + candidates.append(base / f"{alias}.json") + candidates.append(base / f"{alias.lower()}.json") + + # de-duplicate while preserving order + dedup: list[Path] = [] + seen: set[str] = set() + for path in candidates: + marker = str(path) + if marker in seen: + continue + seen.add(marker) + dedup.append(path) + return dedup + + +def _load_resource_payload( + resource_root: Path | None, resource_type: str, resource_id: str, required: bool = False +) -> dict[str, Any] | None: + if resource_root is None: + if required: + raise StoryGenerationError(f"Missing required {resource_type} resource '{resource_id}'") + return None + for path in _resource_candidates(resource_root, resource_type, resource_id): + if not path.exists(): + continue + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise StoryGenerationError(f"Invalid JSON resource {path}: {exc}") from exc + if not isinstance(payload, dict): + raise StoryGenerationError(f"Invalid resource type in {path}: expected object") + return payload + if required: + raise StoryGenerationError(f"Missing required {resource_type} resource '{resource_id}'") + return None + + +def _merge_app_payload(source_payload: dict[str, Any] | None, binding: dict[str, Any]) -> dict[str, Any]: + merged: dict[str, Any] = dict(source_payload or {}) + merged["id"] = binding["id"] + merged["app"] = binding["app"] + + binding_config = binding.get("config") + if binding_config is not None: + existing_config = merged.get("config") + if isinstance(existing_config, dict): + config = dict(existing_config) + else: + config = {} + config.update(binding_config) + merged["config"] = config + return merged + + +def _with_resource_id(payload: dict[str, Any] | None, resource_id: str) -> dict[str, Any]: + result = dict(payload or {}) + result["id"] = resource_id + return result + + +def _scene_profile(scene_id: str) -> dict[str, Any]: + profile = _active_scene_profiles().get(scene_id, DEFAULT_SCENE_PROFILE) + return copy.deepcopy(profile) + + +def _as_positive_int(value: Any, default_value: int) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return default_value + if parsed < 0: + return 0 + return parsed + + +def _as_int_in_range(value: Any, default_value: int, min_value: int, max_value: int) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + parsed = default_value + if parsed < min_value: + return min_value + if parsed > max_value: + return max_value + return parsed + + +def _as_bool(value: Any, default_value: bool) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + return default_value + + +def _as_choice(value: Any, allowed: set[str], default_value: str) -> str: + if isinstance(value, str): + normalized = value.strip().lower().replace("-", "_") + if normalized in allowed: + return normalized + return default_value + + +def _normalize_screen_effect(value: Any, default_value: str) -> str: + if isinstance(value, str): + normalized = value.strip().lower().replace("-", "_") + normalized = SCREEN_EFFECT_ALIASES.get(normalized, normalized) + if normalized in SCREEN_EFFECT_CHOICES: + return normalized + if any(token in normalized for token in ("scan", "radar", "wave", "sonar")): + return "scan" + return "pulse" + return _as_choice(default_value, SCREEN_EFFECT_CHOICES, "pulse") + + +def _normalize_transition_effect(value: Any, default_value: str) -> str: + if isinstance(value, str): + normalized = value.strip().lower().replace("-", "_") + normalized = TRANSITION_EFFECT_ALIASES.get(normalized, normalized) + if normalized in TRANSITION_EFFECT_CHOICES: + return normalized + return _as_choice(default_value, TRANSITION_EFFECT_CHOICES, "fade") + + +def _normalize_theme(theme_payload: Any, fallback_theme: dict[str, str]) -> dict[str, str]: + theme: dict[str, str] = { + "bg": fallback_theme["bg"], + "accent": fallback_theme["accent"], + "text": fallback_theme["text"], + } + if isinstance(theme_payload, dict): + for key in ("bg", "accent", "text"): + value = theme_payload.get(key) + if isinstance(value, str) and value.strip(): + theme[key] = value.strip() + return theme + + +def _normalize_text_options(payload: Any, profile: dict[str, Any]) -> dict[str, Any]: + base = dict(DEFAULT_TEXT_OPTIONS) + profile_options = profile.get("text") + if isinstance(profile_options, dict): + base.update(profile_options) + src = payload if isinstance(payload, dict) else {} + + return { + "show_title": _as_bool(src.get("show_title"), _as_bool(base.get("show_title"), False)), + "show_subtitle": _as_bool(src.get("show_subtitle"), _as_bool(base.get("show_subtitle"), True)), + "show_symbol": _as_bool(src.get("show_symbol"), _as_bool(base.get("show_symbol"), True)), + "title_case": _as_choice( + src.get("title_case"), + TEXT_CASE_CHOICES, + _as_choice(base.get("title_case"), TEXT_CASE_CHOICES, "upper"), + ), + "subtitle_case": _as_choice( + src.get("subtitle_case"), + TEXT_CASE_CHOICES, + _as_choice(base.get("subtitle_case"), TEXT_CASE_CHOICES, "raw"), + ), + "title_align": _as_choice( + src.get("title_align"), + TEXT_ALIGN_CHOICES, + _as_choice(base.get("title_align"), TEXT_ALIGN_CHOICES, "top"), + ), + "subtitle_align": _as_choice( + src.get("subtitle_align"), + TEXT_ALIGN_CHOICES, + _as_choice(base.get("subtitle_align"), TEXT_ALIGN_CHOICES, "bottom"), + ), + } + + +def _normalize_framing_options(payload: Any, profile: dict[str, Any]) -> dict[str, Any]: + base = dict(DEFAULT_FRAMING_OPTIONS) + profile_options = profile.get("framing") + if isinstance(profile_options, dict): + base.update(profile_options) + src = payload if isinstance(payload, dict) else {} + + return { + "preset": _as_choice( + src.get("preset"), + FRAMING_PRESET_CHOICES, + _as_choice(base.get("preset"), FRAMING_PRESET_CHOICES, "center"), + ), + "x_offset": _as_int_in_range(src.get("x_offset"), int(base.get("x_offset", 0)), -80, 80), + "y_offset": _as_int_in_range(src.get("y_offset"), int(base.get("y_offset", 0)), -80, 80), + "scale_pct": _as_int_in_range(src.get("scale_pct"), int(base.get("scale_pct", 100)), 60, 140), + } + + +def _normalize_scroll_options(payload: Any, profile: dict[str, Any]) -> dict[str, Any]: + base = dict(DEFAULT_SCROLL_OPTIONS) + profile_options = profile.get("scroll") + if isinstance(profile_options, dict): + base.update(profile_options) + src = payload if isinstance(payload, dict) else {} + + mode_value = src.get("mode") + if isinstance(mode_value, str): + normalized_mode = mode_value.strip().lower().replace("-", "_") + mode_value = SCROLL_MODE_ALIASES.get(normalized_mode, normalized_mode) + mode = _as_choice(mode_value, SCROLL_MODE_CHOICES, _as_choice(base.get("mode"), SCROLL_MODE_CHOICES, "none")) + return { + "mode": mode, + "speed_ms": _as_int_in_range(src.get("speed_ms"), int(base.get("speed_ms", 4200)), 600, 20000), + "pause_ms": _as_int_in_range(src.get("pause_ms"), int(base.get("pause_ms", 900)), 0, 10000), + "loop": _as_bool(src.get("loop"), _as_bool(base.get("loop"), True)), + } + + +def _normalize_demo_options(payload: Any, profile: dict[str, Any]) -> dict[str, Any]: + base = dict(DEFAULT_DEMO_OPTIONS) + profile_options = profile.get("demo") + if isinstance(profile_options, dict): + base.update(profile_options) + src = payload if isinstance(payload, dict) else {} + + return { + "mode": _as_choice( + src.get("mode"), + DEMO_MODE_CHOICES, + _as_choice(base.get("mode"), DEMO_MODE_CHOICES, "standard"), + ), + "particle_count": _as_int_in_range(src.get("particle_count"), int(base.get("particle_count", 4)), 0, 4), + "strobe_level": _as_int_in_range(src.get("strobe_level"), int(base.get("strobe_level", 65)), 0, 100), + } + + +def _normalize_screen_timeline(payload: dict[str, Any], profile: dict[str, Any]) -> dict[str, Any]: + timeline_source = payload.get("timeline") + visual_source = payload.get("visual") + timeline_obj: dict[str, Any] | None = None + if isinstance(timeline_source, dict): + timeline_obj = timeline_source + elif isinstance(visual_source, dict) and isinstance(visual_source.get("timeline"), dict): + timeline_obj = visual_source.get("timeline") + + timeline_nodes: list[Any] = [] + if isinstance(timeline_source, list): + timeline_nodes = list(timeline_source) + elif isinstance(timeline_obj, dict): + keyframes = timeline_obj.get("keyframes") + if isinstance(keyframes, list): + timeline_nodes = list(keyframes) + elif isinstance(timeline_obj.get("frames"), list): + timeline_nodes = list(timeline_obj.get("frames")) + elif isinstance(visual_source, dict) and isinstance(visual_source.get("timeline"), list): + timeline_nodes = list(visual_source.get("timeline")) + + default_frames = profile["timeline"] + base_theme = _normalize_theme(payload.get("theme"), profile["theme"]) + base_effect = _normalize_screen_effect(payload.get("effect"), str(profile["effect"])) + visual = payload.get("visual") + if isinstance(visual, dict): + base_speed = _as_positive_int(visual.get("effect_speed_ms"), profile["effect_speed_ms"]) + else: + base_speed = profile["effect_speed_ms"] + base_speed = _as_positive_int(payload.get("effect_speed_ms"), base_speed) + if base_speed <= 0: + base_speed = profile["effect_speed_ms"] + + normalized_frames: list[dict[str, Any]] = [] + if not timeline_nodes: + timeline_nodes = copy.deepcopy(default_frames) + + prev_at = 0 + prev_effect = base_effect + prev_speed = base_speed + prev_theme = dict(base_theme) + for frame in timeline_nodes: + if not isinstance(frame, dict): + continue + at_value = frame.get("at_ms", frame.get("time_ms", frame.get("t", prev_at))) + at_ms = _as_positive_int(at_value, prev_at) + if at_ms < prev_at: + at_ms = prev_at + + effect = _normalize_screen_effect(frame.get("effect", frame.get("fx", prev_effect)), prev_effect) + + speed_value = frame.get("speed_ms", frame.get("effect_speed_ms", frame.get("speed", prev_speed))) + speed_ms = _as_positive_int(speed_value, prev_speed) + if speed_ms <= 0: + speed_ms = prev_speed + + theme_payload = frame.get("theme") + if not isinstance(theme_payload, dict): + theme_payload = { + "bg": frame.get("bg"), + "accent": frame.get("accent"), + "text": frame.get("text"), + } + theme = _normalize_theme(theme_payload, prev_theme) + + normalized_frames.append( + { + "at_ms": at_ms, + "effect": effect, + "speed_ms": speed_ms, + "theme": theme, + } + ) + prev_at = at_ms + prev_effect = effect + prev_speed = speed_ms + prev_theme = dict(theme) + + if not normalized_frames: + normalized_frames = copy.deepcopy(default_frames) + + if normalized_frames[0]["at_ms"] != 0: + first = copy.deepcopy(normalized_frames[0]) + first["at_ms"] = 0 + normalized_frames.insert(0, first) + + if len(normalized_frames) == 1: + extra = copy.deepcopy(normalized_frames[0]) + extra["at_ms"] = max(1000, normalized_frames[0]["at_ms"] + 1000) + normalized_frames.append(extra) + + duration_ms = normalized_frames[-1]["at_ms"] + if timeline_obj is not None: + duration_ms = max(duration_ms, _as_positive_int(timeline_obj.get("duration_ms"), duration_ms)) + if duration_ms < 100: + duration_ms = 100 + + loop = True + if timeline_obj is not None and isinstance(timeline_obj.get("loop"), bool): + loop = bool(timeline_obj.get("loop")) + + return { + "loop": loop, + "duration_ms": duration_ms, + "keyframes": normalized_frames, + } + + +def _normalize_screen_payload(source_payload: dict[str, Any] | None, scene_id: str) -> dict[str, Any]: + payload: dict[str, Any] = dict(source_payload or {}) + payload["id"] = scene_id + + profile = _scene_profile(scene_id) + + for text_field in ("title", "subtitle", "symbol", "effect"): + value = payload.get(text_field) + if not isinstance(value, str) or not value.strip(): + payload[text_field] = profile[text_field] + payload["effect"] = _normalize_screen_effect(payload.get("effect"), str(profile["effect"])) + + text_options = _normalize_text_options(payload.get("text"), profile) + payload["text"] = text_options + + visual = payload.get("visual") + if not isinstance(visual, dict): + visual = {} + visual["show_title"] = text_options["show_title"] + visual["show_subtitle"] = text_options["show_subtitle"] + visual["show_symbol"] = text_options["show_symbol"] + visual["effect_speed_ms"] = _as_positive_int(visual.get("effect_speed_ms"), profile["effect_speed_ms"]) + if visual["effect_speed_ms"] <= 0: + visual["effect_speed_ms"] = profile["effect_speed_ms"] + payload["visual"] = visual + + payload["theme"] = _normalize_theme(payload.get("theme"), profile["theme"]) + payload["framing"] = _normalize_framing_options(payload.get("framing"), profile) + payload["scroll"] = _normalize_scroll_options(payload.get("scroll"), profile) + payload["demo"] = _normalize_demo_options(payload.get("demo"), profile) + + transition = payload.get("transition") + if isinstance(transition, str): + transition = {"effect": transition} + if not isinstance(transition, dict): + transition = {} + default_transition = profile["transition"] + effect = transition.get("effect", transition.get("type", default_transition["effect"])) + transition["effect"] = _normalize_transition_effect(effect, str(default_transition["effect"])) + transition["duration_ms"] = _as_positive_int(transition.get("duration_ms"), default_transition["duration_ms"]) + if transition["duration_ms"] <= 0: + transition["duration_ms"] = default_transition["duration_ms"] + payload["transition"] = transition + + payload["timeline"] = _normalize_screen_timeline(payload, profile) + return payload + + +def _load_screen_payload_with_legacy_fallback( + resource_root: Path | None, screen_id: str +) -> tuple[dict[str, Any] | None, bool]: + source_payload = _load_resource_payload(resource_root, "screens", screen_id, required=False) + if source_payload is not None: + return source_payload, False + if screen_id in _active_scene_profiles(): + # Legacy bundles may omit some known screen JSON files. For those scenes, + # generate payloads from profile defaults instead of failing hard. + return None, True + raise StoryGenerationError(f"Missing required screens resource '{screen_id}'") + + +def generate_bundle_files( + scenarios: list[dict[str, Any]], + out_dir: Path, + spec_hash: str, + resource_root: Path | None = None, +) -> None: + resources: dict[str, set[str]] = { + "screens": set(), + "audio": set(), + "actions": set(), + "apps": set(), + } + autogenerated_screens: list[str] = [] + bindings: dict[str, dict[str, Any]] = {} + + for scenario in scenarios: + scenario_payload = { + "id": scenario["id"], + "version": scenario["version"], + "estimated_duration_s": scenario.get("estimated_duration_s", 0), + "debug_transition_bypass_enabled": bool(scenario.get("debug_transition_bypass_enabled", False)), + "initial_step": scenario["initial_step"], + "app_bindings": scenario["app_bindings"], + "steps": scenario["steps"], + } + _write_json_with_checksum(out_dir, f"story/scenarios/{scenario['id']}.json", scenario_payload) + + for binding in scenario["app_bindings"]: + resources["apps"].add(binding["id"]) + bindings[binding["id"]] = { + "id": binding["id"], + "app": binding["app"], + "config": binding.get("config"), + } + + for step in scenario["steps"]: + if step["screen_scene_id"]: + resources["screens"].add(step["screen_scene_id"]) + if step["audio_pack_id"]: + resources["audio"].add(step["audio_pack_id"]) + for action in step["actions"]: + resources["actions"].add(action) + + for app_id in sorted(resources["apps"]): + binding = bindings[app_id] + source_payload = _load_resource_payload(resource_root, "apps", app_id) + app_payload = _merge_app_payload(source_payload, binding) + _write_json_with_checksum(out_dir, f"story/apps/{app_id}.json", app_payload) + + for screen_id in sorted(resources["screens"]): + source_payload, autogenerated = _load_screen_payload_with_legacy_fallback(resource_root, screen_id) + payload = _normalize_screen_payload(source_payload, screen_id) + _write_json_with_checksum(out_dir, f"story/screens/{screen_id}.json", payload) + if autogenerated: + autogenerated_screens.append(screen_id) + + for audio_id in sorted(resources["audio"]): + source_payload = _load_resource_payload(resource_root, "audio", audio_id) + payload = _with_resource_id(source_payload, audio_id) + _write_json_with_checksum(out_dir, f"story/audio/{audio_id}.json", payload) + + for action_id in sorted(resources["actions"]): + source_payload = _load_resource_payload(resource_root, "actions", action_id) + payload = _with_resource_id(source_payload, action_id) + _write_json_with_checksum(out_dir, f"story/actions/{action_id}.json", payload) + + manifest = { + "spec_hash": spec_hash, + "scenarios": [scenario["id"] for scenario in scenarios], + "resource_counts": {key: len(values) for key, values in resources.items()}, + "autogenerated_resources": { + "screens": autogenerated_screens, + }, + } + _write_json_with_checksum(out_dir, "story/manifest.json", manifest) + + +def create_archive(root: Path, archive_path: Path) -> None: + archive_path.parent.mkdir(parents=True, exist_ok=True) + with tarfile.open(archive_path, "w:gz") as tar: + for path in sorted(root.rglob("*")): + if path.is_file(): + tar.add(path, arcname=path.relative_to(root)) + + +def load_and_validate(paths: StoryPaths, spec_dir: Path | None = None, game_dir: Path | None = None) -> tuple[list[dict[str, Any]], list[str]]: + _require_deps() + actual_spec_dir = spec_dir or paths.story_specs_dir + actual_game_dir = game_dir or paths.game_scenarios_dir + + spec_files = _list_yaml_files(actual_spec_dir) + game_files = _list_yaml_files(actual_game_dir) + + runtime_game_files: list[Path] = [] + narrative_game_files: list[Path] = [] + template_files: list[Path] = [] + for file_path in game_files: + category = _classify_game_yaml(file_path) + if category == "runtime": + runtime_game_files.append(file_path) + elif category == "narrative": + narrative_game_files.append(file_path) + elif category == "template": + template_files.append(file_path) + elif category == "workbench": + continue + + _validate_yamale(_schema_path("story_spec_schema.yamale"), spec_files) + if runtime_game_files: + _validate_yamale(_schema_path("story_spec_schema.yamale"), runtime_game_files) + if template_files: + _validate_yamale(_schema_path("scenario_template_schema.yamale"), template_files) + + _validate_runtime_mirror(spec_files, runtime_game_files) + + runtime_story_files = _build_runtime_story_file_set(spec_files, runtime_game_files) + scenarios = _normalize_story_specs(runtime_story_files) + game_ids = _validate_game_scenarios(narrative_game_files) + return scenarios, game_ids + + +def _pretty_json(payload: dict[str, Any]) -> str: + return json.dumps(payload, indent=2, ensure_ascii=True) + "\n" + + +def _build_screen_payload_from_profile(scene_id: str) -> dict[str, Any]: + profile_payload = _scene_profile(scene_id) + return _normalize_screen_payload(profile_payload, scene_id) + + +def _legacy_screen_mirror_targets(scene_id: str) -> list[tuple[str, str]]: + targets: list[tuple[str, str]] = [(_scene_slug(scene_id), scene_id)] + for alias_id, canonical in LEGACY_SCREEN_ALIASES.items(): + if canonical != scene_id: + continue + alias_slug = _scene_slug(alias_id) + if any(existing_slug == alias_slug for existing_slug, _ in targets): + continue + targets.append((alias_slug, alias_id)) + return targets + + +def _sync_screens(paths: StoryPaths, check_only: bool) -> dict[str, Any]: + _activate_scene_profiles(paths) + story_screens_dir = paths.story_data_dir / "screens" + # Keep legacy payload mirrors out of the LittleFS data/ tree. + legacy_screens_dir = paths.fw_root / "legacy_payloads" / "fs_excluded" / "screens" + story_screens_dir.mkdir(parents=True, exist_ok=True) + legacy_screens_dir.mkdir(parents=True, exist_ok=True) + + story_written = 0 + story_unchanged = 0 + legacy_written = 0 + legacy_unchanged = 0 + drifts: list[str] = [] + + for scene_id in CANONICAL_SCREEN_SCENE_IDS: + payload = _build_screen_payload_from_profile(scene_id) + payload_text = _pretty_json(payload) + story_path = story_screens_dir / f"{scene_id}.json" + current_story = story_path.read_text(encoding="utf-8") if story_path.exists() else None + if current_story != payload_text: + if check_only: + drifts.append(str(story_path)) + else: + story_path.write_text(payload_text, encoding="utf-8") + story_written += 1 + else: + story_unchanged += 1 + + for slug, payload_id in _legacy_screen_mirror_targets(scene_id): + legacy_payload = dict(payload) + legacy_payload["id"] = payload_id + legacy_text = _pretty_json(legacy_payload) + legacy_path = legacy_screens_dir / f"{slug}.json" + current_legacy = legacy_path.read_text(encoding="utf-8") if legacy_path.exists() else None + if current_legacy != legacy_text: + if check_only: + drifts.append(str(legacy_path)) + else: + legacy_path.write_text(legacy_text, encoding="utf-8") + legacy_written += 1 + else: + legacy_unchanged += 1 + + if check_only and drifts: + raise StoryGenerationError( + "screen sync drift detected:\n" + "\n".join(f"- {path}" for path in sorted(set(drifts))) + ) + + return { + "check_only": check_only, + "story_count": len(CANONICAL_SCREEN_SCENE_IDS), + "story_written": story_written, + "story_unchanged": story_unchanged, + "legacy_written": legacy_written, + "legacy_unchanged": legacy_unchanged, + "legacy_dir": str(legacy_screens_dir), + "palette_path": str(_palette_path(paths)), + } + + +def run_validate(paths: StoryPaths, spec_dir: Path | None = None, game_dir: Path | None = None) -> dict[str, Any]: + _activate_scene_profiles(paths) + scenarios, game_ids = load_and_validate(paths, spec_dir=spec_dir, game_dir=game_dir) + return { + "scenarios": [item["id"] for item in scenarios], + "scenario_count": len(scenarios), + "game_scenarios": game_ids, + "game_scenario_count": len(game_ids), + } + + +def run_generate_cpp( + paths: StoryPaths, + out_dir: Path | None = None, + spec_dir: Path | None = None, + game_dir: Path | None = None, +) -> dict[str, Any]: + _activate_scene_profiles(paths) + scenarios, game_ids = load_and_validate(paths, spec_dir=spec_dir, game_dir=game_dir) + cpp_out = out_dir or paths.generated_cpp_dir + spec_hash = generate_cpp_files(scenarios, cpp_out) + return { + "spec_hash": spec_hash, + "out_dir": cpp_out, + "scenario_count": len(scenarios), + "game_scenario_count": len(game_ids), + } + + +def run_generate_bundle( + paths: StoryPaths, + out_dir: Path | None = None, + archive: Path | None = None, + spec_dir: Path | None = None, + game_dir: Path | None = None, +) -> dict[str, Any]: + _activate_scene_profiles(paths) + scenarios, game_ids = load_and_validate(paths, spec_dir=spec_dir, game_dir=game_dir) + bundle_out = out_dir or paths.bundle_root + spec_hash = _story_spec_hash(scenarios) + + if bundle_out.exists(): + for file_path in sorted(bundle_out.rglob("*"), reverse=True): + if file_path.is_file(): + file_path.unlink() + + resource_root = paths.story_data_dir if paths.story_data_dir.exists() else None + generate_bundle_files(scenarios, bundle_out, spec_hash, resource_root=resource_root) + + archive_path = archive + if archive_path is not None: + create_archive(bundle_out, archive_path) + + return { + "spec_hash": spec_hash, + "out_dir": bundle_out, + "archive": archive_path, + "scenario_count": len(scenarios), + "game_scenario_count": len(game_ids), + } + + +def run_sync_screens(paths: StoryPaths, check_only: bool = False) -> dict[str, Any]: + return _sync_screens(paths, check_only=check_only) diff --git a/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/schemas/game_scenario_schema.yamale b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/schemas/game_scenario_schema.yamale new file mode 100644 index 0000000..9b355da --- /dev/null +++ b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/schemas/game_scenario_schema.yamale @@ -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) diff --git a/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/schemas/scenario_template_schema.yamale b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/schemas/scenario_template_schema.yamale new file mode 100644 index 0000000..a58723a --- /dev/null +++ b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/schemas/scenario_template_schema.yamale @@ -0,0 +1,2 @@ +prompt_input: any() +current_firmware_snapshot: any(required=False) diff --git a/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/schemas/story_spec_schema.yamale b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/schemas/story_spec_schema.yamale new file mode 100644 index 0000000..bcae1e1 --- /dev/null +++ b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/schemas/story_spec_schema.yamale @@ -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) diff --git a/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/templates/apps_gen.cpp.j2 b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/templates/apps_gen.cpp.j2 new file mode 100644 index 0000000..9f2bf17 --- /dev/null +++ b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/templates/apps_gen.cpp.j2 @@ -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 + +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(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; +} diff --git a/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/templates/apps_gen.h.j2 b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/templates/apps_gen.h.j2 new file mode 100644 index 0000000..8d146af --- /dev/null +++ b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/templates/apps_gen.h.j2 @@ -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 + +#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); diff --git a/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/templates/scenarios_gen.cpp.j2 b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/templates/scenarios_gen.cpp.j2 new file mode 100644 index 0000000..e4583c7 --- /dev/null +++ b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/templates/scenarios_gen.cpp.j2 @@ -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 + +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(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 }}"; +} diff --git a/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/templates/scenarios_gen.h.j2 b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/templates/scenarios_gen.h.j2 new file mode 100644 index 0000000..1234706 --- /dev/null +++ b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/templates/scenarios_gen.h.j2 @@ -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 + +#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(); diff --git a/lib/zacus_story_portable/include/zacus_story_portable/story_hal.h b/lib/zacus_story_portable/include/zacus_story_portable/story_hal.h new file mode 100644 index 0000000..ab1df9d --- /dev/null +++ b/lib/zacus_story_portable/include/zacus_story_portable/story_hal.h @@ -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 +#include + +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 diff --git a/lib/zacus_story_portable/src/hal_arduino.cpp b/lib/zacus_story_portable/src/hal_arduino.cpp new file mode 100644 index 0000000..641a532 --- /dev/null +++ b/lib/zacus_story_portable/src/hal_arduino.cpp @@ -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 +#include +#include +#include + +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 diff --git a/lib/zacus_story_portable/src/story_hal.cpp b/lib/zacus_story_portable/src/story_hal.cpp new file mode 100644 index 0000000..80453d4 --- /dev/null +++ b/lib/zacus_story_portable/src/story_hal.cpp @@ -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 diff --git a/ui_freenove_allinone/include/npc/npc_engine.h b/ui_freenove_allinone/include/npc/npc_engine.h new file mode 100644 index 0000000..79145bd --- /dev/null +++ b/ui_freenove_allinone/include/npc/npc_engine.h @@ -0,0 +1,112 @@ +// npc_engine.h - Professor Zacus NPC decision engine. +// Lightweight state machine: trigger rules, mood system, phrase selection. +#pragma once + +#include +#include + +#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 diff --git a/ui_freenove_allinone/include/npc/qr_scanner.h b/ui_freenove_allinone/include/npc/qr_scanner.h new file mode 100644 index 0000000..ed94483 --- /dev/null +++ b/ui_freenove_allinone/include/npc/qr_scanner.h @@ -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 +#include + +#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 diff --git a/ui_freenove_allinone/include/npc/tts_client.h b/ui_freenove_allinone/include/npc/tts_client.h new file mode 100644 index 0000000..e924ac1 --- /dev/null +++ b/ui_freenove_allinone/include/npc/tts_client.h @@ -0,0 +1,63 @@ +// tts_client.h - HTTP client for Piper TTS on Tower:8001 with SD fallback. +#pragma once + +#include +#include +#include + +#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 diff --git a/ui_freenove_allinone/src/npc/npc_engine.cpp b/ui_freenove_allinone/src/npc/npc_engine.cpp new file mode 100644 index 0000000..cbbdf6f --- /dev/null +++ b/ui_freenove_allinone/src/npc/npc_engine.cpp @@ -0,0 +1,198 @@ +// npc_engine.cpp - Professor Zacus NPC decision engine implementation. +#include "npc/npc_engine.h" +#include +#include + +// 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; +} diff --git a/ui_freenove_allinone/src/npc/qr_scanner.cpp b/ui_freenove_allinone/src/npc/qr_scanner.cpp new file mode 100644 index 0000000..9c506dc --- /dev/null +++ b/ui_freenove_allinone/src/npc/qr_scanner.cpp @@ -0,0 +1,157 @@ +// qr_scanner.cpp - ZACUS QR protocol parser, validator, anti-cheat. +#include "npc/qr_scanner.h" +#include +#include +#include + +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; +} diff --git a/ui_freenove_allinone/src/npc/tts_client.cpp b/ui_freenove_allinone/src/npc/tts_client.cpp new file mode 100644 index 0000000..4a3cfbc --- /dev/null +++ b/ui_freenove_allinone/src/npc/tts_client.cpp @@ -0,0 +1,143 @@ +// tts_client.cpp - Piper TTS HTTP client for ESP32. +#include "npc/tts_client.h" + +#include +#include +#include +#include +#include + +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; +}