chore: checkpoint app work before firmware sync

This commit is contained in:
Adolfo Reyna
2026-09-07 23:01:26 -04:00
parent e42036a044
commit 59612020bb
20 changed files with 2111 additions and 1218 deletions
+2 -2
View File
@@ -12,8 +12,8 @@
#include <esp_random.h> #include <esp_random.h>
#include <tt_lvgl_keyboard.h> #include <tt_lvgl_keyboard.h>
#include <tactility/lvgl_module.h> #include <lvgl/lvgl.h>
#include <tactility/lvgl_fonts.h> #include <lvgl/fonts.h>
constexpr auto* TAG = "Breakout"; constexpr auto* TAG = "Breakout";
+54 -55
View File
@@ -25,10 +25,11 @@ struct Device* device_find_by_name(const char* name);
struct Device* device_find_first_by_type(const struct DeviceType* type); struct Device* device_find_first_by_type(const struct DeviceType* type);
#define TAG "LiveCaptions" #define TAG "LiveCaptions"
#define DEFAULT_ENDPOINT "ws://192.168.68.102:8642/api/esp32/voice/ws" #define DEFAULT_ENDPOINT "ws://192.168.68.102:8645/api/esp32/captions/ws"
#define DEFAULT_DEVICE_ID "tactility-14c19d1a790" #define DEFAULT_DEVICE_ID "tactility-14c19d1a790"
#define PCM_BUFFER_BYTES 1024U #define PCM_BUFFER_BYTES 1024U
#define EVENT_BUFFER_BYTES 4096U #define EVENT_BUFFER_BYTES 4096U
#define DISPLAY_WORD_LIMIT 50
typedef enum { typedef enum {
CAPTION_IDLE, CAPTION_IDLE,
@@ -59,31 +60,15 @@ typedef struct {
TaskHandle_t receiver; TaskHandle_t receiver;
SemaphoreHandle_t socket_lock; SemaphoreHandle_t socket_lock;
SemaphoreHandle_t audio_lock; SemaphoreHandle_t audio_lock;
lv_obj_t* state_label;
lv_obj_t* caption_label; lv_obj_t* caption_label;
lv_obj_t* start_button; lv_obj_t* status_label;
lv_obj_t* stop_button;
} CaptionContext; } CaptionContext;
static void update_ui(CaptionContext* ctx) { static void update_ui(CaptionContext* ctx) {
if (!ctx->visible || !tt_lvgl_lock(pdMS_TO_TICKS(100))) return; if (!ctx->visible || !tt_lvgl_lock(pdMS_TO_TICKS(100))) return;
const char* state = "READY"; lv_label_set_text(ctx->caption_label, ctx->caption);
if (ctx->state == CAPTION_CONNECTING) state = "CONNECTING"; const char* status = ctx->state == CAPTION_LISTENING ? "Connected" : (ctx->state == CAPTION_CONNECTING ? "Connecting" : (ctx->state == CAPTION_FAILED ? "Failed — Reconnecting" : "Connecting"));
else if (ctx->state == CAPTION_LISTENING) state = "LISTENING"; lv_label_set_text(ctx->status_label, status);
else if (ctx->state == CAPTION_PROCESSING) state = "FINALIZING";
else if (ctx->state == CAPTION_FAILED) state = "FAILED";
lv_label_set_text(ctx->state_label, state);
lv_label_set_text(ctx->caption_label, ctx->caption[0] ? ctx->caption : ctx->detail);
if (ctx->state == CAPTION_IDLE || ctx->state == CAPTION_FAILED) {
lv_obj_clear_state(ctx->start_button, LV_STATE_DISABLED);
} else {
lv_obj_add_state(ctx->start_button, LV_STATE_DISABLED);
}
if (ctx->state == CAPTION_LISTENING || ctx->state == CAPTION_PROCESSING) {
lv_obj_clear_state(ctx->stop_button, LV_STATE_DISABLED);
} else {
lv_obj_add_state(ctx->stop_button, LV_STATE_DISABLED);
}
tt_lvgl_unlock(); tt_lvgl_unlock();
} }
@@ -162,14 +147,29 @@ static void append_final_caption(const char* text) {
fclose(log); fclose(log);
} }
static bool is_space_char(char value) { return value == ' ' || value == '\n' || value == '\r' || value == ' '; }
static void copy_recent_words(char* output, size_t output_size, const char* text) {
const char* starts[DISPLAY_WORD_LIMIT];
int count = 0;
bool in_word = false;
for (const char* cursor = text; *cursor; ++cursor) {
if (is_space_char(*cursor)) { in_word = false; continue; }
if (!in_word) { starts[count % DISPLAY_WORD_LIMIT] = cursor; ++count; in_word = true; }
}
const char* first = count > DISPLAY_WORD_LIMIT ? starts[count % DISPLAY_WORD_LIMIT] : text;
snprintf(output, output_size, "%s", first);
}
static void display_caption(CaptionContext* ctx, const char* text, bool final) { static void display_caption(CaptionContext* ctx, const char* text, bool final) {
if (text == NULL || !*text) return; if (text == NULL || !*text) return;
snprintf(ctx->caption, sizeof(ctx->caption), "%s", text); copy_recent_words(ctx->caption, sizeof(ctx->caption), text);
if (final && strcmp(ctx->last_final, text) != 0) { if (final && strcmp(ctx->last_final, text) != 0) {
snprintf(ctx->last_final, sizeof(ctx->last_final), "%s", text); snprintf(ctx->last_final, sizeof(ctx->last_final), "%s", text);
append_final_caption(text); append_final_caption(text);
set_state(ctx, CAPTION_IDLE, "Saved final caption to SD card"); ctx->state = CAPTION_IDLE;
} else update_ui(ctx); }
update_ui(ctx);
} }
static void handle_event(CaptionContext* ctx, const char* json) { static void handle_event(CaptionContext* ctx, const char* json) {
@@ -183,7 +183,14 @@ static void handle_event(CaptionContext* ctx, const char* json) {
set_state(ctx, CAPTION_CONNECTING, "Starting caption stream"); set_state(ctx, CAPTION_CONNECTING, "Starting caption stream");
} else if (strcmp(name, "listening") == 0) { } else if (strcmp(name, "listening") == 0) {
set_state(ctx, CAPTION_LISTENING, "Listening — press Stop when finished"); set_state(ctx, CAPTION_LISTENING, "Listening — press Stop when finished");
} else if (strcmp(name, "draft") == 0 || strcmp(name, "interim_transcript") == 0) { } else if (strcmp(name, "state") == 0) {
cJSON* remote_state = cJSON_GetObjectItem(root, "state");
if (cJSON_IsString(remote_state) && strcmp(remote_state->valuestring, "listening") == 0) {
set_state(ctx, CAPTION_LISTENING, "Listening");
} else if (cJSON_IsString(remote_state) && strcmp(remote_state->valuestring, "processing") == 0) {
set_state(ctx, CAPTION_PROCESSING, "Captioning…");
}
} else if (strcmp(name, "draft") == 0 || strcmp(name, "interim_transcript") == 0 || strcmp(name, "review") == 0) {
if (cJSON_IsString(text)) display_caption(ctx, text->valuestring, false); if (cJSON_IsString(text)) display_caption(ctx, text->valuestring, false);
} else if (strcmp(name, "transcript") == 0 || strcmp(name, "final") == 0) { } else if (strcmp(name, "transcript") == 0 || strcmp(name, "final") == 0) {
cJSON* is_final = cJSON_GetObjectItem(root, "isFinal"); cJSON* is_final = cJSON_GetObjectItem(root, "isFinal");
@@ -248,7 +255,7 @@ static void worker_task(void* argument) {
ctx->fd = ws_connect(host, port, path, ctx->device_id, ctx->api_key); ctx->fd = ws_connect(host, port, path, ctx->device_id, ctx->api_key);
if (ctx->fd < 0) { set_state(ctx, CAPTION_FAILED, "Fail to connect"); ctx->worker = NULL; vTaskDelete(NULL); } if (ctx->fd < 0) { set_state(ctx, CAPTION_FAILED, "Fail to connect"); ctx->worker = NULL; vTaskDelete(NULL); }
char start[256]; char start[256];
snprintf(start, sizeof(start), "{\"event\":\"start\",\"device_id\":\"%s\",\"format\":\"pcm_s16le\",\"sample_rate\":16000,\"channels\":1,\"sample_width\":2,\"session_id\":\"cap-%08lx\"}", ctx->device_id, (unsigned long)esp_random()); snprintf(start, sizeof(start), "{\"v\":1,\"event\":\"start\",\"session_id\":\"cap-%08lx\",\"device_id\":\"%s\",\"audio\":{\"format\":\"pcm_s16le\",\"sample_rate\":16000,\"channels\":1,\"sample_width\":2}}", (unsigned long)esp_random(), ctx->device_id);
if (send_locked(ctx, (const uint8_t*)start, strlen(start), false) != 0 || !open_input_stream(ctx)) { if (send_locked(ctx, (const uint8_t*)start, strlen(start), false) != 0 || !open_input_stream(ctx)) {
set_state(ctx, CAPTION_FAILED, "Fail to connect"); ws_close(ctx->fd); ctx->fd = -1; ctx->worker = NULL; vTaskDelete(NULL); set_state(ctx, CAPTION_FAILED, "Fail to connect"); ws_close(ctx->fd); ctx->fd = -1; ctx->worker = NULL; vTaskDelete(NULL);
} }
@@ -271,26 +278,17 @@ static void worker_task(void* argument) {
for (unsigned i = 0; ctx->session_active && i < 150; ++i) vTaskDelay(pdMS_TO_TICKS(100)); for (unsigned i = 0; ctx->session_active && i < 150; ++i) vTaskDelay(pdMS_TO_TICKS(100));
} }
if (ctx->fd >= 0) { ws_send_close(ctx->fd); ws_close(ctx->fd); ctx->fd = -1; } if (ctx->fd >= 0) { ws_send_close(ctx->fd); ws_close(ctx->fd); ctx->fd = -1; }
if (ctx->socket_failed && ctx->visible) set_state(ctx, CAPTION_FAILED, "Fail to connect"); bool reconnect = ctx->socket_failed && ctx->visible;
else if (ctx->state == CAPTION_PROCESSING) set_state(ctx, CAPTION_IDLE, "No final caption received"); if (reconnect) set_state(ctx, CAPTION_FAILED, "Reconnecting");
else if (ctx->state == CAPTION_PROCESSING) set_state(ctx, CAPTION_IDLE, "");
ctx->worker = NULL; ctx->worker = NULL;
if (reconnect) {
vTaskDelay(pdMS_TO_TICKS(3000));
if (ctx->visible) { ctx->socket_failed = false; ctx->capture_audio = true; xTaskCreate(worker_task, "caption_tx", 8192, ctx, 5, &ctx->worker); }
}
vTaskDelete(NULL); vTaskDelete(NULL);
} }
static void on_start(lv_event_t* event) {
CaptionContext* ctx = lv_event_get_user_data(event);
if (ctx->worker != NULL || ctx->state == CAPTION_LISTENING || ctx->state == CAPTION_PROCESSING) return;
ctx->caption[0] = '\0'; ctx->last_final[0] = '\0'; ctx->socket_failed = false; ctx->stop_requested = false; ctx->capture_audio = true;
xTaskCreate(worker_task, "caption_tx", 8192, ctx, 5, &ctx->worker);
}
static void on_stop(lv_event_t* event) {
CaptionContext* ctx = lv_event_get_user_data(event);
if (ctx->state != CAPTION_LISTENING) return;
ctx->capture_audio = false; ctx->stop_requested = true;
set_state(ctx, CAPTION_PROCESSING, "Final captioning…");
}
static void* create_data(void) { CaptionContext* ctx = calloc(1, sizeof(*ctx)); if (ctx) ctx->fd = -1; return ctx; } static void* create_data(void) { CaptionContext* ctx = calloc(1, sizeof(*ctx)); if (ctx) ctx->fd = -1; return ctx; }
static void destroy_data(void* data) { free(data); } static void destroy_data(void* data) { free(data); }
static void on_create(AppHandle app, void* data) { ((CaptionContext*)data)->app = app; } static void on_create(AppHandle app, void* data) { ((CaptionContext*)data)->app = app; }
@@ -298,19 +296,20 @@ static void on_create(AppHandle app, void* data) { ((CaptionContext*)data)->app
static void on_show(AppHandle app, void* data, lv_obj_t* parent) { static void on_show(AppHandle app, void* data, lv_obj_t* parent) {
CaptionContext* ctx = data; ctx->visible = true; load_config(ctx); find_audio_stream_device(ctx); CaptionContext* ctx = data; ctx->visible = true; load_config(ctx); find_audio_stream_device(ctx);
ctx->socket_lock = xSemaphoreCreateMutex(); ctx->audio_lock = xSemaphoreCreateMutex(); ctx->socket_lock = xSemaphoreCreateMutex(); ctx->audio_lock = xSemaphoreCreateMutex();
tt_lvgl_toolbar_create_for_app(parent, app); lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app);
ctx->state_label = lv_label_create(parent); lv_obj_align(ctx->state_label, LV_ALIGN_TOP_MID, 0, 38); lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
ctx->caption_label = lv_label_create(parent); lv_obj_set_width(ctx->caption_label, lv_pct(88)); ctx->caption_label = lv_label_create(parent);
lv_label_set_long_mode(ctx->caption_label, LV_LABEL_LONG_WRAP); lv_obj_set_style_text_align(ctx->caption_label, LV_TEXT_ALIGN_CENTER, 0); lv_obj_set_width(ctx->caption_label, lv_pct(88));
lv_obj_align(ctx->caption_label, LV_ALIGN_CENTER, 0, -8); lv_label_set_long_mode(ctx->caption_label, LV_LABEL_LONG_WRAP);
ctx->start_button = lv_btn_create(parent); lv_obj_set_size(ctx->start_button, 100, 42); lv_obj_align(ctx->start_button, LV_ALIGN_BOTTOM_LEFT, 22, -18); lv_obj_set_style_text_align(ctx->caption_label, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_t* start_text = lv_label_create(ctx->start_button); lv_label_set_text(start_text, "Start"); lv_obj_center(start_text); lv_obj_align(ctx->caption_label, LV_ALIGN_CENTER, 0, 0);
lv_obj_add_event_cb(ctx->start_button, on_start, LV_EVENT_CLICKED, ctx); ctx->status_label = lv_label_create(parent);
ctx->stop_button = lv_btn_create(parent); lv_obj_set_size(ctx->stop_button, 100, 42); lv_obj_align(ctx->stop_button, LV_ALIGN_BOTTOM_RIGHT, -22, -18); lv_obj_align(ctx->status_label, LV_ALIGN_BOTTOM_MID, 0, -10);
lv_obj_t* stop_text = lv_label_create(ctx->stop_button); lv_label_set_text(stop_text, "Stop"); lv_obj_center(stop_text); ctx->caption[0] = '\0';
lv_obj_add_event_cb(ctx->stop_button, on_stop, LV_EVENT_CLICKED, ctx); if (ctx->stream_dev != NULL && ctx->socket_lock != NULL && ctx->audio_lock != NULL) {
if (ctx->stream_dev == NULL || ctx->socket_lock == NULL || ctx->audio_lock == NULL) set_state(ctx, CAPTION_FAILED, "Audio service unavailable"); ctx->capture_audio = true; ctx->stop_requested = false; ctx->socket_failed = false;
else set_state(ctx, CAPTION_IDLE, "Press Start to caption"); xTaskCreate(worker_task, "caption_tx", 8192, ctx, 5, &ctx->worker);
}
} }
static void on_hide(AppHandle app, void* data) { static void on_hide(AppHandle app, void* data) {
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
cmake_minimum_required(VERSION 3.20)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
if (DEFINED ENV{TACTILITY_SDK_PATH})
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
else()
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
message(WARNING "TACTILITY_SDK_PATH is not set, defaulting to ${TACTILITY_SDK_PATH}")
endif()
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
project(PipecatVoice)
tactility_project(PipecatVoice)
+23 -12
View File
@@ -1,19 +1,30 @@
# PipecatVoice — Tactility native voice companion # Pipecat Voice
Native ESP32 ELF app (like VoiceRecorder/ReynaBot), not a web client. Minimal native Tactility client for the LAN voice-adapter protocol v1. Opening the app immediately connects to `ws://192.168.68.102:8644/api/esp32/voice/ws`, sends the versioned `start` declaration, and continuously streams 16 kHz mono signed-16-bit PCM. There is no Connect button, speaker/transport picker, PTT control, text entry, transcript display, or credential on the device.
- Uses `audio-stream`/`i2s_controller` APIs (see VoiceRecorder) for 16kHz PCM mic + speaker The Mac-hosted adapter owns VAD, STT, Pipecat/Hermes orchestration, TTS, and credentials. It returns metadata followed by a single PCM response frame; the app pauses capture, plays that response at the declared rate through Tactility `i2s0`, then resumes capture. The app only displays connection/streaming/reconnect/configuration state and uses the standard toolbar to exit.
- Uses ReynaBot's `websocket.c/h` + `lwip` for WS transport
- Default gateway: Hermes WS `ws://<mac>:8642/api/esp32/voice/ws` (voice profile, limited tools, imperfect STT)
- Optional second target: Pipecat websocket transport `ws://<mac>:7861` (when server exposes `websocket` via `create_transport`)
Build: ## Configuration
`config.json` in app user data can override the non-secret endpoint and allowed adapter device id:
```json
{"server_url":"ws://192.168.68.102:8644/api/esp32/voice/ws","device_id":"tactility-14c19d1a790"}
``` ```
unset PYTHONPATH; export IDF_PYTHON_ENV_PATH=~/.espressif/python_env/idf5.3_py3.9_env
. ~/esp/esp-idf/export.sh Only `ws://` private-LAN endpoints are accepted; loopback endpoints are rejected. Invalid configuration is a terminal actionable state. Network failures use bounded 1, 2, 4, 8, 16, then 30-second reconnect delays. Protocol and payload violations close the session and reconnect; stale audio is never queued.
export TACTILITY_SDK_PATH=~/Projects/electronics/tactility/tactility/Buildscripts/TactilitySDK # or firmware/release/TactilitySDK if built
## Build and test
```sh
cc -std=c11 -Wall -Wextra -Werror -I main/Source tests/test_voice_protocol.c main/Source/voice_protocol.c -o /tmp/pipecatvoice-protocol-test
/tmp/pipecatvoice-protocol-test
unset PYTHONPATH PYTHONHOME
export IDF_PYTHON_ENV_PATH=/Users/adolforeyna/.espressif/python_env/idf5.3_py3.9_env
source /Users/adolforeyna/esp/esp-idf/export.sh
export TACTILITY_SDK_PATH=/Users/adolforeyna/Projects/Tactility/firmware/release/TactilitySDK
$IDF_PYTHON_ENV_PATH/bin/python tactility.py Apps/PipecatVoice build esp32s3 --local-sdk $IDF_PYTHON_ENV_PATH/bin/python tactility.py Apps/PipecatVoice build esp32s3 --local-sdk
$IDF_PYTHON_ENV_PATH/bin/python tactility.py Apps/PipecatVoice install 192.168.68.112 esp32s3
``` ```
Adapted from ReynaBot (PTT + I2S + WS JSON events: ready/listening/transcript/thinking/response_text/audio_start/audio_end/done/error). The compatible adapter is documented at `/Users/adolforeyna/Projects/voice-assistant/hermes-esp32-voice-gateway/docs/lan-ws-pipecat-adapter.md`. It is the only supported endpoint; Pipecat `:7861` is not an optional device transport.
@@ -0,0 +1,89 @@
# Pipecat SmallWebRTC ESP32 feasibility spike
Date: 2026-08-10
## Decision
**NO-GO for a Tactility external ELF on the current SDK; GO only for a separate, full ESP-IDF firmware application.**
Pipecat has an official native ESP32 client, so the required transport is real and source-proven. It cannot presently be used as a Tactility runtime ELF without a firmware/SDK integration project: the client is a full ESP-IDF firmware with private, static component dependencies and system configuration that the ELF loader does not provide or export. Do not replace the rejected raw-WebSocket implementation with another transport until that integration is designed and proven.
This is not a proposal to flash anything. No device was deployed or flashed during this spike.
## Source-pinned native client
| Item | Evidence |
| --- | --- |
| Client | `https://github.com/pipecat-ai/pipecat-esp32`, commit `e70e3b1f0576e502af9e390434e1a6e8a5cd0d2e`, cloned with all recursive submodules |
| Top-level licence | MIT (`LICENSE`, copyright Daily/OpenAI) |
| Pipecat server | local installed `pipecat-ai 1.7.0`, Python 3.11 virtual environment |
| Server ESP32 support | `SmallWebRTCRequestHandler(..., esp32_mode=True, host=...)` munges the SDP; `smallwebrtc_sdp_munging()` removes SHA-384/SHA-512 fingerprints and retains only the chosen host's ICE candidates |
| ESP-IDF build used | local ESP-IDF `v5.5.2`, environment `idf5.5_py3.9_env` |
| Native build result | upstream `esp32-s3-box-3` built successfully, including `peer`, `srtp`, `esp-libopus`, Wi-Fi, HTTP client, DTLS/SRTP, Opus, and the ESP-BOX-3 BSP |
The official client is designed for ESP32-S3 and uses the `libpeer` API. Its `PeerConfiguration` sets `CODEC_OPUS`, creates a peer connection, installs ICE/data/audio callbacks, and invokes `peer_connection_create_offer()`. This is the source-proven native WebRTC implementation; it owns ICE, DTLS-SRTP, RTP, and Opus rather than hand-implementing any of them.
## Required SmallWebRTC contract
The product transport is SmallWebRTC HTTP signaling plus WebRTC media, never the old raw WebSocket PCM/JSON protocol:
1. `POST /start` with `transport: "webrtc"`, `enableDefaultIceServers: false`, and optional `body`; retain the returned `sessionId`.
2. `POST /sessions/{sessionId}/api/offer` with `{ "sdp": ..., "type": "offer", "pc_id": optional, "restart_pc": optional, "requestData": optional }`; Pipecat returns SDP answer, type, and `pc_id`.
3. `PATCH /sessions/{sessionId}/api/offer` with `{ "pc_id": ..., "candidates": [{ "candidate": ..., "sdp_mid": ..., "sdp_mline_index": ... }] }` for trickle ICE. An empty candidate is the end-of-candidates marker.
4. Use the negotiated WebRTC audio track continuously. The runner starts the bot after the offer is processed.
The local Pipecat source also supports the direct `/api/offer` route used by the current official ESP32 example. The session form above is the approved application contract because it supports Pipecat runner session lifecycle. The live endpoint returned HTTP 200 to `/status`, but it was not restarted with `--esp32`; therefore no live offer/candidate exchange is represented as ESP32 validation.
## Audio adapter boundary
The official client source (`media.cpp`) uses 16 kHz, mono, signed 16-bit PCM (`640` bytes = 320 samples = 20 ms) and encodes it as Opus for `peer_connection_send_audio()`. Inbound WebRTC audio reaches the `onaudiotrack` callback as Opus, is decoded to the same PCM shape, and is written to the speaker codec.
For a future firmware-level integration, Tactility must keep ownership at the following boundary (no hard-coded board pins):
- acquire the existing Tactility `audio_stream` / `i2s_controller` service;
- pull fixed 20 ms frames, 16 kHz mono S16LE, into the native client encoder;
- feed decoded remote S16LE frames to the existing output service;
- serialize I/O ownership, keep bounded queues, and drop stale audio rather than accumulating latency;
- close peer/media callbacks before releasing the audio device.
The current kernel exports `audio_stream_open_input`, `audio_stream_open_output`, `audio_stream_read`, `audio_stream_write`, `audio_stream_close`, and `i2s_controller_read`/`i2s_controller_write`. Those APIs are the usable boundary, not a reason to configure physical pins in the app.
## Full-firmware build evidence
The following was run in a temporary checkout; non-secret placeholder Wi-Fi values were used and no flash command was run:
```text
cd /tmp/pipecat-esp32-spike
# cloned pipecat-esp32 at e70e3b1... and initialized all recursive submodules
cd esp32-s3-box-3
unset PYTHONPATH PYTHONHOME
export IDF_PYTHON_ENV_PATH=/Users/adolforeyna/.espressif/python_env/idf5.5_py3.9_env
export WIFI_SSID=spike
export WIFI_PASSWORD=spike
export PIPECAT_SMALLWEBRTC_URL=http://192.168.68.112:7860/api/offer
source /Users/adolforeyna/esp/esp-idf/export.sh
idf.py build
```
Actual result: `src.elf` and `src.bin` were produced; the IDF build ended with `Project build complete`. `src.bin` is **1,493,408 bytes** and the upstream 1.5 MiB app partition reported **79,712 bytes (5%) free**. `xtensa-esp32s3-elf-size src.elf` reported text `1,304,360`, data `201,012`, bss `2,863,205` (total `4,368,577`). The linked firmware has no undefined dynamic symbols.
This is important capacity evidence: even before adapting it to the target board and Tactility services, the supported client nearly fills its own dedicated application partition and has a 2.86 MiB BSS footprint.
## Why this does not link as a Tactility ELF
Tactility's `TactilitySDK.cmake` calls `project_elf()`. Its loader CMake builds a PIC shared ELF with `-nostartfiles -nostdlib -shared -e app_main`, and links only `main` plus explicitly listed `ELF_COMPONENTS` / `ELF_LIBS`. The current PipecatVoice component declares only `REQUIRES TactilitySDK lwip`.
The upstream client instead requires full firmware components including `peer`, `srtp`, `esp-libopus`, `esp_http_client`, `esp_wifi`, `nvs_flash`, `esp_psram`, `esp_netif`, mbedTLS, and ESP-BOX-3 BSP. The official `peer` static archive has unresolved references to the linked firmware environment such as `mbedtls_ssl_conf_dtls_srtp_protection_profiles`, `mbedtls_ssl_config_defaults`, `lwip_inet_ntop`, and socket/ICE helpers. The Tactility kernel export table contains the audio service APIs listed above but no `peer_connection`, `opus_*`, `srtp_*`, `mbedtls_*`, `esp_http_client*`, `esp_wifi*`, or `esp_netif*` exports.
Attempting the ordinary app build also hit a concrete local SDK packaging blocker before linking: `tactility.py Apps/PipecatVoice build esp32s3 --local-sdk` reported that `Buildscripts/TactilitySDK/0.8.0-dev-esp32s3/TactilitySDK` is missing. This must be corrected for later normal ELF builds, but it is distinct from the component/loader incompatibility.
Therefore copying the client sources or merely adding `REQUIRES peer` would not make a runnable ELF: it would either fail to find the private IDF component libraries during the ELF link or produce imports that the firmware loader cannot resolve. Statically embedding all dependencies is unproven and high-risk because of ELF size, duplicate runtime/library state, SDK configuration, and Wi-Fi/codec ownership conflicts.
## Security, licensing, and follow-up gate
- Do not log SDP, ICE details, credentials, or raw audio. The source-level HTTP helper currently logs offer/answer in debug mode; any reused code must remove that logging.
- The upstream defaults deliberately disable TLS certificate verification for its demo. Production must use HTTPS with a pinned/validated trust chain; do not inherit that setting.
- Preserve MIT notices for Pipecat ESP32 and audit each pinned submodule separately (`libpeer`, SRTP/libSRTP, Opus, and Espressif managed components have their own licenses).
- Do not use the existing PipecatVoice raw WebSocket code or its historical configuration as a fallback; it is protocol-incompatible with SmallWebRTC.
A firmware-level project must first export/package the required WebRTC dependency set, prove an external ELF link with zero unresolved loader symbols (or move the client into firmware), set deterministic memory budgets, and then perform an `--esp32` SmallWebRTC live offer/ICE/media test. Only after that gate may the approved minimal auto-start UI be implemented.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,113 @@
#include "voice_protocol.h"
#include <stdio.h>
#include <string.h>
/* NOTE: Do not use ctype.h (isalnum/isalpha/isdigit/isspace) in this app. Those
* functions read the `_ctype_` table, which is resolved from the flashed firmware
* at runtime; the firmware's table does not behave correctly for side-loaded ELF
* apps, so isalnum('a') can return false. Use explicit ASCII range checks instead. */
static bool is_digit(unsigned char c) { return c >= '0' && c <= '9'; }
static bool is_space(unsigned char c) {
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\v' || c == '\f';
}
static bool is_alnum(unsigned char c) {
return is_digit(c) || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
}
static bool copy_part(char* destination, size_t destination_size, const char* start, size_t length) {
if (length == 0 || length >= destination_size) return false;
memcpy(destination, start, length);
destination[length] = '\0';
return true;
}
static bool is_loopback(const char* host) {
return strcmp(host, "localhost") == 0 || strcmp(host, "::1") == 0 || strncmp(host, "127.", 4) == 0;
}
static bool valid_identifier(const char* value) {
if (value == NULL || *value == '\0') return false;
for (const unsigned char* p = (const unsigned char*)value; *p; ++p) {
if (!is_alnum(*p) && *p != '-' && *p != '_' && *p != '.') return false;
}
return true;
}
bool pv_parse_endpoint(const char* url, PvEndpoint* endpoint) {
if (url == NULL || endpoint == NULL || strncmp(url, "ws://", 5) != 0) return false;
const char* authority = url + 5;
const char* path = strchr(authority, '/');
const char* authority_end = path ? path : authority + strlen(authority);
const char* colon = NULL;
for (const char* p = authority; p < authority_end; ++p) {
if (*p == ':') {
if (colon != NULL) return false;
colon = p;
}
if (is_space((unsigned char)*p) || *p == '@' || *p == '?' || *p == '#') return false;
}
size_t host_length = (size_t)((colon ? colon : authority_end) - authority);
if (!copy_part(endpoint->host, sizeof(endpoint->host), authority, host_length) || is_loopback(endpoint->host)) return false;
endpoint->port = 80;
if (colon != NULL) {
unsigned long port = 0;
for (const char* p = colon + 1; p < authority_end; ++p) {
if (!is_digit((unsigned char)*p)) return false;
port = port * 10U + (unsigned long)(*p - '0');
if (port > 65535U) return false;
}
if (port == 0) return false;
endpoint->port = (uint16_t)port;
}
return path == NULL ? copy_part(endpoint->path, sizeof(endpoint->path), "/", 1)
: copy_part(endpoint->path, sizeof(endpoint->path), path, strlen(path));
}
bool pv_make_start_json(char* out, size_t out_size, const char* session_id, const char* device_id) {
if (out == NULL || !valid_identifier(session_id) || !valid_identifier(device_id)) return false;
int written = snprintf(out, out_size,
"{\"v\":1,\"event\":\"start\",\"session_id\":\"%s\",\"device_id\":\"%s\",\"audio\":{\"format\":\"pcm_s16le\",\"sample_rate\":16000,\"channels\":1,\"sample_width\":2}}",
session_id, device_id);
return written > 0 && (size_t)written < out_size;
}
bool pv_valid_pcm_chunk(size_t bytes) {
return bytes > 0 && bytes <= PV_PCM_CHUNK_MAX && (bytes % 2U) == 0;
}
bool pv_valid_downstream_audio(const char* format, int sample_rate, int channels, int sample_width, size_t byte_length) {
return format != NULL && strcmp(format, "pcm_s16le") == 0 && sample_rate > 0 && sample_rate <= 48000 &&
channels == 1 && sample_width == 2 && byte_length > 0 && byte_length <= PV_DOWNSTREAM_MAX &&
(byte_length % 2U) == 0;
}
bool pv_binary_matches_metadata(size_t expected_bytes, size_t received_bytes) {
return expected_bytes > 0 && expected_bytes == received_bytes;
}
uint32_t pv_retry_delay_seconds(unsigned attempt) {
uint32_t delay = 1;
while (attempt > 0 && delay < 30) {
delay *= 2;
--attempt;
}
return delay > 30 ? 30 : delay;
}
PvState pv_disconnect_state(bool endpoint_valid) {
return endpoint_valid ? PV_RECONNECTING : PV_FAILED;
}
const char* pv_state_label(PvState state) {
switch (state) {
case PV_CONNECTING: return "CONNECTING";
case PV_STREAMING: return "STREAMING";
case PV_RECONNECTING: return "RECONNECTING";
case PV_FAILED: return "FAILED";
default: return "FAILED";
}
}
@@ -0,0 +1,31 @@
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#define PV_PROTOCOL_VERSION 1
#define PV_PCM_CHUNK_MAX 16384U
#define PV_DOWNSTREAM_MAX 65536U
typedef enum {
PV_CONNECTING,
PV_STREAMING,
PV_RECONNECTING,
PV_FAILED,
} PvState;
typedef struct {
char host[64];
char path[96];
uint16_t port;
} PvEndpoint;
bool pv_parse_endpoint(const char* url, PvEndpoint* endpoint);
bool pv_make_start_json(char* out, size_t out_size, const char* session_id, const char* device_id);
bool pv_valid_pcm_chunk(size_t bytes);
bool pv_valid_downstream_audio(const char* format, int sample_rate, int channels, int sample_width, size_t byte_length);
bool pv_binary_matches_metadata(size_t expected_bytes, size_t received_bytes);
uint32_t pv_retry_delay_seconds(unsigned attempt);
PvState pv_disconnect_state(bool endpoint_valid);
const char* pv_state_label(PvState state);
+129 -202
View File
@@ -2,239 +2,166 @@
#include <string.h> #include <string.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h>
#include <unistd.h> #include <unistd.h>
#include <lwip/sockets.h>
#include <lwip/inet.h>
#include <esp_log.h> #include <esp_log.h>
#include <esp_random.h>
#include <lwip/inet.h>
#include <lwip/sockets.h>
static int recv_all(int fd, void* buf, size_t len) { #define TAG "PipecatVoiceWs"
size_t total = 0; #define WS_HEADER_LIMIT 1024U
char* p = (char*)buf; #define WS_CONTROL_LIMIT 125U
while (total < len) {
int r = lwip_recv(fd, p + total, len - total, 0); static int send_all(int fd, const uint8_t* data, size_t length) {
if (r <= 0) { size_t sent = 0;
return -1; while (sent < length) {
} int result = lwip_send(fd, data + sent, length - sent, 0);
total += r; if (result <= 0) return -1;
sent += (size_t)result;
} }
return 0; return 0;
} }
static uint16_t my_htons(uint16_t val) { static int recv_all(int fd, uint8_t* data, size_t length) {
return (uint16_t)(((val & 0xff) << 8) | ((val & 0xff00) >> 8)); size_t received = 0;
while (received < length) {
int result = lwip_recv(fd, data + received, length - received, 0);
if (result <= 0) return -1;
received += (size_t)result;
}
return 0;
} }
int ws_connect(const char* host, int port, const char* path, const char* device_id, const char* auth_key) { static int discard(int fd, uint64_t length) {
uint8_t buffer[256];
while (length > 0) {
size_t chunk = length > sizeof(buffer) ? sizeof(buffer) : (size_t)length;
if (recv_all(fd, buffer, chunk) < 0) return -1;
length -= chunk;
}
return 0;
}
static int send_frame(int fd, uint8_t opcode, const uint8_t* payload, size_t length) {
if (length > 65535U || ((opcode & 0x08U) && length > WS_CONTROL_LIMIT)) return -1;
uint8_t header[8];
size_t header_length = 2;
header[0] = 0x80U | opcode;
if (length < 126U) {
header[1] = 0x80U | (uint8_t)length;
} else {
header[1] = 0x80U | 126U;
header[2] = (uint8_t)(length >> 8U);
header[3] = (uint8_t)length;
header_length = 4;
}
uint8_t mask[4];
uint32_t random = esp_random();
memcpy(mask, &random, sizeof(mask));
memcpy(header + header_length, mask, sizeof(mask));
header_length += sizeof(mask);
if (send_all(fd, header, header_length) < 0) return -1;
uint8_t chunk[512];
size_t offset = 0;
while (offset < length) {
size_t count = length - offset > sizeof(chunk) ? sizeof(chunk) : length - offset;
for (size_t i = 0; i < count; ++i) chunk[i] = payload[offset + i] ^ mask[(offset + i) % sizeof(mask)];
if (send_all(fd, chunk, count) < 0) return -1;
offset += count;
}
return 0;
}
int ws_connect(const char* host, int port, const char* path, const char* device_id, const char* api_key) {
if (host == NULL || path == NULL || device_id == NULL || api_key == NULL || port < 1 || port > 65535) return -1;
int fd = lwip_socket(AF_INET, SOCK_STREAM, 0); int fd = lwip_socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) return -1; if (fd < 0) {
ESP_LOGW(TAG, "socket create failed");
struct sockaddr_in addr; return -1;
memset(&addr, 0, sizeof(addr)); }
addr.sin_family = AF_INET; struct sockaddr_in address = {0};
addr.sin_port = my_htons(port); address.sin_family = AF_INET;
addr.sin_addr.s_addr = ipaddr_addr(host); address.sin_port = htons((uint16_t)port);
address.sin_addr.s_addr = ipaddr_addr(host);
if (lwip_connect(fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) { if (address.sin_addr.s_addr == IPADDR_NONE) {
ESP_LOGW(TAG, "endpoint address parse failed");
close(fd); close(fd);
return -1; return -1;
} }
if (lwip_connect(fd, (struct sockaddr*)&address, sizeof(address)) < 0) {
// Set socket receive timeout (e.g. 90 seconds) to prevent blocking indefinitely ESP_LOGW(TAG, "TCP connect failed");
struct timeval tv;
tv.tv_sec = 90;
tv.tv_usec = 0;
lwip_setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
// Send HTTP upgrade handshake request
char req[1024];
snprintf(req, sizeof(req),
"GET %s HTTP/1.1\r\n"
"Host: %s:%d\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
"Sec-WebSocket-Version: 13\r\n"
"Authorization: Bearer %s\r\n"
"X-Device-ID: %s\r\n"
"\r\n",
path, host, port, auth_key, device_id);
if (lwip_send(fd, req, strlen(req), 0) < 0) {
close(fd); close(fd);
return -1; return -1;
} }
struct timeval timeout = {.tv_sec = 15, .tv_usec = 0};
// Read HTTP response headers until we hit "\r\n\r\n" lwip_setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
char header_buf[1024]; char request[WS_HEADER_LIMIT];
size_t header_len = 0; int request_length = snprintf(request, sizeof(request),
while (header_len < sizeof(header_buf) - 1) { "GET %s HTTP/1.1\r\nHost: %s:%d\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
char c; "Sec-WebSocket-Key: MDEyMzQ1Njc4OWFiY2RlZg==\r\nSec-WebSocket-Version: 13\r\n"
int r = lwip_recv(fd, &c, 1, 0); "Authorization: Bearer %s\r\nX-Device-ID: %s\r\n\r\n",
if (r <= 0) { path, host, port, api_key, device_id);
if (request_length < 0 || (size_t)request_length >= sizeof(request) || send_all(fd, (const uint8_t*)request, (size_t)request_length) < 0) {
ESP_LOGW(TAG, "WebSocket upgrade request failed");
close(fd);
return -1;
}
char response[WS_HEADER_LIMIT];
size_t length = 0;
while (length + 1 < sizeof(response)) {
if (recv_all(fd, (uint8_t*)&response[length], 1) < 0) {
ESP_LOGW(TAG, "WebSocket upgrade response failed");
close(fd); close(fd);
return -1; return -1;
} }
header_buf[header_len++] = c; response[++length] = '\0';
header_buf[header_len] = '\0'; if (length >= 4 && memcmp(response + length - 4, "\r\n\r\n", 4) == 0) break;
if (header_len >= 4 && strcmp(header_buf + header_len - 4, "\r\n\r\n") == 0) {
break;
}
} }
if (length + 1 >= sizeof(response) || strstr(response, " 101 ") == NULL) {
// Verify HTTP 101 Switching Protocols response status ESP_LOGW(TAG, "WebSocket upgrade rejected");
if (strstr(header_buf, "HTTP/1.1 101") == NULL && strstr(header_buf, "HTTP/1.0 101") == NULL) {
close(fd); close(fd);
return -1; return -1;
} }
ESP_LOGI(TAG, "WebSocket upgrade accepted");
return fd; return fd;
} }
int ws_send(int fd, const uint8_t* data, size_t len, bool binary) { int ws_send(int fd, const uint8_t* data, size_t length, bool binary) {
uint8_t header[10]; if (fd < 0 || data == NULL || length == 0) return -1;
size_t header_len = 0; return send_frame(fd, binary ? 0x02U : 0x01U, data, length);
header[0] = binary ? 0x82 : 0x81;
if (len < 126) {
header[1] = 0x80 | (uint8_t)len;
header_len = 2;
} else {
header[1] = 0x80 | 126;
header[2] = (uint8_t)((len >> 8) & 0xFF);
header[3] = (uint8_t)(len & 0xFF);
header_len = 4;
}
// Use fixed client mask for performance: 0x12, 0x34, 0x56, 0x78
uint8_t mask[4] = { 0x12, 0x34, 0x56, 0x78 };
memcpy(header + header_len, mask, 4);
header_len += 4;
// Send WebSocket frame header
int sent = lwip_send(fd, header, header_len, 0);
if (sent < 0) return -1;
// Mask the payload
uint8_t* masked = malloc(len);
if (masked == NULL) return -1;
for (size_t i = 0; i < len; ++i) {
masked[i] = data[i] ^ mask[i % 4];
}
// Send masked payload
sent = lwip_send(fd, masked, len, 0);
free(masked);
return sent >= 0 ? 0 : -1;
} }
int ws_recv(int fd, int* out_opcode, uint8_t* payload, size_t max_len) { int ws_recv(int fd, int* opcode, bool* final, uint8_t* payload, size_t maximum) {
uint8_t header[2]; uint8_t header[2];
if (recv_all(fd, header, 2) < 0) { if (fd < 0 || recv_all(fd, header, sizeof(header)) < 0) return -1;
return -1; uint64_t length = header[1] & 0x7fU;
if (length == 126U) {
uint8_t extended[2];
if (recv_all(fd, extended, sizeof(extended)) < 0) return -1;
length = ((uint64_t)extended[0] << 8U) | extended[1];
} else if (length == 127U) {
uint8_t extended[8];
if (recv_all(fd, extended, sizeof(extended)) < 0) return -1;
length = 0;
for (size_t i = 0; i < sizeof(extended); ++i) length = (length << 8U) | extended[i];
} }
bool masked = (header[1] & 0x80U) != 0;
int opcode = header[0] & 0x0F; uint8_t mask[4] = {0};
if (out_opcode != NULL) { if (masked && recv_all(fd, mask, sizeof(mask)) < 0) return -1;
*out_opcode = opcode; uint8_t frame_opcode = header[0] & 0x0fU;
if (((frame_opcode & 0x08U) && (length > WS_CONTROL_LIMIT || !(header[0] & 0x80U))) || length > maximum) {
if (discard(fd, length) < 0) return -1;
return -2;
} }
if (length > 0 && recv_all(fd, payload, (size_t)length) < 0) return -1;
int masked = (header[1] & 0x80) != 0; if (masked) for (size_t i = 0; i < (size_t)length; ++i) payload[i] ^= mask[i % sizeof(mask)];
size_t len = header[1] & 0x7F; if (opcode) *opcode = frame_opcode;
if (final) *final = (header[0] & 0x80U) != 0;
if (len == 126) { return (int)length;
uint8_t ext_len[2];
if (recv_all(fd, ext_len, 2) < 0) return -1;
len = ((size_t)ext_len[0] << 8) | ext_len[1];
} else if (len == 127) {
uint8_t ext_len[8];
if (recv_all(fd, ext_len, 8) < 0) return -1;
// Parse 64-bit length into size_t
len = ((size_t)ext_len[4] << 24) | ((size_t)ext_len[5] << 16) | ((size_t)ext_len[6] << 8) | ext_len[7];
}
if (masked) {
uint8_t mask[4];
if (recv_all(fd, mask, 4) < 0) return -1;
if (len > max_len) {
ESP_LOGE("websocket", "ws_recv overflow (masked): len=%u, max_len=%u", (unsigned)len, (unsigned)max_len);
// Buffer overflow, skip payload to align stream
size_t to_discard = len;
uint8_t discard_buf[256];
while (to_discard > 0) {
size_t chunk = to_discard < sizeof(discard_buf) ? to_discard : sizeof(discard_buf);
if (recv_all(fd, discard_buf, chunk) < 0) return -1;
to_discard -= chunk;
}
return -2;
}
if (recv_all(fd, payload, len) < 0) return -1;
for (size_t i = 0; i < len; ++i) {
payload[i] ^= mask[i % 4];
}
} else {
if (len > max_len) {
ESP_LOGE("websocket", "ws_recv overflow (unmasked): len=%u, max_len=%u", (unsigned)len, (unsigned)max_len);
size_t to_discard = len;
uint8_t discard_buf[256];
while (to_discard > 0) {
size_t chunk = to_discard < sizeof(discard_buf) ? to_discard : sizeof(discard_buf);
if (recv_all(fd, discard_buf, chunk) < 0) return -1;
to_discard -= chunk;
}
return -2;
}
if (recv_all(fd, payload, len) < 0) return -1;
}
return (int)len;
} }
void ws_close(int fd) { int ws_send_pong(int fd, const uint8_t* payload, size_t length) { return send_frame(fd, 0x0aU, payload, length); }
if (fd >= 0) { int ws_send_close(int fd) { return send_frame(fd, 0x08U, NULL, 0); }
close(fd); void ws_close(int fd) { if (fd >= 0) close(fd); }
}
}
int ws_send_pong(int fd, const uint8_t* payload, size_t len) {
uint8_t header[10];
size_t header_len = 0;
header[0] = 0x8A; // FIN | PONG (0x0A)
if (len < 126) {
header[1] = 0x80 | (uint8_t)len;
header_len = 2;
} else {
header[1] = 0x80 | 126;
header[2] = (uint8_t)((len >> 8) & 0xFF);
header[3] = (uint8_t)(len & 0xFF);
header_len = 4;
}
uint8_t mask[4] = { 0x12, 0x34, 0x56, 0x78 };
memcpy(header + header_len, mask, 4);
header_len += 4;
int sent = lwip_send(fd, header, header_len, 0);
if (sent < 0) return -1;
if (len > 0 && payload != NULL) {
uint8_t* masked = malloc(len);
if (masked == NULL) return -1;
for (size_t i = 0; i < len; ++i) {
masked[i] = payload[i] ^ mask[i % 4];
}
sent = lwip_send(fd, masked, len, 0);
free(masked);
}
return sent >= 0 ? 0 : -1;
}
+6 -3
View File
@@ -14,10 +14,10 @@ extern "C" {
* @param port Port number (e.g. 8642) * @param port Port number (e.g. 8642)
* @param path WebSocket path (e.g. "/api/esp32/voice/ws") * @param path WebSocket path (e.g. "/api/esp32/voice/ws")
* @param device_id Unique device identifier * @param device_id Unique device identifier
* @param auth_key Hermes Bearer API key * @param api_key Optional profile API key; never compiled into firmware
* @return Socket file descriptor on success, or -1 on failure * @return Socket file descriptor on success, or -1 on failure
*/ */
int ws_connect(const char* host, int port, const char* path, const char* device_id, const char* auth_key); int ws_connect(const char* host, int port, const char* path, const char* device_id, const char* api_key);
/** /**
* Send a WebSocket frame. * Send a WebSocket frame.
@@ -37,7 +37,7 @@ int ws_send(int fd, const uint8_t* data, size_t len, bool binary);
* @param max_len Maximum length of the payload buffer * @param max_len Maximum length of the payload buffer
* @return Received payload length on success, -1 on connection failure, or -2 on buffer overflow * @return Received payload length on success, -1 on connection failure, or -2 on buffer overflow
*/ */
int ws_recv(int fd, int* out_opcode, uint8_t* payload, size_t max_len); int ws_recv(int fd, int* out_opcode, bool* out_final, uint8_t* payload, size_t max_len);
/** /**
* Close a WebSocket connection. * Close a WebSocket connection.
@@ -54,6 +54,9 @@ void ws_close(int fd);
*/ */
int ws_send_pong(int fd, const uint8_t* payload, size_t len); int ws_send_pong(int fd, const uint8_t* payload, size_t len);
/** Send a clean WebSocket close control frame before closing the socket. */
int ws_send_close(int fd);
#ifdef __cplusplus #ifdef __cplusplus
} }
#endif #endif
@@ -0,0 +1,52 @@
#include "voice_protocol.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
static void test_endpoint_validation(void) {
PvEndpoint endpoint;
assert(pv_parse_endpoint("ws://192.168.68.102:8644/api/esp32/voice/ws", &endpoint));
assert(strcmp(endpoint.host, "192.168.68.102") == 0);
assert(endpoint.port == 8644);
assert(strcmp(endpoint.path, "/api/esp32/voice/ws") == 0);
assert(!pv_parse_endpoint("ws://127.0.0.1:8642/api", &endpoint));
assert(!pv_parse_endpoint("ws://localhost:8642/api", &endpoint));
assert(!pv_parse_endpoint("wss://192.168.68.102/api", &endpoint));
assert(!pv_parse_endpoint("ws://192.168.68.102:0/api", &endpoint));
}
static void test_start_and_pcm_boundaries(void) {
char json[256];
assert(pv_make_start_json(json, sizeof(json), "session-01", "tactility-14c19d1a790"));
assert(strstr(json, "\"v\":1") != NULL);
assert(strstr(json, "\"pcm_s16le\"") != NULL);
assert(!pv_make_start_json(json, sizeof(json), "bad session", "device"));
assert(pv_valid_pcm_chunk(2));
assert(pv_valid_pcm_chunk(PV_PCM_CHUNK_MAX));
assert(!pv_valid_pcm_chunk(0));
assert(!pv_valid_pcm_chunk(3));
assert(!pv_valid_pcm_chunk(PV_PCM_CHUNK_MAX + 2));
assert(pv_valid_downstream_audio("pcm_s16le", 24000, 1, 2, 48000));
assert(!pv_valid_downstream_audio("wav", 24000, 1, 2, 48000));
assert(!pv_valid_downstream_audio("pcm_s16le", 24000, 2, 2, 48000));
assert(!pv_valid_downstream_audio("pcm_s16le", 24000, 1, 2, PV_DOWNSTREAM_MAX + 2));
assert(pv_binary_matches_metadata(48000, 48000));
assert(!pv_binary_matches_metadata(48000, 47998));
}
static void test_retry_and_state(void) {
const uint32_t expected[] = {1, 2, 4, 8, 16, 30, 30};
for (unsigned i = 0; i < sizeof(expected) / sizeof(expected[0]); ++i) assert(pv_retry_delay_seconds(i) == expected[i]);
assert(pv_disconnect_state(true) == PV_RECONNECTING);
assert(pv_disconnect_state(false) == PV_FAILED);
assert(strcmp(pv_state_label(PV_STREAMING), "STREAMING") == 0);
}
int main(void) {
test_endpoint_validation();
test_start_and_pcm_boundaries();
test_retry_and_state();
puts("voice_protocol tests passed");
return 0;
}
+208 -68
View File
@@ -1,24 +1,32 @@
#include <tt_app.h> #include <tt_app.h>
#include <tt_lvgl.h> #include <tt_lvgl.h>
#include <tt_lvgl_toolbar.h> #include <tt_lvgl_toolbar.h>
#include <tt_wifi.h>
#include <tactility/device.h> #include <tactility/device.h>
#include <tactility/drivers/i2s_controller.h> #include <tactility/drivers/audio_stream.h>
#include "websocket.h" #include "websocket.h"
#include <cJSON.h> #include <cJSON.h>
#include <string.h> #include <string.h>
#include <stdlib.h> #include <stdlib.h>
#include <stdio.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <errno.h> #include <errno.h>
/* Exported by Tactility firmware 0.8.0-dev; absent from the CDN SDK header. */
struct Device* device_find_by_name(const char* name);
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/task.h" #include "freertos/task.h"
#include "esp_log.h" #include "esp_log.h"
#include "esp_random.h"
#define TAG "ReynaBot" #define TAG "ReynaBot"
#define AUDIO_SAMPLE_RATE 16000U
#define AUDIO_BITS_PER_SAMPLE 16U
#define MAX_PCM_CHUNK_BYTES 16384U
#define MAX_RESPONSE_AUDIO_BYTES 65536U
typedef enum { typedef enum {
STATE_IDLE, STATE_IDLE,
@@ -64,10 +72,19 @@ typedef struct {
TaskHandle_t worker_task; TaskHandle_t worker_task;
TaskHandle_t rx_task; TaskHandle_t rx_task;
// Hardware // Hardware/audio service
struct Device* i2s_dev; struct Device* audio_stream_dev;
AudioStreamHandle input_handle;
// WebSocket AudioStreamHandle output_handle;
uint32_t output_sample_rate;
uint8_t output_channels;
uint8_t output_bits_per_sample;
bool output_stream_pending;
size_t expected_audio_bytes;
uint32_t expected_audio_rate;
uint8_t expected_audio_channels;
uint8_t expected_audio_bits;
bool stop_sent;
int ws_fd; int ws_fd;
bool ws_connected; bool ws_connected;
bool ws_done; bool ws_done;
@@ -81,6 +98,77 @@ static void reynabot_rx_task(void* arg);
static void update_ui(ReynaBotCtx* ctx); static void update_ui(ReynaBotCtx* ctx);
static void load_config(ReynaBotCtx* ctx); static void load_config(ReynaBotCtx* ctx);
static bool find_audio_stream_device(ReynaBotCtx* ctx) {
struct Device* dev = device_find_by_name("audio-stream");
if (dev != NULL) {
ctx->audio_stream_dev = dev;
return true;
}
// The 0.8.0-dev firmware registers this shared service by name. The
// CDN SDK headers do not expose the deprecated type-search helper, so do
// not reference it from a dynamically linked app.
return false;
}
static void close_input_stream(ReynaBotCtx* ctx) {
if (ctx->input_handle != NULL) {
audio_stream_close(ctx->input_handle);
ctx->input_handle = NULL;
}
}
static void close_output_stream(ReynaBotCtx* ctx) {
if (ctx->output_handle != NULL) {
audio_stream_close(ctx->output_handle);
ctx->output_handle = NULL;
}
ctx->output_stream_pending = false;
}
static bool open_input_stream(ReynaBotCtx* ctx) {
if (ctx->audio_stream_dev == NULL) return false;
close_input_stream(ctx);
struct AudioStreamConfig config = {
.sample_rate = 16000,
.bits_per_sample = 16,
.channels = 1,
};
error_t err = audio_stream_open_input(ctx->audio_stream_dev, &config, &ctx->input_handle);
if (err != ERROR_NONE) {
ctx->input_handle = NULL;
ESP_LOGE(TAG, "audio_stream_open_input failed: %d", err);
return false;
}
audio_stream_set_mute(ctx->audio_stream_dev, AUDIO_CODEC_DIR_INPUT, false);
audio_stream_set_volume(ctx->audio_stream_dev, AUDIO_CODEC_DIR_INPUT, 100.0f);
ESP_LOGI(TAG, "Microphone opened via audio-stream at 16000 Hz mono");
return true;
}
static bool open_output_stream(ReynaBotCtx* ctx, uint32_t sample_rate, uint8_t channels, uint8_t bits_per_sample) {
if (ctx->audio_stream_dev == NULL || sample_rate == 0 || channels == 0 || bits_per_sample != 16) return false;
close_output_stream(ctx);
struct AudioStreamConfig config = {
.sample_rate = sample_rate,
.bits_per_sample = bits_per_sample,
.channels = channels,
};
error_t err = audio_stream_open_output(ctx->audio_stream_dev, &config, &ctx->output_handle);
if (err != ERROR_NONE) {
ctx->output_handle = NULL;
ESP_LOGE(TAG, "audio_stream_open_output failed: %d rate=%u ch=%u", err, (unsigned)sample_rate, channels);
return false;
}
audio_stream_set_mute(ctx->audio_stream_dev, AUDIO_CODEC_DIR_OUTPUT, false);
audio_stream_set_volume(ctx->audio_stream_dev, AUDIO_CODEC_DIR_OUTPUT, 100.0f);
ctx->output_sample_rate = sample_rate;
ctx->output_channels = channels;
ctx->output_bits_per_sample = bits_per_sample;
ctx->output_stream_pending = true;
ESP_LOGI(TAG, "Speaker opened via audio-stream at %u Hz mono", (unsigned)sample_rate);
return true;
}
/* ─── Helper for URL parsing ─── */ /* ─── Helper for URL parsing ─── */
static bool parse_ws_url(const char* url, char* host, int* port, char* path) { static bool parse_ws_url(const char* url, char* host, int* port, char* path) {
if (strncmp(url, "ws://", 5) != 0) return false; if (strncmp(url, "ws://", 5) != 0) return false;
@@ -111,9 +199,9 @@ static bool parse_ws_url(const char* url, char* host, int* port, char* path) {
/* ─── Config Loading/Saving ─── */ /* ─── Config Loading/Saving ─── */
static void load_config(ReynaBotCtx* ctx) { static void load_config(ReynaBotCtx* ctx) {
// Default fallback config // Default fallback config
snprintf(ctx->server_url, sizeof(ctx->server_url), "ws://192.168.68.126:8643/api/esp32/voice/ws"); snprintf(ctx->server_url, sizeof(ctx->server_url), "ws://192.168.68.112:8643/api/esp32/voice/ws");
snprintf(ctx->device_id, sizeof(ctx->device_id), "reynabot_screen"); snprintf(ctx->device_id, sizeof(ctx->device_id), "reynabot_screen");
snprintf(ctx->api_key, sizeof(ctx->api_key), "hmek_sXB7921bZ9FXTVKARExqUZ7ttBxtEoURHRU0JCB-gNY"); ctx->api_key[0] = '\0';
char path[256]; char path[256];
size_t path_size = sizeof(path); size_t path_size = sizeof(path);
@@ -130,7 +218,7 @@ static void load_config(ReynaBotCtx* ctx) {
// Write default config file // Write default config file
file = fopen(path, "w"); file = fopen(path, "w");
if (file != NULL) { if (file != NULL) {
fprintf(file, "{\n \"server_url\": \"ws://192.168.68.126:8643/api/esp32/voice/ws\",\n \"device_id\": \"reynabot_screen\",\n \"api_key\": \"mcT1YA1vOr9wXSiHpCYalweEGGZKX-PIfZv2drp8BSg\"\n}\n"); fprintf(file, "{\n \"server_url\": \"ws://192.168.68.112:8643/api/esp32/voice/ws\",\n \"device_id\": \"reynabot_screen\",\n \"api_key\": \"\"\n}\n");
fclose(file); fclose(file);
} }
ESP_LOGI(TAG, "Created default config.json at %s", path); ESP_LOGI(TAG, "Created default config.json at %s", path);
@@ -181,6 +269,21 @@ static void parse_json_message(ReynaBotCtx* ctx, const char* json_str) {
if (strcmp(evt, "ready") == 0) { if (strcmp(evt, "ready") == 0) {
ESP_LOGI(TAG, "Server ready"); ESP_LOGI(TAG, "Server ready");
} else if (strcmp(evt, "state") == 0) {
cJSON* state_item = cJSON_GetObjectItem(json, "state");
if (state_item != NULL && cJSON_IsString(state_item)) {
if (strcmp(state_item->valuestring, "listening") == 0) {
if (ctx->stop_sent) {
ctx->ws_done = true;
} else {
ctx->state = STATE_LISTENING;
}
ctx->ui_update_pending = true;
} else if (strcmp(state_item->valuestring, "processing") == 0) {
ctx->state = STATE_THINKING;
ctx->ui_update_pending = true;
}
}
} else if (strcmp(evt, "listening") == 0) { } else if (strcmp(evt, "listening") == 0) {
ctx->state = STATE_LISTENING; ctx->state = STATE_LISTENING;
ctx->ui_update_pending = true; ctx->ui_update_pending = true;
@@ -199,11 +302,42 @@ static void parse_json_message(ReynaBotCtx* ctx, const char* json_str) {
strncpy(ctx->last_response, txt_item->valuestring, sizeof(ctx->last_response) - 1); strncpy(ctx->last_response, txt_item->valuestring, sizeof(ctx->last_response) - 1);
ctx->ui_update_pending = true; ctx->ui_update_pending = true;
} }
} else if (strcmp(evt, "audio") == 0) {
cJSON* format_item = cJSON_GetObjectItem(json, "format");
cJSON* rate_item = cJSON_GetObjectItem(json, "sample_rate");
cJSON* channels_item = cJSON_GetObjectItem(json, "channels");
cJSON* width_item = cJSON_GetObjectItem(json, "sample_width");
cJSON* length_item = cJSON_GetObjectItem(json, "byte_length");
bool valid = format_item != NULL && cJSON_IsString(format_item) &&
strcmp(format_item->valuestring, "pcm_s16le") == 0 &&
rate_item != NULL && cJSON_IsNumber(rate_item) && rate_item->valueint > 0 && rate_item->valueint <= 48000 &&
channels_item != NULL && cJSON_IsNumber(channels_item) && channels_item->valueint == 1 &&
width_item != NULL && cJSON_IsNumber(width_item) && width_item->valueint == 2 &&
length_item != NULL && cJSON_IsNumber(length_item) && length_item->valueint > 0 &&
length_item->valueint <= MAX_RESPONSE_AUDIO_BYTES && (length_item->valueint % 2) == 0;
if (valid) {
ctx->expected_audio_rate = (uint32_t)rate_item->valueint;
ctx->expected_audio_channels = (uint8_t)channels_item->valueint;
ctx->expected_audio_bits = (uint8_t)width_item->valueint;
ctx->expected_audio_bytes = (size_t)length_item->valueint;
if (!open_output_stream(ctx, ctx->expected_audio_rate, ctx->expected_audio_channels, ctx->expected_audio_bits)) {
snprintf(ctx->error_message, sizeof(ctx->error_message), "Audio output unavailable");
ctx->state = STATE_ERROR;
ctx->expected_audio_bytes = 0;
} else {
ctx->state = STATE_SPEAKING;
}
ctx->ui_update_pending = true;
} else {
snprintf(ctx->error_message, sizeof(ctx->error_message), "Invalid audio metadata");
ctx->state = STATE_ERROR;
ctx->expected_audio_bytes = 0;
ctx->ui_update_pending = true;
}
} else if (strcmp(evt, "audio_start") == 0) { } else if (strcmp(evt, "audio_start") == 0) {
/* Legacy event: metadata must still arrive as `audio` before binary PCM. */
ctx->state = STATE_SPEAKING; ctx->state = STATE_SPEAKING;
ctx->ui_update_pending = true; ctx->ui_update_pending = true;
// I2S is already configured globally at session startup
} else if (strcmp(evt, "audio_end") == 0) { } else if (strcmp(evt, "audio_end") == 0) {
ESP_LOGI(TAG, "Audio response ended"); ESP_LOGI(TAG, "Audio response ended");
} else if (strcmp(evt, "done") == 0) { } else if (strcmp(evt, "done") == 0) {
@@ -225,7 +359,7 @@ static void parse_json_message(ReynaBotCtx* ctx, const char* json_str) {
/* ─── WebSocket RX (Receive) Task ─── */ /* ─── WebSocket RX (Receive) Task ─── */
static void reynabot_rx_task(void* arg) { static void reynabot_rx_task(void* arg) {
ReynaBotCtx* ctx = (ReynaBotCtx*)arg; ReynaBotCtx* ctx = (ReynaBotCtx*)arg;
uint8_t* rx_buf = malloc(8192); uint8_t* rx_buf = malloc(MAX_RESPONSE_AUDIO_BYTES + 1U);
if (rx_buf == NULL) { if (rx_buf == NULL) {
ESP_LOGE(TAG, "Failed to allocate RX buffer"); ESP_LOGE(TAG, "Failed to allocate RX buffer");
ctx->ws_done = true; ctx->ws_done = true;
@@ -238,7 +372,7 @@ static void reynabot_rx_task(void* arg) {
ESP_LOGI(TAG, "WS Receive task started"); ESP_LOGI(TAG, "WS Receive task started");
while (ctx->ws_fd >= 0) { while (ctx->ws_fd >= 0) {
int r = ws_recv(ctx->ws_fd, &opcode, rx_buf, 8191); int r = ws_recv(ctx->ws_fd, &opcode, rx_buf, MAX_RESPONSE_AUDIO_BYTES);
if (r < 0) { if (r < 0) {
if (r == -1) { if (r == -1) {
ESP_LOGI(TAG, "WS connection closed or read error, errno=%d", errno); ESP_LOGI(TAG, "WS connection closed or read error, errno=%d", errno);
@@ -252,21 +386,31 @@ static void reynabot_rx_task(void* arg) {
if (opcode == 0x01) { // Text frame (JSON) if (opcode == 0x01) { // Text frame (JSON)
rx_buf[r] = '\0'; rx_buf[r] = '\0';
parse_json_message(ctx, (char*)rx_buf); parse_json_message(ctx, (char*)rx_buf);
} else if (opcode == 0x02) { // Binary frame (Audio data) } else if (opcode == 0x02) { // Audio response PCM
if (ctx->state == STATE_SPEAKING && ctx->i2s_dev != NULL) { if (ctx->output_handle == NULL || ctx->expected_audio_bytes == 0 || (size_t)r != ctx->expected_audio_bytes) {
const uint8_t* payload_ptr = rx_buf; ESP_LOGE(TAG, "Audio frame does not match metadata: got=%d expected=%u", r, (unsigned)ctx->expected_audio_bytes);
size_t payload_len = r; ctx->state = STATE_ERROR;
snprintf(ctx->error_message, sizeof(ctx->error_message), "Invalid audio frame");
// Skip WAV header if present in the first chunk ctx->ui_update_pending = true;
if (payload_len > 44 && memcmp(payload_ptr, "RIFF", 4) == 0) { } else {
payload_ptr += 44; size_t written_total = 0;
payload_len -= 44; while (written_total < (size_t)r) {
size_t written = 0;
error_t err = audio_stream_write(ctx->output_handle, rx_buf + written_total,
(size_t)r - written_total, &written, pdMS_TO_TICKS(3000));
if (err != ERROR_NONE || written == 0) {
ESP_LOGE(TAG, "audio_stream_write failed: %d written=%u", err, (unsigned)written);
ctx->state = STATE_ERROR;
snprintf(ctx->error_message, sizeof(ctx->error_message), "Audio playback failed");
ctx->ui_update_pending = true;
break;
}
written_total += written;
} }
close_output_stream(ctx);
size_t written = 0; ctx->expected_audio_bytes = 0;
device_lock(ctx->i2s_dev); ctx->state = STATE_THINKING;
i2s_controller_write(ctx->i2s_dev, payload_ptr, payload_len, &written, pdMS_TO_TICKS(100)); ctx->ui_update_pending = true;
device_unlock(ctx->i2s_dev);
} }
} else if (opcode == 0x09) { // PING frame } else if (opcode == 0x09) { // PING frame
ESP_LOGI(TAG, "WS PING received, sending PONG"); ESP_LOGI(TAG, "WS PING received, sending PONG");
@@ -408,8 +552,11 @@ static void reynabot_task(void* arg) {
ctx->start_session = false; ctx->start_session = false;
ctx->stop_session = false; ctx->stop_session = false;
ctx->cancel_session = false; ctx->cancel_session = false;
ctx->stop_sent = false;
ctx->expected_audio_bytes = 0;
close_input_stream(ctx);
close_output_stream(ctx);
ctx->state = STATE_CONNECTING;
ctx->last_transcript[0] = '\0'; ctx->last_transcript[0] = '\0';
ctx->last_response[0] = '\0'; ctx->last_response[0] = '\0';
ctx->error_message[0] = '\0'; ctx->error_message[0] = '\0';
@@ -435,28 +582,17 @@ static void reynabot_task(void* arg) {
ctx->ws_fd = fd; ctx->ws_fd = fd;
ctx->ws_connected = true; ctx->ws_connected = true;
// Configure I2S on demand for this session (16 kHz, 16-bit, mono) // Spawn background RX task to read and parse events.
struct I2sConfig session_cfg = {
.communication_format = I2S_FORMAT_STAND_I2S,
.sample_rate = 16000,
.bits_per_sample = 16,
.channel_left = 0,
.channel_right = I2S_CHANNEL_NONE
};
if (ctx->i2s_dev != NULL) {
device_lock(ctx->i2s_dev);
i2s_controller_set_config(ctx->i2s_dev, &session_cfg);
device_unlock(ctx->i2s_dev);
}
// Spawn background RX task to read and parse events
xTaskCreate(reynabot_rx_task, "reynabot_rx", 4096, ctx, 6, &ctx->rx_task); xTaskCreate(reynabot_rx_task, "reynabot_rx", 4096, ctx, 6, &ctx->rx_task);
// Send start event handshake // The Kids LAN adapter requires protocol v1 and a fresh session id.
char start_json[256]; char session_id[80];
snprintf(session_id, sizeof(session_id), "reynabot-%08x%08x",
(unsigned)esp_random(), (unsigned)esp_random());
char start_json[384];
snprintf(start_json, sizeof(start_json), snprintf(start_json, sizeof(start_json),
"{\"event\":\"start\",\"device_id\":\"%s\",\"sample_rate\":16000,\"channels\":1,\"sample_width\":2,\"format\":\"pcm_s16le\"}", "{\"v\":1,\"event\":\"start\",\"session_id\":\"%s\",\"device_id\":\"%s\",\"audio\":{\"format\":\"pcm_s16le\",\"sample_rate\":16000,\"channels\":1,\"sample_width\":2}}",
ctx->device_id); session_id, ctx->device_id);
if (ws_send(ctx->ws_fd, (const uint8_t*)start_json, strlen(start_json), false) < 0) { if (ws_send(ctx->ws_fd, (const uint8_t*)start_json, strlen(start_json), false) < 0) {
ctx->state = STATE_ERROR; ctx->state = STATE_ERROR;
snprintf(ctx->error_message, sizeof(ctx->error_message), "Handshake send failed"); snprintf(ctx->error_message, sizeof(ctx->error_message), "Handshake send failed");
@@ -486,11 +622,20 @@ static void reynabot_task(void* arg) {
continue; continue;
} }
// I2S is already configured globally at session startup if (!open_input_stream(ctx)) {
ctx->state = STATE_ERROR;
snprintf(ctx->error_message, sizeof(ctx->error_message), "Microphone unavailable");
ws_close(ctx->ws_fd);
ctx->ws_fd = -1;
ctx->ws_connected = false;
update_ui(ctx);
continue;
}
uint8_t* buffer = malloc(1024); uint8_t* buffer = malloc(MAX_PCM_CHUNK_BYTES);
if (buffer == NULL) { if (buffer == NULL) {
ESP_LOGE(TAG, "Failed to allocate record buffer"); ESP_LOGE(TAG, "Failed to allocate record buffer");
close_input_stream(ctx);
ctx->state = STATE_ERROR; ctx->state = STATE_ERROR;
snprintf(ctx->error_message, sizeof(ctx->error_message), "Out of memory"); snprintf(ctx->error_message, sizeof(ctx->error_message), "Out of memory");
ws_close(ctx->ws_fd); ws_close(ctx->ws_fd);
@@ -501,11 +646,12 @@ static void reynabot_task(void* arg) {
} }
size_t total_sent_bytes = 0; size_t total_sent_bytes = 0;
// Stream audio loop while PTT is held // Stream 16 kHz mono PCM while PTT is held.
while (ctx->is_pressed && !ctx->stop_session && !ctx->cancel_session && !ctx->ws_done && total_sent_bytes < 320000) { while (ctx->is_pressed && !ctx->stop_session && !ctx->cancel_session && !ctx->ws_done && total_sent_bytes < 320000) {
size_t bytes_read = 0; size_t bytes_read = 0;
error_t r = i2s_controller_read(ctx->i2s_dev, buffer, 1024, &bytes_read, pdMS_TO_TICKS(100)); error_t r = audio_stream_read(ctx->input_handle, buffer, MAX_PCM_CHUNK_BYTES,
if (r == ERROR_NONE && bytes_read > 0) { &bytes_read, pdMS_TO_TICKS(200));
if (r == ERROR_NONE && bytes_read > 0 && bytes_read <= MAX_PCM_CHUNK_BYTES && (bytes_read % 2U) == 0) {
if (ws_send(ctx->ws_fd, buffer, bytes_read, true) < 0) { if (ws_send(ctx->ws_fd, buffer, bytes_read, true) < 0) {
ESP_LOGE(TAG, "Audio stream send failed"); ESP_LOGE(TAG, "Audio stream send failed");
break; break;
@@ -514,16 +660,19 @@ static void reynabot_task(void* arg) {
} }
} }
close_input_stream(ctx);
free(buffer); free(buffer);
if (ctx->cancel_session || total_sent_bytes < 3200) { if (ctx->cancel_session || total_sent_bytes < 3200) {
ESP_LOGI(TAG, "Cancelling audio session"); ESP_LOGI(TAG, "Cancelling audio session");
const char* cancel_json = "{\"event\":\"cancel\"}"; const char* cancel_json = "{\"event\":\"cancel\"}";
ws_send(ctx->ws_fd, (const uint8_t*)cancel_json, strlen(cancel_json), false); ws_send(ctx->ws_fd, (const uint8_t*)cancel_json, strlen(cancel_json), false);
ctx->stop_sent = true;
ctx->state = STATE_IDLE; ctx->state = STATE_IDLE;
} else { } else {
const char* stop_json = "{\"event\":\"stop\"}"; const char* stop_json = "{\"event\":\"stop\"}";
ws_send(ctx->ws_fd, (const uint8_t*)stop_json, strlen(stop_json), false); ws_send(ctx->ws_fd, (const uint8_t*)stop_json, strlen(stop_json), false);
ctx->stop_sent = true;
ctx->state = STATE_THINKING; ctx->state = STATE_THINKING;
update_ui(ctx); update_ui(ctx);
@@ -550,12 +699,8 @@ static void reynabot_task(void* arg) {
ctx->ws_connected = false; ctx->ws_connected = false;
ws_close(fd_to_close); ws_close(fd_to_close);
// Reset I2S controller to release DMA and stop white noise close_input_stream(ctx);
if (ctx->i2s_dev != NULL) { close_output_stream(ctx);
device_lock(ctx->i2s_dev);
i2s_controller_reset(ctx->i2s_dev);
device_unlock(ctx->i2s_dev);
}
// Wait for receive task to exit // Wait for receive task to exit
int rx_timeout = 100; int rx_timeout = 100;
@@ -611,10 +756,9 @@ static void on_show(AppHandle app, void* data, lv_obj_t* parent) {
load_config(ctx); load_config(ctx);
// Find I2S controller // Find the firmware Audio System service; it owns codec/native-rate conversion.
ctx->i2s_dev = device_find_by_name("i2s0"); if (!find_audio_stream_device(ctx)) {
if (ctx->i2s_dev == NULL) { ESP_LOGE(TAG, "audio-stream device not found!");
ESP_LOGE(TAG, "I2S controller 'i2s0' not found!");
} }
// Style the parent screen // Style the parent screen
@@ -800,12 +944,8 @@ static void on_hide(AppHandle app, void* data) {
ws_close(fd_to_close); ws_close(fd_to_close);
} }
// Reset I2S controller to stop DMA and looping noise close_input_stream(ctx);
if (ctx->i2s_dev != NULL) { close_output_stream(ctx);
device_lock(ctx->i2s_dev);
i2s_controller_reset(ctx->i2s_dev);
device_unlock(ctx->i2s_dev);
}
// Wait briefly for tasks to exit // Wait briefly for tasks to exit
int timeout = 100; int timeout = 100;
+2 -2
View File
@@ -56,8 +56,8 @@ int ws_connect(const char* host, int port, const char* path, const char* device_
"Connection: Upgrade\r\n" "Connection: Upgrade\r\n"
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
"Sec-WebSocket-Version: 13\r\n" "Sec-WebSocket-Version: 13\r\n"
"Authorization: Bearer %s\r\n" "Authorization: Bearer %s\\r\\n"
"X-Device-ID: %s\r\n" "X-Device-ID: %s\\r\\n"
"\r\n", "\r\n",
path, host, port, auth_key, device_id); path, host, port, auth_key, device_id);
+2 -2
View File
@@ -10,8 +10,8 @@ extern "C" {
/** /**
* Connect to a WebSocket server. * Connect to a WebSocket server.
* @param host Server IP address (e.g. "192.168.68.126") * @param host Server IP address (for example, the Mac LAN host 192.168.68.112)
* @param port Port number (e.g. 8642) * @param port Port number (e.g. 8643 for the Kids gateway)
* @param path WebSocket path (e.g. "/api/esp32/voice/ws") * @param path WebSocket path (e.g. "/api/esp32/voice/ws")
* @param device_id Unique device identifier * @param device_id Unique device identifier
* @param auth_key Hermes Bearer API key * @param auth_key Hermes Bearer API key
+4
View File
@@ -15,6 +15,10 @@
#include <cstring> #include <cstring>
#include "esp_log.h" #include "esp_log.h"
// The deployed 0.8.0-dev firmware still exports this legacy lookup API, while
// the current SDK headers only expose device_get_by_name().
extern "C" Device* device_find_by_name(const char* name);
#ifndef M_PI #ifndef M_PI
#define M_PI 3.14159265358979323846f #define M_PI 3.14159265358979323846f
#endif #endif
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Validate MP3 Player close-and-resume evidence captured from serial."""
from __future__ import annotations
import argparse
import re
from pathlib import Path
def evaluate_resume_trace(trace: str, fixture: str, minimum_position: int = 6) -> dict[str, int]:
"""Require real playback, persisted position, and same-file relaunch evidence."""
escaped = re.escape(fixture)
initial = re.search(rf"Starting MP3 playback: {escaped} .* resume=0\b", trace)
stream_opened = re.search(r"Audio stream opened: \d+ Hz, \d+ channels", trace)
persisted = re.search(rf"History saved on hide: {escaped} pos (\d+) total \d+", trace)
resumed = re.search(rf"Resuming last play {escaped} at (\d+) sec from history", trace)
playback_matches = list(re.finditer(rf"Starting MP3 playback: {escaped} .* resume=(\d+)\b", trace))
resumed_playback = playback_matches[-1] if playback_matches else None
if "Playback task stuck, force deleting" in trace:
raise RuntimeError("forced playback task termination invalidates the device test")
if not initial or not stream_opened:
raise RuntimeError("missing verified playback evidence for the fixture")
if not persisted or not resumed or not resumed_playback:
raise RuntimeError("missing persisted or resumed playback evidence")
persisted_position = int(persisted.group(1))
resumed_position = int(resumed.group(1))
resumed_playback_position = int(resumed_playback.group(1))
if persisted_position < minimum_position:
raise RuntimeError(f"persisted position {persisted_position} is below {minimum_position}")
if resumed_position != persisted_position or resumed_playback_position != persisted_position:
raise RuntimeError("relaunch did not resume the persisted position on the same file")
return {"persisted_position": persisted_position, "resumed_position": resumed_position}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--trace", type=Path, required=True, help="Captured serial log")
parser.add_argument("--fixture", required=True, help="Absolute fixture path logged by MP3 Player")
parser.add_argument("--minimum-position", type=int, default=6)
args = parser.parse_args()
result = evaluate_resume_trace(args.trace.read_text(encoding="utf-8", errors="replace"), args.fixture, args.minimum_position)
print(f"PASS persisted_position={result['persisted_position']} resumed_position={result['resumed_position']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+9
View File
@@ -30,6 +30,15 @@ class LiveCaptionsContractTests(unittest.TestCase):
self.assertIn("const char* stop", source) self.assertIn("const char* stop", source)
self.assertNotIn("i2s_controller_", source) self.assertNotIn("i2s_controller_", source)
def test_ui_autostarts_and_keeps_only_the_latest_thirty_words(self):
source = (APP / "main" / "Source" / "main.c").read_text()
self.assertIn("#define DISPLAY_WORD_LIMIT 50", source)
self.assertIn("copy_recent_words", source)
self.assertIn("tt_lvgl_toolbar_create_for_app", source)
self.assertNotIn("lv_btn_create", source)
self.assertNotIn("start_button", source)
self.assertIn("xTaskCreate(worker_task", source)
def test_connection_failure_stays_failed_until_user_starts_again(self): def test_connection_failure_stays_failed_until_user_starts_again(self):
source = (APP / "main" / "Source" / "main.c").read_text() source = (APP / "main" / "Source" / "main.c").read_text()
self.assertIn("CAPTION_FAILED", source) self.assertIn("CAPTION_FAILED", source)
+82
View File
@@ -0,0 +1,82 @@
from pathlib import Path
MP3_SOURCE = Path(__file__).parents[1] / "Apps" / "Mp3Player" / "main" / "Source" / "main.c"
def test_mp3_player_uses_the_firmware_audio_stream_device_name():
source = MP3_SOURCE.read_text(encoding="utf-8")
assert 'device_find_by_name("audio-stream0")' in source
assert 'device_find_by_name("audio-stream")' not in source
assert "device_get_first_by_type" not in source
assert "device_find_first_by_type" not in source
def test_mp3_player_restores_a_visible_persisted_volume_control_above_the_six_button_row():
source = MP3_SOURCE.read_text(encoding="utf-8")
assert "lv_obj_t* card = lv_obj_create(parent);" not in source
assert "lv_label_set_text(icon, LV_SYMBOL_AUDIO);" not in source
assert "lv_obj_t* vol_box = lv_obj_create(parent);" in source
assert "g_ctx.slider_volume = lv_slider_create(vol_box);" in source
assert "lv_slider_set_value(g_ctx.slider_volume, g_ctx.volume, LV_ANIM_OFF);" in source
assert "on_volume_slider_changed" in source
assert "save_volume(ctx);" in source
assert "lv_obj_set_size(bottom_box, lv_pct(100), 52);" in source
assert "lv_obj_t* btn_close = lv_btn_create(ctrl_box);" in source
assert "lv_obj_t* btn_hist = lv_btn_create(ctrl_box);" in source
assert "lv_label_set_text(lbl_close, LV_SYMBOL_CLOSE);" in source
assert "lv_label_set_text(lbl_hist_btn, LV_SYMBOL_DIRECTORY);" in source
assert "lv_label_set_text(lbl_back, \"-15s\");" in source
assert "lv_label_set_text(lbl_fwd, \"+15s\");" in source
def test_mp3_player_folder_button_returns_to_the_files_list():
source = MP3_SOURCE.read_text(encoding="utf-8")
library_callback = source.split("static void on_library_click", 1)[1].split("/* ─── App Lifecycle", 1)[0]
assert "tt_app_stop();" not in library_callback
assert "show_history_screen(&g_ctx);" in library_callback
assert "load_first_sd_mp3" not in library_callback
def test_mp3_player_history_view_and_fifteen_second_seek_are_present():
source = MP3_SOURCE.read_text(encoding="utf-8")
assert "#define SEEK_SECONDS 15" in source
assert 'tt_app_get_user_data_child_path(app, "play_history.txt"' in source
assert "static void show_history_screen" in source
assert "static void on_history_selected" in source
assert "char label[640];" in source
assert "request_seek(ctx, -SEEK_SECONDS);" in source
assert "request_seek(ctx, SEEK_SECONDS);" in source
def test_mp3_player_persists_and_resumes_short_tracks_too():
source = MP3_SOURCE.read_text(encoding="utf-8")
history_selector = source.split("static void on_history_selected", 1)[1].split("static void refresh_history_list", 1)[0]
assert "Skip history: audio too short" not in source
assert "Resuming last 10min+ play" not in source
assert "Resuming last play %s at %d sec from history" in source
assert "if (he->total_sec >= 600)" not in history_selector
assert "resume_pos = he->pos_sec;" in history_selector
def test_mp3_player_close_waits_for_playback_before_stopping_the_app():
source = MP3_SOURCE.read_text(encoding="utf-8")
close_callback = source.split("static void on_close_click", 1)[1].split("static void on_library_click", 1)[0]
assert "wait_for_playback_task_to_exit(&g_ctx);" in close_callback
assert close_callback.index("wait_for_playback_task_to_exit(&g_ctx);") < close_callback.index("tt_app_stop();")
assert "tt_lvgl_unlock();" in source
assert "tt_lvgl_lock(portMAX_DELAY);" in source
def test_mp3_player_has_a_consumed_dev_autoclose_hook_for_device_tests():
source = MP3_SOURCE.read_text(encoding="utf-8")
assert '"mp3player_dev_autoclose_ms"' in source
assert "on_close_click(NULL);" in source
assert "unlink(marker_path);" in source
+69
View File
@@ -0,0 +1,69 @@
import importlib.util
from pathlib import Path
RUNNER_PATH = Path(__file__).parents[1] / "scripts" / "mp3_player_device_runner.py"
def load_runner():
spec = importlib.util.spec_from_file_location("mp3_player_device_runner", RUNNER_PATH)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
def test_resume_trace_requires_playback_persistence_and_same_path_resume():
runner = load_runner()
fixture = "/sdcard/download/test.mp3"
trace = "\n".join(
[
"I Mp3Player: Starting MP3 playback: /sdcard/download/test.mp3 (size: 480000 bytes) resume=0",
"I Mp3Player: Audio stream opened: 16000 Hz, 1 channels",
"I Mp3Player: History saved on hide: /sdcard/download/test.mp3 pos 9 total 30",
"I Mp3Player: Resuming last play /sdcard/download/test.mp3 at 9 sec from history",
"I Mp3Player: Starting MP3 playback: /sdcard/download/test.mp3 (size: 480000 bytes) resume=9",
]
)
result = runner.evaluate_resume_trace(trace, fixture, minimum_position=6)
assert result == {"persisted_position": 9, "resumed_position": 9}
def test_resume_trace_rejects_a_different_file_or_no_playback_evidence():
runner = load_runner()
fixture = "/sdcard/download/test.mp3"
try:
runner.evaluate_resume_trace(
"I Mp3Player: Starting MP3 playback: /sdcard/other.mp3 (size: 1 bytes) resume=0",
fixture,
minimum_position=6,
)
except RuntimeError as exc:
assert "playback" in str(exc).lower()
else:
raise AssertionError("missing playback evidence must fail")
def test_resume_trace_rejects_forced_playback_task_termination():
runner = load_runner()
fixture = "/sdcard/download/test.mp3"
trace = "\n".join(
[
f"I Mp3Player: Starting MP3 playback: {fixture} (size: 480000 bytes) resume=0",
"I Mp3Player: Audio stream opened: 16000 Hz, 1 channels",
f"I Mp3Player: History saved on hide: {fixture} pos 9 total 180",
"W Mp3Player: Playback task stuck, force deleting",
f"I Mp3Player: Resuming last play {fixture} at 9 sec from history",
f"I Mp3Player: Starting MP3 playback: {fixture} (size: 480000 bytes) resume=9",
]
)
try:
runner.evaluate_resume_trace(trace, fixture, minimum_position=6)
except RuntimeError as exc:
assert "forced" in str(exc).lower()
else:
raise AssertionError("forced task termination must fail the device test")