Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59612020bb | |||
| e42036a044 | |||
| 52bf3e1870 | |||
| 03e65fb7f7 |
@@ -63,3 +63,11 @@ Each book subfolder must contain a `manifest.json` file. Here is a sample format
|
||||
|
||||
- **Images**: PNG or JPG format. Recommended size is `320×240` pixels (or scaled to match standard aspect ratios).
|
||||
- **Audio**: Mono `MP3` or uncompressed standard `WAV` files. For ESP32-S3 systems, lower sampling rates (e.g. 16 kHz mono) are recommended for memory efficiency.
|
||||
|
||||
## Book Selection and Playback
|
||||
|
||||
The picker displays the selected book's first page image full-screen with its title.
|
||||
Tap the left or right side of the cover, or swipe right or left, to choose the
|
||||
previous or next book. Tap the Play button to begin page-one narration through the
|
||||
same playback flow used by the in-book player. Once playing, the existing
|
||||
previous, pause/play, next, progress, and auto-advance controls remain unchanged.
|
||||
|
||||
+312
-112
@@ -5,6 +5,7 @@
|
||||
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/drivers/audio_stream.h>
|
||||
#include <lvgl/fonts.h>
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
@@ -31,10 +32,8 @@
|
||||
#define MAX_PATH 256
|
||||
#define MAX_TITLE 128
|
||||
#define MAX_AUTHOR 128
|
||||
|
||||
// Fade-in/out applied at the start/end of each page's narration to avoid an
|
||||
// amplitude step (audible "pop") at page boundaries. In frames per channel.
|
||||
#define FADE_FRAMES 4096
|
||||
#define MAX_LVGL_IMAGE_PATH 1024
|
||||
#define PICKER_COVER_LOAD_DELAY_MS 250
|
||||
|
||||
typedef enum {
|
||||
STATE_IDLE,
|
||||
@@ -57,6 +56,10 @@ typedef struct {
|
||||
// Book picker list data
|
||||
BookMetadata books[MAX_BOOKS];
|
||||
int book_count;
|
||||
int selected_book;
|
||||
lv_coord_t picker_drag_start_x;
|
||||
bool picker_dragging;
|
||||
bool picker_dragged;
|
||||
|
||||
// Currently loaded book details
|
||||
char current_book_slug[MAX_PATH];
|
||||
@@ -68,8 +71,18 @@ typedef struct {
|
||||
|
||||
// UI elements
|
||||
AppHandle app;
|
||||
// Keep the decoded cover on a dedicated back sibling. The picker controls
|
||||
// are a separate transparent sibling above it, mirroring the player image
|
||||
// first / chrome second composition without invalidating the cover for UI
|
||||
// updates.
|
||||
lv_obj_t* picker_background;
|
||||
lv_obj_t* picker_wrapper;
|
||||
lv_obj_t* lst_books;
|
||||
lv_obj_t* picker_cover;
|
||||
lv_obj_t* picker_touch_area;
|
||||
lv_obj_t* lbl_picker_title;
|
||||
lv_obj_t* lbl_picker_author;
|
||||
lv_obj_t* btn_picker_play;
|
||||
lv_obj_t* lbl_picker_status;
|
||||
|
||||
lv_obj_t* player_wrapper;
|
||||
lv_obj_t* header_bar;
|
||||
@@ -84,6 +97,9 @@ typedef struct {
|
||||
|
||||
// Audio State
|
||||
char current_audio_path[512];
|
||||
char picker_cover_path[MAX_LVGL_IMAGE_PATH];
|
||||
char player_image_path[MAX_LVGL_IMAGE_PATH];
|
||||
lv_timer_t* picker_cover_timer;
|
||||
uint8_t* audio_buf; // Shared MP3 input and WAV buffer
|
||||
mp3d_sample_t* pcm_buf; // MP3 decoded pcm buffer
|
||||
|
||||
@@ -93,6 +109,10 @@ typedef struct {
|
||||
uint8_t stream_channels;
|
||||
uint8_t stream_bits;
|
||||
|
||||
// Page navigation stops the decoder task but keeps a compatible output
|
||||
// stream open for the next page.
|
||||
bool keep_stream_on_stop;
|
||||
|
||||
TaskHandle_t playback_task_handle;
|
||||
} AppCtx;
|
||||
|
||||
@@ -116,13 +136,18 @@ static AppCtx g_ctx;
|
||||
|
||||
/* ─── Forward Declarations ─── */
|
||||
static void update_ui(AppCtx* ctx);
|
||||
static void wait_for_playback_task_to_exit(AppCtx* ctx);
|
||||
static void wait_for_playback_task_to_exit(AppCtx* ctx, bool keep_stream_open);
|
||||
static void load_page(AppCtx* ctx, int page_index, bool start_audio);
|
||||
static void return_to_picker(AppCtx* ctx);
|
||||
static void audio_playback_task(void* arg);
|
||||
static void play_mp3(AppCtx* ctx);
|
||||
static void play_wav(AppCtx* ctx);
|
||||
static void scan_books(AppCtx* ctx);
|
||||
static bool select_picker_book(AppCtx* ctx, int index);
|
||||
static void open_selected_book(AppCtx* ctx, bool start_audio);
|
||||
static void clear_picker_cover(AppCtx* ctx);
|
||||
static void schedule_picker_cover_load(AppCtx* ctx);
|
||||
static void cancel_picker_cover_load(AppCtx* ctx);
|
||||
|
||||
/* ─── Audio-stream helpers ─── */
|
||||
// The flashed firmware exports the legacy device_find_* lookup API; the newer
|
||||
@@ -266,20 +291,52 @@ static void handle_audio_finished(AppCtx* ctx) {
|
||||
}
|
||||
|
||||
/* ─── Helper to wait for playback thread to terminate safely ─── */
|
||||
static void wait_for_playback_task_to_exit(AppCtx* ctx) {
|
||||
static void wait_for_playback_task_to_exit(AppCtx* ctx, bool keep_stream_open) {
|
||||
if (ctx->playback_task_handle != NULL) {
|
||||
ctx->keep_stream_on_stop = keep_stream_open;
|
||||
ctx->state = STATE_IDLE;
|
||||
while (ctx->playback_task_handle != NULL) {
|
||||
tt_lvgl_unlock();
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
tt_lvgl_lock(portMAX_DELAY);
|
||||
}
|
||||
ctx->keep_stream_on_stop = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Load Page Content (Image, Caption, Audio path) ─── */
|
||||
static void load_image_from_page(AppCtx* ctx, cJSON* page, lv_obj_t* image,
|
||||
char* lv_img_path, size_t lv_img_path_size) {
|
||||
cJSON* img_item = cJSON_GetObjectItem(page, "image");
|
||||
if (lv_img_path && lv_img_path_size > 0) lv_img_path[0] = '\0';
|
||||
|
||||
if (img_item && img_item->valuestring) {
|
||||
char img_path[512];
|
||||
snprintf(img_path, sizeof(img_path), "/sdcard/books/%s/%s", ctx->current_book_slug, img_item->valuestring);
|
||||
|
||||
FILE* img_file = fopen(img_path, "rb");
|
||||
if (img_file) {
|
||||
fclose(img_file);
|
||||
if (!lv_img_path || lv_img_path_size == 0) {
|
||||
lv_image_set_src(image, LV_SYMBOL_IMAGE);
|
||||
return;
|
||||
}
|
||||
#ifdef ESP_PLATFORM
|
||||
snprintf(lv_img_path, lv_img_path_size, "A:%s", img_path);
|
||||
#else
|
||||
snprintf(lv_img_path, lv_img_path_size, "A:/%s", img_path);
|
||||
#endif
|
||||
lv_image_set_src(image, lv_img_path);
|
||||
return;
|
||||
}
|
||||
ESP_LOGW(TAG, "Image file not found: %s", img_path);
|
||||
}
|
||||
|
||||
lv_image_set_src(image, LV_SYMBOL_IMAGE);
|
||||
}
|
||||
|
||||
static void load_page(AppCtx* ctx, int page_index, bool start_audio) {
|
||||
wait_for_playback_task_to_exit(ctx);
|
||||
wait_for_playback_task_to_exit(ctx, true);
|
||||
|
||||
if (page_index < 0 || page_index >= ctx->page_count) return;
|
||||
ctx->current_page = page_index;
|
||||
@@ -288,31 +345,10 @@ static void load_page(AppCtx* ctx, int page_index, bool start_audio) {
|
||||
cJSON* page = cJSON_GetArrayItem(ctx->pages_array, page_index);
|
||||
if (!page) return;
|
||||
|
||||
cJSON* img_item = cJSON_GetObjectItem(page, "image");
|
||||
cJSON* snd_item = cJSON_GetObjectItem(page, "audio");
|
||||
|
||||
// Load Image file
|
||||
if (img_item && img_item->valuestring) {
|
||||
char img_path[512];
|
||||
snprintf(img_path, sizeof(img_path), "/sdcard/books/%s/%s", ctx->current_book_slug, img_item->valuestring);
|
||||
|
||||
FILE* img_file = fopen(img_path, "rb");
|
||||
if (img_file) {
|
||||
fclose(img_file);
|
||||
char lv_img_path[1024];
|
||||
#ifdef ESP_PLATFORM
|
||||
snprintf(lv_img_path, sizeof(lv_img_path), "A:%s", img_path);
|
||||
#else
|
||||
snprintf(lv_img_path, sizeof(lv_img_path), "A:/%s", img_path);
|
||||
#endif
|
||||
lv_image_set_src(ctx->img_page, lv_img_path);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Image file not found: %s", img_path);
|
||||
lv_image_set_src(ctx->img_page, LV_SYMBOL_IMAGE);
|
||||
}
|
||||
} else {
|
||||
lv_image_set_src(ctx->img_page, LV_SYMBOL_IMAGE);
|
||||
}
|
||||
load_image_from_page(ctx, page, ctx->img_page,
|
||||
ctx->player_image_path, sizeof(ctx->player_image_path));
|
||||
|
||||
// Update Page index indicator
|
||||
char ind_buf[32];
|
||||
@@ -351,7 +387,7 @@ static void load_page(AppCtx* ctx, int page_index, bool start_audio) {
|
||||
|
||||
/* ─── Return to Book Picker Screen ─── */
|
||||
static void return_to_picker(AppCtx* ctx) {
|
||||
wait_for_playback_task_to_exit(ctx);
|
||||
wait_for_playback_task_to_exit(ctx, false);
|
||||
close_stream_if_open(ctx);
|
||||
|
||||
if (ctx->manifest_root) {
|
||||
@@ -361,6 +397,7 @@ static void return_to_picker(AppCtx* ctx) {
|
||||
}
|
||||
|
||||
lv_obj_add_flag(ctx->player_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_remove_flag(ctx->picker_background, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_remove_flag(ctx->picker_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
|
||||
@@ -419,7 +456,6 @@ static void play_mp3(AppCtx* ctx) {
|
||||
bool eof = false;
|
||||
int sample_rate = 0;
|
||||
int channels = 0;
|
||||
size_t page_frames_written = 0;
|
||||
|
||||
ESP_LOGI(TAG, "Starting MP3 playback via audio-stream: %s (%d bytes)", ctx->current_audio_path, file_size);
|
||||
|
||||
@@ -474,30 +510,14 @@ static void play_mp3(AppCtx* ctx) {
|
||||
channels = info.channels;
|
||||
}
|
||||
|
||||
// Adjust Volume and apply a fade-in/out at the start/end of the page
|
||||
// so the transition to the next page doesn't click.
|
||||
// Adjust volume without altering the start or end of the narration.
|
||||
int vol = ctx->volume;
|
||||
int16_t* samples_ptr = (int16_t*)ctx->pcm_buf;
|
||||
size_t sample_count = (size_t)samples * info.channels;
|
||||
uint8_t ch = (uint8_t)info.channels;
|
||||
size_t bytes_per_frame = (size_t)ch * sizeof(int16_t);
|
||||
size_t frames_in_chunk = (size_t)samples;
|
||||
float remaining_frames = (file_size > bytes_read_total)
|
||||
? (float)(file_size - bytes_read_total) / (float)bytes_per_frame : 0.0f;
|
||||
for (size_t i = 0; i < sample_count; ++i) {
|
||||
size_t frame = page_frames_written + i / ch;
|
||||
float fade_in = (frame < FADE_FRAMES) ? (float)frame / (float)FADE_FRAMES : 1.0f;
|
||||
float fade_out = 1.0f;
|
||||
float remaining = remaining_frames - (float)(i / ch);
|
||||
if (remaining < (float)FADE_FRAMES) {
|
||||
fade_out = (remaining > 0.0f) ? remaining / (float)FADE_FRAMES : 0.0f;
|
||||
}
|
||||
float gain = fade_in * fade_out;
|
||||
int32_t scaled = (int32_t)samples_ptr[i] * vol / 100;
|
||||
scaled = (int32_t)((float)scaled * gain);
|
||||
samples_ptr[i] = (int16_t)scaled;
|
||||
}
|
||||
page_frames_written += frames_in_chunk;
|
||||
|
||||
// Write via audio_stream (resampled to native 44100 internally)
|
||||
size_t offset = 0;
|
||||
@@ -536,12 +556,10 @@ static void play_mp3(AppCtx* ctx) {
|
||||
|
||||
bool stopped_externally = (ctx->state == STATE_IDLE);
|
||||
|
||||
if (stopped_externally) {
|
||||
// User-initiated stop (prev/next, back, app close): tear the stream down.
|
||||
if (stopped_externally && !ctx->keep_stream_on_stop) {
|
||||
// App exit and returning to the picker release the output device.
|
||||
close_stream_if_open(ctx);
|
||||
}
|
||||
// On a natural page finish we deliberately LEAVE the stream open so the next
|
||||
// page reuses it, instead of closing+recreating the codec (which pops).
|
||||
|
||||
if (!stopped_externally) {
|
||||
tt_lvgl_lock(portMAX_DELAY);
|
||||
@@ -621,7 +639,6 @@ static void play_wav(AppCtx* ctx) {
|
||||
ESP_LOGI(TAG, "Starting WAV via audio-stream: %s (%u Hz, %u ch)", ctx->current_audio_path, (unsigned int)header.sample_rate, (unsigned int)header.channels);
|
||||
|
||||
size_t total_played = 0;
|
||||
size_t page_frames_written = 0;
|
||||
bool stream_open = true;
|
||||
|
||||
while (total_played < data_size && ctx->state != STATE_IDLE) {
|
||||
@@ -643,29 +660,14 @@ static void play_wav(AppCtx* ctx) {
|
||||
size_t read_bytes = fread(ctx->audio_buf, 1, to_read, file);
|
||||
if (read_bytes == 0) break;
|
||||
|
||||
// Scaling Volume + fade-in/out at page boundaries to avoid a click.
|
||||
// Adjust volume without altering the start or end of the narration.
|
||||
int vol = ctx->volume;
|
||||
int16_t* samples_ptr = (int16_t*)ctx->audio_buf;
|
||||
size_t sample_count = read_bytes / sizeof(int16_t);
|
||||
uint8_t ch = (header.channels > 0) ? header.channels : 1;
|
||||
size_t bytes_per_frame = (size_t)ch * sizeof(int16_t);
|
||||
size_t frames_in_chunk = read_bytes / bytes_per_frame;
|
||||
float remaining_frames = (data_size > total_played)
|
||||
? (float)(data_size - total_played) / (float)bytes_per_frame : 0.0f;
|
||||
for (size_t i = 0; i < sample_count; ++i) {
|
||||
size_t frame = page_frames_written + i / ch;
|
||||
float fade_in = (frame < FADE_FRAMES) ? (float)frame / (float)FADE_FRAMES : 1.0f;
|
||||
float fade_out = 1.0f;
|
||||
float remaining = remaining_frames - (float)(i / ch);
|
||||
if (remaining < (float)FADE_FRAMES) {
|
||||
fade_out = (remaining > 0.0f) ? remaining / (float)FADE_FRAMES : 0.0f;
|
||||
}
|
||||
float gain = fade_in * fade_out;
|
||||
int32_t scaled = (int32_t)samples_ptr[i] * vol / 100;
|
||||
scaled = (int32_t)((float)scaled * gain);
|
||||
samples_ptr[i] = (int16_t)scaled;
|
||||
}
|
||||
page_frames_written += frames_in_chunk;
|
||||
|
||||
// Write via audio_stream
|
||||
size_t offset = 0;
|
||||
@@ -702,12 +704,10 @@ static void play_wav(AppCtx* ctx) {
|
||||
|
||||
bool stopped_externally = (ctx->state == STATE_IDLE);
|
||||
|
||||
if (stopped_externally) {
|
||||
// User-initiated stop (prev/next, back, app close): tear the stream down.
|
||||
if (stopped_externally && !ctx->keep_stream_on_stop) {
|
||||
// App exit and returning to the picker release the output device.
|
||||
close_stream_if_open(ctx);
|
||||
}
|
||||
// On a natural page finish we deliberately LEAVE the stream open so the next
|
||||
// page reuses it, instead of closing+recreating the codec (which pops).
|
||||
|
||||
if (!stopped_externally) {
|
||||
tt_lvgl_lock(portMAX_DELAY);
|
||||
@@ -719,13 +719,19 @@ static void play_wav(AppCtx* ctx) {
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
/* ─── Picker events ─── */
|
||||
static void on_book_selected(lv_event_t* e) {
|
||||
int index = (int)(intptr_t)lv_event_get_user_data(e);
|
||||
AppCtx* ctx = &g_ctx;
|
||||
/* ─── Full-screen book picker ─── */
|
||||
static bool select_picker_book(AppCtx* ctx, int index) {
|
||||
if (index < 0 || index >= ctx->book_count) return false;
|
||||
|
||||
if (ctx->manifest_root) {
|
||||
cJSON_Delete(ctx->manifest_root);
|
||||
ctx->manifest_root = NULL;
|
||||
ctx->pages_array = NULL;
|
||||
}
|
||||
|
||||
BookMetadata* book = &ctx->books[index];
|
||||
strncpy(ctx->current_book_slug, book->slug, sizeof(ctx->current_book_slug) - 1);
|
||||
ctx->current_book_slug[sizeof(ctx->current_book_slug) - 1] = '\0';
|
||||
|
||||
char manifest_path[512];
|
||||
snprintf(manifest_path, sizeof(manifest_path), "/sdcard/books/%s/manifest.json", book->slug);
|
||||
@@ -734,7 +740,7 @@ static void on_book_selected(lv_event_t* e) {
|
||||
if (!json_str) {
|
||||
const char* buttons[] = {"OK"};
|
||||
tt_app_alertdialog_start("Read Error", "Failed to open the book manifest.", buttons, 1);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
ctx->manifest_root = cJSON_Parse(json_str);
|
||||
@@ -743,7 +749,7 @@ static void on_book_selected(lv_event_t* e) {
|
||||
if (!ctx->manifest_root) {
|
||||
const char* buttons[] = {"OK"};
|
||||
tt_app_alertdialog_start("JSON Error", "The book manifest is not formatted correctly.", buttons, 1);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
ctx->pages_array = cJSON_GetObjectItem(ctx->manifest_root, "pages");
|
||||
@@ -753,7 +759,7 @@ static void on_book_selected(lv_event_t* e) {
|
||||
cJSON_Delete(ctx->manifest_root);
|
||||
ctx->manifest_root = NULL;
|
||||
ctx->pages_array = NULL;
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
ctx->page_count = cJSON_GetArraySize(ctx->pages_array);
|
||||
@@ -763,30 +769,149 @@ static void on_book_selected(lv_event_t* e) {
|
||||
cJSON_Delete(ctx->manifest_root);
|
||||
ctx->manifest_root = NULL;
|
||||
ctx->pages_array = NULL;
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
ctx->selected_book = index;
|
||||
lv_label_set_text(ctx->lbl_picker_title, book->title);
|
||||
lv_label_set_text(ctx->lbl_picker_author, book->author[0] ? book->author : "");
|
||||
char picker_status[64];
|
||||
snprintf(picker_status, sizeof(picker_status), "%d / %d - Tap sides or swipe", index + 1, ctx->book_count);
|
||||
lv_label_set_text(ctx->lbl_picker_status, picker_status);
|
||||
schedule_picker_cover_load(ctx);
|
||||
return true;
|
||||
}
|
||||
|
||||
static void clear_picker_cover(AppCtx* ctx) {
|
||||
if (!ctx->picker_cover) return;
|
||||
// Release the previous file-backed source before its stable path buffer is
|
||||
// reused. The symbol fallback has no file decoder or SD resource attached.
|
||||
lv_image_set_src(ctx->picker_cover, LV_SYMBOL_IMAGE);
|
||||
ctx->picker_cover_path[0] = '\0';
|
||||
}
|
||||
|
||||
static void on_picker_cover_timer(lv_timer_t* timer) {
|
||||
AppCtx* ctx = (AppCtx*)lv_timer_get_user_data(timer);
|
||||
if (!ctx) return;
|
||||
|
||||
// The timer is one-shot and LVGL will dispose it after this callback.
|
||||
// Clear the stored pointer first so a subsequent selection cannot delete a
|
||||
// timer that is already being finalized.
|
||||
ctx->picker_cover_timer = NULL;
|
||||
if (!ctx->pages_array || !ctx->picker_cover) return;
|
||||
|
||||
cJSON* first_page = cJSON_GetArrayItem(ctx->pages_array, 0);
|
||||
if (!first_page) return;
|
||||
load_image_from_page(ctx, first_page, ctx->picker_cover,
|
||||
ctx->picker_cover_path, sizeof(ctx->picker_cover_path));
|
||||
}
|
||||
|
||||
static void schedule_picker_cover_load(AppCtx* ctx) {
|
||||
cancel_picker_cover_load(ctx);
|
||||
clear_picker_cover(ctx);
|
||||
|
||||
// Loading a full SD PNG from onShow has rebooted the S3 before LVGL's first
|
||||
// event-loop pass. The player performs this same load from a user event;
|
||||
// defer the picker equivalent until its UI is live and stable.
|
||||
ctx->picker_cover_timer = lv_timer_create(on_picker_cover_timer, PICKER_COVER_LOAD_DELAY_MS, ctx);
|
||||
if (ctx->picker_cover_timer) {
|
||||
lv_timer_set_repeat_count(ctx->picker_cover_timer, 1);
|
||||
} else {
|
||||
ESP_LOGE(TAG, "Failed to schedule picker cover image load");
|
||||
}
|
||||
}
|
||||
|
||||
static void cancel_picker_cover_load(AppCtx* ctx) {
|
||||
if (ctx->picker_cover_timer) {
|
||||
lv_timer_delete(ctx->picker_cover_timer);
|
||||
ctx->picker_cover_timer = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
static void open_selected_book(AppCtx* ctx, bool start_audio) {
|
||||
if (!ctx->manifest_root || ctx->page_count <= 0) return;
|
||||
|
||||
BookMetadata* book = &ctx->books[ctx->selected_book];
|
||||
lv_label_set_text(ctx->lbl_book_title, book->title);
|
||||
|
||||
// Switch views
|
||||
cancel_picker_cover_load(ctx);
|
||||
lv_obj_add_flag(ctx->picker_background, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_add_flag(ctx->picker_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_remove_flag(ctx->player_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_remove_flag(ctx->header_bar, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_remove_flag(ctx->ctrl_bar, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_remove_flag(ctx->bar_progress, LV_OBJ_FLAG_HIDDEN);
|
||||
|
||||
load_page(ctx, 0, false);
|
||||
load_page(ctx, 0, start_audio);
|
||||
}
|
||||
|
||||
static void change_picker_book(AppCtx* ctx, int index) {
|
||||
if (index < 0 || index >= ctx->book_count || index == ctx->selected_book) return;
|
||||
select_picker_book(ctx, index);
|
||||
}
|
||||
|
||||
static void on_picker_touch(lv_event_t* e) {
|
||||
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
|
||||
lv_event_code_t code = lv_event_get_code(e);
|
||||
lv_indev_t* indev = lv_indev_active();
|
||||
if (!ctx || !indev || ctx->book_count == 0) return;
|
||||
|
||||
lv_point_t point;
|
||||
lv_indev_get_point(indev, &point);
|
||||
if (code == LV_EVENT_PRESSED) {
|
||||
ctx->picker_drag_start_x = point.x;
|
||||
ctx->picker_dragging = true;
|
||||
ctx->picker_dragged = false;
|
||||
} else if (code == LV_EVENT_PRESSING && ctx->picker_dragging) {
|
||||
int dx = point.x - ctx->picker_drag_start_x;
|
||||
const int threshold = 28;
|
||||
if (abs(dx) >= threshold) {
|
||||
int steps = dx / threshold;
|
||||
int next_index = ctx->selected_book - steps; // swipe left advances
|
||||
if (next_index < 0) next_index = 0;
|
||||
if (next_index >= ctx->book_count) next_index = ctx->book_count - 1;
|
||||
if (next_index != ctx->selected_book) {
|
||||
change_picker_book(ctx, next_index);
|
||||
ctx->picker_dragged = true;
|
||||
}
|
||||
ctx->picker_drag_start_x = point.x;
|
||||
}
|
||||
} else if (code == LV_EVENT_RELEASED || code == LV_EVENT_PRESS_LOST) {
|
||||
ctx->picker_dragging = false;
|
||||
} else if (code == LV_EVENT_CLICKED) {
|
||||
if (ctx->picker_dragged) {
|
||||
ctx->picker_dragged = false;
|
||||
return;
|
||||
}
|
||||
if (point.x < lv_obj_get_width(ctx->picker_touch_area) / 2) {
|
||||
change_picker_book(ctx, ctx->selected_book - 1);
|
||||
} else {
|
||||
change_picker_book(ctx, ctx->selected_book + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void on_picker_play_click(lv_event_t* e) {
|
||||
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
|
||||
open_selected_book(ctx, true);
|
||||
}
|
||||
|
||||
static void on_picker_close_click(lv_event_t* e) {
|
||||
(void)e;
|
||||
// Follow the normal external-app lifecycle so onHide releases the pending
|
||||
// cover timer, file-backed image sources, manifest, and audio buffers.
|
||||
tt_app_stop();
|
||||
}
|
||||
|
||||
/* ─── Scan Books ─── */
|
||||
static void scan_books(AppCtx* ctx) {
|
||||
ctx->book_count = 0;
|
||||
lv_obj_clean(ctx->lst_books);
|
||||
|
||||
DIR* dir = opendir("/sdcard/books");
|
||||
if (!dir) {
|
||||
ESP_LOGE(TAG, "Failed to scan books: /sdcard/books folder missing.");
|
||||
lv_list_add_text(ctx->lst_books, "No SD card or books directory found.");
|
||||
lv_label_set_text(ctx->lbl_picker_status, "No SD card or books directory found.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -829,7 +954,7 @@ static void scan_books(AppCtx* ctx) {
|
||||
closedir(dir);
|
||||
|
||||
if (ctx->book_count == 0) {
|
||||
lv_list_add_text(ctx->lst_books, "No book manifest.json files found.");
|
||||
lv_label_set_text(ctx->lbl_picker_status, "No book manifest.json files found.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -844,17 +969,8 @@ static void scan_books(AppCtx* ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
// Add items to screen list
|
||||
for (int i = 0; i < ctx->book_count; ++i) {
|
||||
char label_text[512];
|
||||
if (ctx->books[i].author[0] != '\0') {
|
||||
snprintf(label_text, sizeof(label_text), "%s\nby %s", ctx->books[i].title, ctx->books[i].author);
|
||||
} else {
|
||||
snprintf(label_text, sizeof(label_text), "%s", ctx->books[i].title);
|
||||
}
|
||||
lv_obj_t* btn = lv_list_add_button(ctx->lst_books, LV_SYMBOL_DIRECTORY, label_text);
|
||||
lv_obj_add_event_cb(btn, on_book_selected, LV_EVENT_CLICKED, (void*)(intptr_t)i);
|
||||
}
|
||||
lv_obj_remove_flag(ctx->btn_picker_play, LV_OBJ_FLAG_HIDDEN);
|
||||
select_picker_book(ctx, 0);
|
||||
}
|
||||
|
||||
/* ─── Player Control Events ─── */
|
||||
@@ -932,26 +1048,103 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
ESP_LOGE(TAG, "audio-stream device not found! Tried 'audio-stream' name and AUDIO_STREAM_TYPE");
|
||||
}
|
||||
|
||||
// Create dual layouts
|
||||
// 1. Picker Screen wrapper
|
||||
// Create dual layouts.
|
||||
// 1. The picker cover lives in its own back sibling. Keep all picker UI
|
||||
// and touch objects in the transparent foreground sibling above so changing
|
||||
// a title/status/button does not force the PNG-backed image through that
|
||||
// overlay's redraw path.
|
||||
g_ctx.picker_background = lv_obj_create(parent);
|
||||
lv_obj_set_size(g_ctx.picker_background, LV_PCT(100), LV_PCT(100));
|
||||
lv_obj_set_style_border_width(g_ctx.picker_background, 0, 0);
|
||||
lv_obj_set_style_pad_all(g_ctx.picker_background, 0, 0);
|
||||
lv_obj_set_style_pad_gap(g_ctx.picker_background, 0, 0);
|
||||
lv_obj_set_style_bg_color(g_ctx.picker_background, lv_color_hex(0x1E1E2E), 0);
|
||||
lv_obj_set_style_bg_opa(g_ctx.picker_background, LV_OPA_COVER, 0);
|
||||
lv_obj_remove_flag(g_ctx.picker_background, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
g_ctx.picker_cover = lv_image_create(g_ctx.picker_background);
|
||||
lv_obj_align(g_ctx.picker_cover, LV_ALIGN_CENTER, 0, 0);
|
||||
lv_obj_set_style_bg_opa(g_ctx.picker_cover, LV_OPA_TRANSP, 0);
|
||||
lv_obj_set_style_pad_all(g_ctx.picker_cover, 0, 0);
|
||||
lv_obj_set_style_border_width(g_ctx.picker_cover, 0, 0);
|
||||
lv_obj_remove_flag(g_ctx.picker_cover, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
g_ctx.picker_wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_size(g_ctx.picker_wrapper, LV_PCT(100), LV_PCT(100));
|
||||
lv_obj_set_style_border_width(g_ctx.picker_wrapper, 0, 0);
|
||||
lv_obj_set_style_pad_all(g_ctx.picker_wrapper, 0, 0);
|
||||
lv_obj_set_style_pad_gap(g_ctx.picker_wrapper, 0, 0);
|
||||
lv_obj_set_style_bg_color(g_ctx.picker_wrapper, lv_color_hex(0x1E1E2E), 0);
|
||||
lv_obj_set_style_bg_opa(g_ctx.picker_wrapper, LV_OPA_TRANSP, 0);
|
||||
lv_obj_remove_flag(g_ctx.picker_wrapper, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(g_ctx.picker_wrapper, app);
|
||||
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
||||
g_ctx.picker_touch_area = lv_obj_create(g_ctx.picker_wrapper);
|
||||
lv_obj_set_size(g_ctx.picker_touch_area, LV_PCT(100), LV_PCT(100));
|
||||
lv_obj_align(g_ctx.picker_touch_area, LV_ALIGN_CENTER, 0, 0);
|
||||
lv_obj_set_style_bg_opa(g_ctx.picker_touch_area, LV_OPA_TRANSP, 0);
|
||||
lv_obj_set_style_border_width(g_ctx.picker_touch_area, 0, 0);
|
||||
lv_obj_set_style_pad_all(g_ctx.picker_touch_area, 0, 0);
|
||||
lv_obj_remove_flag(g_ctx.picker_touch_area, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_add_flag(g_ctx.picker_touch_area, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_add_event_cb(g_ctx.picker_touch_area, on_picker_touch, LV_EVENT_PRESSED, &g_ctx);
|
||||
lv_obj_add_event_cb(g_ctx.picker_touch_area, on_picker_touch, LV_EVENT_PRESSING, &g_ctx);
|
||||
lv_obj_add_event_cb(g_ctx.picker_touch_area, on_picker_touch, LV_EVENT_RELEASED, &g_ctx);
|
||||
lv_obj_add_event_cb(g_ctx.picker_touch_area, on_picker_touch, LV_EVENT_PRESS_LOST, &g_ctx);
|
||||
lv_obj_add_event_cb(g_ctx.picker_touch_area, on_picker_touch, LV_EVENT_CLICKED, &g_ctx);
|
||||
|
||||
g_ctx.lst_books = lv_list_create(g_ctx.picker_wrapper);
|
||||
lv_obj_set_width(g_ctx.lst_books, LV_PCT(100));
|
||||
lv_obj_align_to(g_ctx.lst_books, toolbar, LV_ALIGN_OUT_BOTTOM_MID, 0, 0);
|
||||
int32_t toolbar_height = lv_obj_get_height(toolbar);
|
||||
int32_t parent_height = lv_obj_get_content_height(parent);
|
||||
lv_obj_set_height(g_ctx.lst_books, parent_height - toolbar_height);
|
||||
lv_obj_set_style_bg_color(g_ctx.lst_books, lv_color_hex(0x1E1E2E), 0);
|
||||
lv_obj_set_style_border_color(g_ctx.lst_books, lv_color_hex(0x313244), 0);
|
||||
lv_obj_t* picker_title_panel = lv_obj_create(g_ctx.picker_wrapper);
|
||||
lv_obj_set_size(picker_title_panel, LV_PCT(100), 68);
|
||||
lv_obj_align(picker_title_panel, LV_ALIGN_TOP_MID, 0, 0);
|
||||
lv_obj_set_style_bg_color(picker_title_panel, lv_color_hex(0x11111B), 0);
|
||||
lv_obj_set_style_bg_opa(picker_title_panel, LV_OPA_60, 0);
|
||||
lv_obj_set_style_border_width(picker_title_panel, 0, 0);
|
||||
lv_obj_set_style_pad_all(picker_title_panel, 0, 0);
|
||||
lv_obj_remove_flag(picker_title_panel, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
g_ctx.lbl_picker_title = lv_label_create(picker_title_panel);
|
||||
lv_obj_set_width(g_ctx.lbl_picker_title, LV_PCT(86));
|
||||
lv_obj_align(g_ctx.lbl_picker_title, LV_ALIGN_TOP_MID, 0, 10);
|
||||
lv_obj_set_style_text_align(g_ctx.lbl_picker_title, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_obj_set_style_text_color(g_ctx.lbl_picker_title, lv_color_hex(0xFFFFFF), 0);
|
||||
lv_obj_set_style_text_font(g_ctx.lbl_picker_title, lvgl_get_text_font(FONT_SIZE_LARGE), 0);
|
||||
lv_label_set_long_mode(g_ctx.lbl_picker_title, LV_LABEL_LONG_WRAP);
|
||||
|
||||
g_ctx.lbl_picker_author = lv_label_create(picker_title_panel);
|
||||
lv_obj_set_width(g_ctx.lbl_picker_author, LV_PCT(86));
|
||||
lv_obj_align(g_ctx.lbl_picker_author, LV_ALIGN_TOP_MID, 0, 42);
|
||||
lv_obj_set_style_text_align(g_ctx.lbl_picker_author, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_obj_set_style_text_color(g_ctx.lbl_picker_author, lv_color_hex(0xE0E0E8), 0);
|
||||
lv_label_set_long_mode(g_ctx.lbl_picker_author, LV_LABEL_LONG_DOT);
|
||||
|
||||
g_ctx.lbl_picker_status = lv_label_create(g_ctx.picker_wrapper);
|
||||
lv_obj_set_width(g_ctx.lbl_picker_status, LV_PCT(86));
|
||||
lv_obj_align(g_ctx.lbl_picker_status, LV_ALIGN_BOTTOM_MID, 0, -54);
|
||||
lv_obj_set_style_text_align(g_ctx.lbl_picker_status, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_obj_set_style_text_color(g_ctx.lbl_picker_status, lv_color_hex(0xF5F5FA), 0);
|
||||
|
||||
g_ctx.btn_picker_play = lv_button_create(g_ctx.picker_wrapper);
|
||||
lv_obj_set_size(g_ctx.btn_picker_play, 58, 42);
|
||||
lv_obj_align(g_ctx.btn_picker_play, LV_ALIGN_BOTTOM_MID, 0, -10);
|
||||
lv_obj_set_style_radius(g_ctx.btn_picker_play, 21, 0);
|
||||
lv_obj_set_style_bg_color(g_ctx.btn_picker_play, lv_color_hex(0x89B4FA), 0);
|
||||
lv_obj_set_style_text_color(g_ctx.btn_picker_play, lv_color_hex(0x11111B), 0);
|
||||
lv_obj_t* lbl_picker_play = lv_label_create(g_ctx.btn_picker_play);
|
||||
lv_label_set_text(lbl_picker_play, LV_SYMBOL_PLAY);
|
||||
lv_obj_center(lbl_picker_play);
|
||||
lv_obj_add_event_cb(g_ctx.btn_picker_play, on_picker_play_click, LV_EVENT_CLICKED, &g_ctx);
|
||||
lv_obj_add_flag(g_ctx.btn_picker_play, LV_OBJ_FLAG_HIDDEN);
|
||||
|
||||
// A dedicated side exit control leaves the center cover, top title panel,
|
||||
// and bottom play target clear while reserving its own small touch area.
|
||||
lv_obj_t* btn_picker_close = lv_button_create(g_ctx.picker_wrapper);
|
||||
lv_obj_set_size(btn_picker_close, 42, 42);
|
||||
lv_obj_align(btn_picker_close, LV_ALIGN_RIGHT_MID, -8, 0);
|
||||
lv_obj_set_style_radius(btn_picker_close, 21, 0);
|
||||
lv_obj_set_style_bg_color(btn_picker_close, lv_color_hex(0xD64545), 0);
|
||||
lv_obj_set_style_text_color(btn_picker_close, lv_color_hex(0xFFFFFF), 0);
|
||||
lv_obj_t* lbl_picker_close = lv_label_create(btn_picker_close);
|
||||
lv_label_set_text(lbl_picker_close, LV_SYMBOL_CLOSE);
|
||||
lv_obj_center(lbl_picker_close);
|
||||
lv_obj_add_event_cb(btn_picker_close, on_picker_close_click, LV_EVENT_CLICKED, &g_ctx);
|
||||
|
||||
// 2. Player Screen wrapper (hidden on start)
|
||||
g_ctx.player_wrapper = lv_obj_create(parent);
|
||||
@@ -1063,9 +1256,16 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
}
|
||||
|
||||
static void onHideApp(AppHandle app, void* data) {
|
||||
wait_for_playback_task_to_exit(&g_ctx);
|
||||
wait_for_playback_task_to_exit(&g_ctx, false);
|
||||
close_stream_if_open(&g_ctx);
|
||||
|
||||
cancel_picker_cover_load(&g_ctx);
|
||||
clear_picker_cover(&g_ctx);
|
||||
if (g_ctx.img_page) {
|
||||
lv_image_set_src(g_ctx.img_page, LV_SYMBOL_IMAGE);
|
||||
g_ctx.player_image_path[0] = '\0';
|
||||
}
|
||||
|
||||
if (g_ctx.manifest_root) {
|
||||
cJSON_Delete(g_ctx.manifest_root);
|
||||
g_ctx.manifest_root = NULL;
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
#include <esp_random.h>
|
||||
#include <tt_lvgl_keyboard.h>
|
||||
|
||||
#include <tactility/lvgl_module.h>
|
||||
#include <tactility/lvgl_fonts.h>
|
||||
#include <lvgl/lvgl.h>
|
||||
#include <lvgl/fonts.h>
|
||||
|
||||
constexpr auto* TAG = "Breakout";
|
||||
|
||||
|
||||
@@ -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(LiveCaptions)
|
||||
tactility_project(LiveCaptions)
|
||||
@@ -0,0 +1,17 @@
|
||||
# Live Captions
|
||||
|
||||
ESP32-S3 external app that streams 16 kHz signed-16-bit mono microphone PCM to the Mac mini Hermes voice gateway and renders its live draft/final captions. It stores **only final captions** on the device SD card in `/sdcard/captions/YYYY-MM-DD.txt`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Before packaging, place `config.json` in the app's user-data directory (not source control):
|
||||
|
||||
```json
|
||||
{
|
||||
"server_url": "ws://192.168.68.102:8642/api/esp32/voice/ws",
|
||||
"device_id": "your-registered-device-id",
|
||||
"api_key": "device-profile-key"
|
||||
}
|
||||
```
|
||||
|
||||
The app never retries automatically: connection failure remains `FAILED` until the user selects **Start** again. **Stop** sends the gateway `stop` event, waits for the final caption, appends it to that day’s text file, then returns to idle.
|
||||
@@ -0,0 +1,8 @@
|
||||
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
|
||||
set(CJSON_SOURCE "$ENV{IDF_PATH}/components/json/cJSON/cJSON.c")
|
||||
|
||||
idf_component_register(
|
||||
SRCS ${SOURCE_FILES} ${CJSON_SOURCE}
|
||||
INCLUDE_DIRS Source "$ENV{IDF_PATH}/components/json/cJSON"
|
||||
REQUIRES TactilitySDK lwip
|
||||
)
|
||||
@@ -0,0 +1,328 @@
|
||||
/* Live Captions: stream mic PCM to Mac mini and save final text on the SD card. */
|
||||
#include <tt_app.h>
|
||||
#include <tt_lvgl.h>
|
||||
#include <tt_lvgl_toolbar.h>
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/drivers/audio_stream.h>
|
||||
|
||||
#include <cJSON.h>
|
||||
#include <esp_log.h>
|
||||
#include <esp_random.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
#include "websocket.h"
|
||||
|
||||
/* These legacy names are exported by the flashed 0.8.0-dev firmware. */
|
||||
struct Device* device_find_by_name(const char* name);
|
||||
struct Device* device_find_first_by_type(const struct DeviceType* type);
|
||||
|
||||
#define TAG "LiveCaptions"
|
||||
#define DEFAULT_ENDPOINT "ws://192.168.68.102:8645/api/esp32/captions/ws"
|
||||
#define DEFAULT_DEVICE_ID "tactility-14c19d1a790"
|
||||
#define PCM_BUFFER_BYTES 1024U
|
||||
#define EVENT_BUFFER_BYTES 4096U
|
||||
#define DISPLAY_WORD_LIMIT 50
|
||||
|
||||
typedef enum {
|
||||
CAPTION_IDLE,
|
||||
CAPTION_CONNECTING,
|
||||
CAPTION_LISTENING,
|
||||
CAPTION_PROCESSING,
|
||||
CAPTION_FAILED,
|
||||
} CaptionState;
|
||||
|
||||
typedef struct {
|
||||
AppHandle app;
|
||||
volatile bool visible;
|
||||
volatile bool capture_audio;
|
||||
volatile bool stop_requested;
|
||||
volatile bool session_active;
|
||||
volatile bool socket_failed;
|
||||
int fd;
|
||||
CaptionState state;
|
||||
char endpoint[128];
|
||||
char device_id[64];
|
||||
char api_key[128];
|
||||
char detail[96];
|
||||
char caption[768];
|
||||
char last_final[768];
|
||||
struct Device* stream_dev;
|
||||
AudioStreamHandle input_handle;
|
||||
TaskHandle_t worker;
|
||||
TaskHandle_t receiver;
|
||||
SemaphoreHandle_t socket_lock;
|
||||
SemaphoreHandle_t audio_lock;
|
||||
lv_obj_t* caption_label;
|
||||
lv_obj_t* status_label;
|
||||
} CaptionContext;
|
||||
|
||||
static void update_ui(CaptionContext* ctx) {
|
||||
if (!ctx->visible || !tt_lvgl_lock(pdMS_TO_TICKS(100))) return;
|
||||
lv_label_set_text(ctx->caption_label, ctx->caption);
|
||||
const char* status = ctx->state == CAPTION_LISTENING ? "Connected" : (ctx->state == CAPTION_CONNECTING ? "Connecting" : (ctx->state == CAPTION_FAILED ? "Failed — Reconnecting" : "Connecting"));
|
||||
lv_label_set_text(ctx->status_label, status);
|
||||
tt_lvgl_unlock();
|
||||
}
|
||||
|
||||
static void set_state(CaptionContext* ctx, CaptionState state, const char* detail) {
|
||||
ctx->state = state;
|
||||
snprintf(ctx->detail, sizeof(ctx->detail), "%s", detail ? detail : "");
|
||||
update_ui(ctx);
|
||||
}
|
||||
|
||||
static bool find_audio_stream_device(CaptionContext* ctx) {
|
||||
ctx->stream_dev = device_find_by_name("audio-stream");
|
||||
if (ctx->stream_dev == NULL) ctx->stream_dev = device_find_first_by_type(&AUDIO_STREAM_TYPE);
|
||||
return ctx->stream_dev != NULL;
|
||||
}
|
||||
|
||||
static bool open_input_stream(CaptionContext* ctx) {
|
||||
if (ctx->stream_dev == NULL) return false;
|
||||
if (ctx->input_handle != NULL) return true;
|
||||
struct AudioStreamConfig config = {.sample_rate = 16000, .bits_per_sample = 16, .channels = 1};
|
||||
if (audio_stream_open_input(ctx->stream_dev, &config, &ctx->input_handle) != ERROR_NONE) return false;
|
||||
audio_stream_set_mute(ctx->stream_dev, AUDIO_CODEC_DIR_INPUT, false);
|
||||
audio_stream_set_volume(ctx->stream_dev, AUDIO_CODEC_DIR_INPUT, 100.0f);
|
||||
return true;
|
||||
}
|
||||
|
||||
static void close_input_stream(CaptionContext* ctx) {
|
||||
if (ctx->input_handle != NULL) {
|
||||
audio_stream_close(ctx->input_handle);
|
||||
ctx->input_handle = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
static int send_locked(CaptionContext* ctx, const uint8_t* data, size_t length, bool binary) {
|
||||
if (ctx->fd < 0 || xSemaphoreTake(ctx->socket_lock, pdMS_TO_TICKS(500)) != pdTRUE) return -1;
|
||||
int result = ws_send(ctx->fd, data, length, binary);
|
||||
xSemaphoreGive(ctx->socket_lock);
|
||||
return result;
|
||||
}
|
||||
|
||||
static bool parse_endpoint(const char* url, char* host, size_t host_size, int* port, char* path, size_t path_size) {
|
||||
if (url == NULL || strncmp(url, "ws://", 5) != 0) return false;
|
||||
const char* authority = url + 5;
|
||||
const char* slash = strchr(authority, '/');
|
||||
const char* end = slash ? slash : authority + strlen(authority);
|
||||
const char* colon = NULL;
|
||||
for (const char* p = authority; p < end; ++p) if (*p == ':') colon = p;
|
||||
size_t host_len = (size_t)((colon ? colon : end) - authority);
|
||||
if (host_len == 0 || host_len >= host_size) return false;
|
||||
memcpy(host, authority, host_len); host[host_len] = '\0';
|
||||
*port = 80;
|
||||
if (colon != NULL) {
|
||||
*port = atoi(colon + 1);
|
||||
if (*port < 1 || *port > 65535) return false;
|
||||
}
|
||||
const char* wire_path = slash ? slash : "/";
|
||||
if (strlen(wire_path) >= path_size) return false;
|
||||
snprintf(path, path_size, "%s", wire_path);
|
||||
return true;
|
||||
}
|
||||
|
||||
static void append_final_caption(const char* text) {
|
||||
if (text == NULL || !*text) return;
|
||||
mkdir("/sdcard/captions", 0755);
|
||||
time_t now = time(NULL);
|
||||
struct tm local;
|
||||
localtime_r(&now, &local);
|
||||
char filename[32];
|
||||
strftime(filename, sizeof(filename), "%Y-%m-%d.txt", &local);
|
||||
char stamp[16];
|
||||
strftime(stamp, sizeof(stamp), "%H:%M:%S", &local);
|
||||
char path[128];
|
||||
snprintf(path, sizeof(path), "/sdcard/captions/%s", filename);
|
||||
FILE* log = fopen(path, "a");
|
||||
if (log == NULL) { ESP_LOGE(TAG, "could not append %s", path); return; }
|
||||
fprintf(log, "[%s] %s\n", stamp, text);
|
||||
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) {
|
||||
if (text == NULL || !*text) return;
|
||||
copy_recent_words(ctx->caption, sizeof(ctx->caption), text);
|
||||
if (final && strcmp(ctx->last_final, text) != 0) {
|
||||
snprintf(ctx->last_final, sizeof(ctx->last_final), "%s", text);
|
||||
append_final_caption(text);
|
||||
ctx->state = CAPTION_IDLE;
|
||||
}
|
||||
update_ui(ctx);
|
||||
}
|
||||
|
||||
static void handle_event(CaptionContext* ctx, const char* json) {
|
||||
cJSON* root = cJSON_Parse(json);
|
||||
if (root == NULL) return;
|
||||
cJSON* event = cJSON_GetObjectItem(root, "event");
|
||||
cJSON* text = cJSON_GetObjectItem(root, "text");
|
||||
if (!cJSON_IsString(event)) { cJSON_Delete(root); return; }
|
||||
const char* name = event->valuestring;
|
||||
if (strcmp(name, "ready") == 0) {
|
||||
set_state(ctx, CAPTION_CONNECTING, "Starting caption stream");
|
||||
} else if (strcmp(name, "listening") == 0) {
|
||||
set_state(ctx, CAPTION_LISTENING, "Listening — press Stop when finished");
|
||||
} 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);
|
||||
} else if (strcmp(name, "transcript") == 0 || strcmp(name, "final") == 0) {
|
||||
cJSON* is_final = cJSON_GetObjectItem(root, "isFinal");
|
||||
if (cJSON_IsString(text) && (!cJSON_IsBool(is_final) || cJSON_IsTrue(is_final) || strcmp(name, "final") == 0)) {
|
||||
display_caption(ctx, text->valuestring, true);
|
||||
}
|
||||
} else if (strcmp(name, "thinking") == 0) {
|
||||
set_state(ctx, CAPTION_PROCESSING, "Final captioning…");
|
||||
} else if (strcmp(name, "error") == 0) {
|
||||
ctx->socket_failed = true;
|
||||
set_state(ctx, CAPTION_FAILED, "Fail to connect");
|
||||
}
|
||||
cJSON_Delete(root);
|
||||
}
|
||||
|
||||
static void receiver_task(void* argument) {
|
||||
CaptionContext* ctx = argument;
|
||||
uint8_t* buffer = malloc(EVENT_BUFFER_BYTES + 1U);
|
||||
if (buffer == NULL) { ctx->socket_failed = true; ctx->receiver = NULL; vTaskDelete(NULL); }
|
||||
while (ctx->visible && ctx->fd >= 0) {
|
||||
int opcode = 0; bool complete = false;
|
||||
int received = ws_recv(ctx->fd, &opcode, &complete, buffer, EVENT_BUFFER_BYTES);
|
||||
if (received < 0 || !complete) break;
|
||||
if (opcode == 0x01) { buffer[received] = '\0'; handle_event(ctx, (const char*)buffer); }
|
||||
else if (opcode == 0x09) {
|
||||
if (xSemaphoreTake(ctx->socket_lock, pdMS_TO_TICKS(500)) == pdTRUE) {
|
||||
ws_send_pong(ctx->fd, buffer, (size_t)received); xSemaphoreGive(ctx->socket_lock);
|
||||
}
|
||||
} else if (opcode == 0x08) break;
|
||||
}
|
||||
ctx->session_active = false;
|
||||
ctx->receiver = NULL;
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
static void load_config(CaptionContext* ctx) {
|
||||
snprintf(ctx->endpoint, sizeof(ctx->endpoint), "%s", DEFAULT_ENDPOINT);
|
||||
snprintf(ctx->device_id, sizeof(ctx->device_id), "%s", DEFAULT_DEVICE_ID);
|
||||
ctx->api_key[0] = '\0';
|
||||
char path[256]; size_t size = sizeof(path);
|
||||
tt_app_get_user_data_child_path(ctx->app, "config.json", path, &size);
|
||||
FILE* file = fopen(path, "r");
|
||||
if (file == NULL) return;
|
||||
char raw[512]; size_t bytes = fread(raw, 1, sizeof(raw) - 1, file); fclose(file); raw[bytes] = '\0';
|
||||
cJSON* root = cJSON_Parse(raw);
|
||||
cJSON* endpoint = root ? cJSON_GetObjectItem(root, "server_url") : NULL;
|
||||
cJSON* device = root ? cJSON_GetObjectItem(root, "device_id") : NULL;
|
||||
cJSON* key = root ? cJSON_GetObjectItem(root, "api_key") : NULL;
|
||||
if (cJSON_IsString(endpoint)) snprintf(ctx->endpoint, sizeof(ctx->endpoint), "%s", endpoint->valuestring);
|
||||
if (cJSON_IsString(device)) snprintf(ctx->device_id, sizeof(ctx->device_id), "%s", device->valuestring);
|
||||
if (cJSON_IsString(key)) snprintf(ctx->api_key, sizeof(ctx->api_key), "%s", key->valuestring);
|
||||
cJSON_Delete(root);
|
||||
}
|
||||
|
||||
static void worker_task(void* argument) {
|
||||
CaptionContext* ctx = argument;
|
||||
char host[64], path[96]; int port = 0;
|
||||
if (!parse_endpoint(ctx->endpoint, host, sizeof(host), &port, path, sizeof(path))) {
|
||||
set_state(ctx, CAPTION_FAILED, "Fail to connect"); ctx->worker = NULL; vTaskDelete(NULL);
|
||||
}
|
||||
set_state(ctx, CAPTION_CONNECTING, "Connecting to Mac mini");
|
||||
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); }
|
||||
char start[256];
|
||||
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)) {
|
||||
set_state(ctx, CAPTION_FAILED, "Fail to connect"); ws_close(ctx->fd); ctx->fd = -1; ctx->worker = NULL; vTaskDelete(NULL);
|
||||
}
|
||||
ctx->session_active = true;
|
||||
xTaskCreate(receiver_task, "caption_rx", 6144, ctx, 6, &ctx->receiver);
|
||||
set_state(ctx, CAPTION_LISTENING, "Listening — press Stop when finished");
|
||||
uint8_t pcm[PCM_BUFFER_BYTES];
|
||||
while (ctx->visible && ctx->capture_audio && !ctx->socket_failed) {
|
||||
size_t bytes = 0;
|
||||
xSemaphoreTake(ctx->audio_lock, portMAX_DELAY);
|
||||
error_t result = audio_stream_read(ctx->input_handle, pcm, sizeof(pcm), &bytes, pdMS_TO_TICKS(100));
|
||||
xSemaphoreGive(ctx->audio_lock);
|
||||
if (result == ERROR_NONE && bytes > 0 && (bytes % 2U) == 0 && send_locked(ctx, pcm, bytes, true) != 0) ctx->socket_failed = true;
|
||||
}
|
||||
xSemaphoreTake(ctx->audio_lock, portMAX_DELAY); close_input_stream(ctx); xSemaphoreGive(ctx->audio_lock);
|
||||
if (ctx->stop_requested && !ctx->socket_failed) {
|
||||
const char* stop = "{\"event\":\"stop\"}";
|
||||
send_locked(ctx, (const uint8_t*)stop, strlen(stop), false);
|
||||
set_state(ctx, CAPTION_PROCESSING, "Final captioning…");
|
||||
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; }
|
||||
bool reconnect = ctx->socket_failed && ctx->visible;
|
||||
if (reconnect) set_state(ctx, CAPTION_FAILED, "Reconnecting");
|
||||
else if (ctx->state == CAPTION_PROCESSING) set_state(ctx, CAPTION_IDLE, "");
|
||||
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);
|
||||
}
|
||||
|
||||
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 on_create(AppHandle app, void* data) { ((CaptionContext*)data)->app = app; }
|
||||
|
||||
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);
|
||||
ctx->socket_lock = xSemaphoreCreateMutex(); ctx->audio_lock = xSemaphoreCreateMutex();
|
||||
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app);
|
||||
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));
|
||||
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_align(ctx->caption_label, LV_ALIGN_CENTER, 0, 0);
|
||||
ctx->status_label = lv_label_create(parent);
|
||||
lv_obj_align(ctx->status_label, LV_ALIGN_BOTTOM_MID, 0, -10);
|
||||
ctx->caption[0] = '\0';
|
||||
if (ctx->stream_dev != NULL && ctx->socket_lock != NULL && ctx->audio_lock != NULL) {
|
||||
ctx->capture_audio = true; ctx->stop_requested = false; ctx->socket_failed = false;
|
||||
xTaskCreate(worker_task, "caption_tx", 8192, ctx, 5, &ctx->worker);
|
||||
}
|
||||
}
|
||||
|
||||
static void on_hide(AppHandle app, void* data) {
|
||||
(void)app; CaptionContext* ctx = data; ctx->visible = false; ctx->capture_audio = false; ctx->stop_requested = false;
|
||||
if (ctx->fd >= 0) { ws_send_close(ctx->fd); ws_close(ctx->fd); ctx->fd = -1; }
|
||||
for (unsigned i = 0; (ctx->worker || ctx->receiver) && i < 100; ++i) vTaskDelay(pdMS_TO_TICKS(10));
|
||||
close_input_stream(ctx);
|
||||
if (ctx->socket_lock) { vSemaphoreDelete(ctx->socket_lock); ctx->socket_lock = NULL; }
|
||||
if (ctx->audio_lock) { vSemaphoreDelete(ctx->audio_lock); ctx->audio_lock = NULL; }
|
||||
}
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
(void)argc; (void)argv;
|
||||
tt_app_register((AppRegistration){.createData=create_data,.destroyData=destroy_data,.onCreate=on_create,.onShow=on_show,.onHide=on_hide});
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
#include "websocket.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <esp_log.h>
|
||||
#include <esp_random.h>
|
||||
#include <lwip/inet.h>
|
||||
#include <lwip/sockets.h>
|
||||
|
||||
#define TAG "PipecatVoiceWs"
|
||||
#define WS_HEADER_LIMIT 1024U
|
||||
#define WS_CONTROL_LIMIT 125U
|
||||
|
||||
static int send_all(int fd, const uint8_t* data, size_t length) {
|
||||
size_t sent = 0;
|
||||
while (sent < length) {
|
||||
int result = lwip_send(fd, data + sent, length - sent, 0);
|
||||
if (result <= 0) return -1;
|
||||
sent += (size_t)result;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int recv_all(int fd, uint8_t* data, size_t length) {
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
if (fd < 0) {
|
||||
ESP_LOGW(TAG, "socket create failed");
|
||||
return -1;
|
||||
}
|
||||
struct sockaddr_in address = {0};
|
||||
address.sin_family = AF_INET;
|
||||
address.sin_port = htons((uint16_t)port);
|
||||
address.sin_addr.s_addr = ipaddr_addr(host);
|
||||
if (address.sin_addr.s_addr == IPADDR_NONE) {
|
||||
ESP_LOGW(TAG, "endpoint address parse failed");
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
if (lwip_connect(fd, (struct sockaddr*)&address, sizeof(address)) < 0) {
|
||||
ESP_LOGW(TAG, "TCP connect failed");
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
struct timeval timeout = {.tv_sec = 15, .tv_usec = 0};
|
||||
lwip_setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
|
||||
char request[WS_HEADER_LIMIT];
|
||||
int request_length = snprintf(request, sizeof(request),
|
||||
"GET %s HTTP/1.1\r\nHost: %s:%d\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
|
||||
"Sec-WebSocket-Key: MDEyMzQ1Njc4OWFiY2RlZg==\r\nSec-WebSocket-Version: 13\r\n"
|
||||
"Authorization: Bearer %s\r\nX-Device-ID: %s\r\n\r\n",
|
||||
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);
|
||||
return -1;
|
||||
}
|
||||
response[++length] = '\0';
|
||||
if (length >= 4 && memcmp(response + length - 4, "\r\n\r\n", 4) == 0) break;
|
||||
}
|
||||
if (length + 1 >= sizeof(response) || strstr(response, " 101 ") == NULL) {
|
||||
ESP_LOGW(TAG, "WebSocket upgrade rejected");
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
ESP_LOGI(TAG, "WebSocket upgrade accepted");
|
||||
return fd;
|
||||
}
|
||||
|
||||
int ws_send(int fd, const uint8_t* data, size_t length, bool binary) {
|
||||
if (fd < 0 || data == NULL || length == 0) return -1;
|
||||
return send_frame(fd, binary ? 0x02U : 0x01U, data, length);
|
||||
}
|
||||
|
||||
int ws_recv(int fd, int* opcode, bool* final, uint8_t* payload, size_t maximum) {
|
||||
uint8_t header[2];
|
||||
if (fd < 0 || recv_all(fd, header, sizeof(header)) < 0) 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;
|
||||
uint8_t mask[4] = {0};
|
||||
if (masked && recv_all(fd, mask, sizeof(mask)) < 0) return -1;
|
||||
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;
|
||||
if (masked) for (size_t i = 0; i < (size_t)length; ++i) payload[i] ^= mask[i % sizeof(mask)];
|
||||
if (opcode) *opcode = frame_opcode;
|
||||
if (final) *final = (header[0] & 0x80U) != 0;
|
||||
return (int)length;
|
||||
}
|
||||
|
||||
int ws_send_pong(int fd, const uint8_t* payload, size_t length) { return send_frame(fd, 0x0aU, payload, length); }
|
||||
int ws_send_close(int fd) { return send_frame(fd, 0x08U, NULL, 0); }
|
||||
void ws_close(int fd) { if (fd >= 0) close(fd); }
|
||||
@@ -0,0 +1,62 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* Connect to a WebSocket server.
|
||||
* @param host Server IP address (e.g. "192.168.68.126")
|
||||
* @param port Port number (e.g. 8642)
|
||||
* @param path WebSocket path (e.g. "/api/esp32/voice/ws")
|
||||
* @param device_id Unique device identifier
|
||||
* @param api_key Optional profile API key; never compiled into firmware
|
||||
* @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* api_key);
|
||||
|
||||
/**
|
||||
* Send a WebSocket frame.
|
||||
* @param fd Socket file descriptor
|
||||
* @param data Data payload to send
|
||||
* @param len Length of the data payload
|
||||
* @param binary True for binary frame, false for text frame
|
||||
* @return 0 on success, or -1 on failure
|
||||
*/
|
||||
int ws_send(int fd, const uint8_t* data, size_t len, bool binary);
|
||||
|
||||
/**
|
||||
* Receive a WebSocket frame.
|
||||
* @param fd Socket file descriptor
|
||||
* @param out_opcode Pointer to store the received opcode (e.g. 0x01 text, 0x02 binary)
|
||||
* @param payload Buffer to store the received payload
|
||||
* @param max_len Maximum length of the payload buffer
|
||||
* @return Received payload length on success, -1 on connection failure, or -2 on buffer overflow
|
||||
*/
|
||||
int ws_recv(int fd, int* out_opcode, bool* out_final, uint8_t* payload, size_t max_len);
|
||||
|
||||
/**
|
||||
* Close a WebSocket connection.
|
||||
* @param fd Socket file descriptor
|
||||
*/
|
||||
void ws_close(int fd);
|
||||
|
||||
/**
|
||||
* Send a WebSocket PONG frame.
|
||||
* @param fd Socket file descriptor
|
||||
* @param payload Payload to reflect
|
||||
* @param len Length of payload
|
||||
* @return 0 on success, or -1 on failure
|
||||
*/
|
||||
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
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,7 @@
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32s3
|
||||
app.id=one.tactility.livecaptions
|
||||
app.version.name=1.0.0
|
||||
app.version.code=1
|
||||
app.name=Live Captions
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
- 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`)
|
||||
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.
|
||||
|
||||
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
|
||||
export TACTILITY_SDK_PATH=~/Projects/electronics/tactility/tactility/Buildscripts/TactilitySDK # or firmware/release/TactilitySDK if built
|
||||
|
||||
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.
|
||||
|
||||
## 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 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);
|
||||
@@ -2,239 +2,166 @@
|
||||
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <lwip/sockets.h>
|
||||
#include <lwip/inet.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) {
|
||||
size_t total = 0;
|
||||
char* p = (char*)buf;
|
||||
while (total < len) {
|
||||
int r = lwip_recv(fd, p + total, len - total, 0);
|
||||
if (r <= 0) {
|
||||
return -1;
|
||||
}
|
||||
total += r;
|
||||
#define TAG "PipecatVoiceWs"
|
||||
#define WS_HEADER_LIMIT 1024U
|
||||
#define WS_CONTROL_LIMIT 125U
|
||||
|
||||
static int send_all(int fd, const uint8_t* data, size_t length) {
|
||||
size_t sent = 0;
|
||||
while (sent < length) {
|
||||
int result = lwip_send(fd, data + sent, length - sent, 0);
|
||||
if (result <= 0) return -1;
|
||||
sent += (size_t)result;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static uint16_t my_htons(uint16_t val) {
|
||||
return (uint16_t)(((val & 0xff) << 8) | ((val & 0xff00) >> 8));
|
||||
static int recv_all(int fd, uint8_t* data, size_t length) {
|
||||
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);
|
||||
if (fd < 0) return -1;
|
||||
|
||||
struct sockaddr_in addr;
|
||||
memset(&addr, 0, sizeof(addr));
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = my_htons(port);
|
||||
addr.sin_addr.s_addr = ipaddr_addr(host);
|
||||
|
||||
if (lwip_connect(fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
|
||||
if (fd < 0) {
|
||||
ESP_LOGW(TAG, "socket create failed");
|
||||
return -1;
|
||||
}
|
||||
struct sockaddr_in address = {0};
|
||||
address.sin_family = AF_INET;
|
||||
address.sin_port = htons((uint16_t)port);
|
||||
address.sin_addr.s_addr = ipaddr_addr(host);
|
||||
if (address.sin_addr.s_addr == IPADDR_NONE) {
|
||||
ESP_LOGW(TAG, "endpoint address parse failed");
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Set socket receive timeout (e.g. 90 seconds) to prevent blocking indefinitely
|
||||
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) {
|
||||
if (lwip_connect(fd, (struct sockaddr*)&address, sizeof(address)) < 0) {
|
||||
ESP_LOGW(TAG, "TCP connect failed");
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Read HTTP response headers until we hit "\r\n\r\n"
|
||||
char header_buf[1024];
|
||||
size_t header_len = 0;
|
||||
while (header_len < sizeof(header_buf) - 1) {
|
||||
char c;
|
||||
int r = lwip_recv(fd, &c, 1, 0);
|
||||
if (r <= 0) {
|
||||
struct timeval timeout = {.tv_sec = 15, .tv_usec = 0};
|
||||
lwip_setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
|
||||
char request[WS_HEADER_LIMIT];
|
||||
int request_length = snprintf(request, sizeof(request),
|
||||
"GET %s HTTP/1.1\r\nHost: %s:%d\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
|
||||
"Sec-WebSocket-Key: MDEyMzQ1Njc4OWFiY2RlZg==\r\nSec-WebSocket-Version: 13\r\n"
|
||||
"Authorization: Bearer %s\r\nX-Device-ID: %s\r\n\r\n",
|
||||
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;
|
||||
}
|
||||
header_buf[header_len++] = c;
|
||||
header_buf[header_len] = '\0';
|
||||
|
||||
if (header_len >= 4 && strcmp(header_buf + header_len - 4, "\r\n\r\n") == 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify HTTP 101 Switching Protocols response status
|
||||
if (strstr(header_buf, "HTTP/1.1 101") == NULL && strstr(header_buf, "HTTP/1.0 101") == NULL) {
|
||||
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);
|
||||
return -1;
|
||||
}
|
||||
|
||||
response[++length] = '\0';
|
||||
if (length >= 4 && memcmp(response + length - 4, "\r\n\r\n", 4) == 0) break;
|
||||
}
|
||||
if (length + 1 >= sizeof(response) || strstr(response, " 101 ") == NULL) {
|
||||
ESP_LOGW(TAG, "WebSocket upgrade rejected");
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
ESP_LOGI(TAG, "WebSocket upgrade accepted");
|
||||
return fd;
|
||||
}
|
||||
|
||||
int ws_send(int fd, const uint8_t* data, size_t len, bool binary) {
|
||||
uint8_t header[10];
|
||||
size_t header_len = 0;
|
||||
|
||||
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;
|
||||
int ws_send(int fd, const uint8_t* data, size_t length, bool binary) {
|
||||
if (fd < 0 || data == NULL || length == 0) return -1;
|
||||
return send_frame(fd, binary ? 0x02U : 0x01U, data, length);
|
||||
}
|
||||
|
||||
// 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];
|
||||
if (recv_all(fd, header, 2) < 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int opcode = header[0] & 0x0F;
|
||||
if (out_opcode != NULL) {
|
||||
*out_opcode = opcode;
|
||||
}
|
||||
|
||||
int masked = (header[1] & 0x80) != 0;
|
||||
size_t len = header[1] & 0x7F;
|
||||
|
||||
if (len == 126) {
|
||||
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;
|
||||
if (fd < 0 || recv_all(fd, header, sizeof(header)) < 0) 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;
|
||||
uint8_t mask[4] = {0};
|
||||
if (masked && recv_all(fd, mask, sizeof(mask)) < 0) return -1;
|
||||
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 (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 (length > 0 && recv_all(fd, payload, (size_t)length) < 0) return -1;
|
||||
if (masked) for (size_t i = 0; i < (size_t)length; ++i) payload[i] ^= mask[i % sizeof(mask)];
|
||||
if (opcode) *opcode = frame_opcode;
|
||||
if (final) *final = (header[0] & 0x80U) != 0;
|
||||
return (int)length;
|
||||
}
|
||||
|
||||
if (recv_all(fd, payload, len) < 0) return -1;
|
||||
}
|
||||
|
||||
return (int)len;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
int ws_send_pong(int fd, const uint8_t* payload, size_t length) { return send_frame(fd, 0x0aU, payload, length); }
|
||||
int ws_send_close(int fd) { return send_frame(fd, 0x08U, NULL, 0); }
|
||||
void ws_close(int fd) { if (fd >= 0) close(fd); }
|
||||
@@ -14,10 +14,10 @@ extern "C" {
|
||||
* @param port Port number (e.g. 8642)
|
||||
* @param path WebSocket path (e.g. "/api/esp32/voice/ws")
|
||||
* @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
|
||||
*/
|
||||
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.
|
||||
@@ -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
|
||||
* @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.
|
||||
@@ -54,6 +54,9 @@ void ws_close(int fd);
|
||||
*/
|
||||
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
|
||||
}
|
||||
#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;
|
||||
}
|
||||
@@ -1,24 +1,32 @@
|
||||
#include <tt_app.h>
|
||||
#include <tt_lvgl.h>
|
||||
#include <tt_lvgl_toolbar.h>
|
||||
#include <tt_wifi.h>
|
||||
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/drivers/i2s_controller.h>
|
||||
#include <tactility/drivers/audio_stream.h>
|
||||
|
||||
#include "websocket.h"
|
||||
#include <cJSON.h>
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
#include <sys/stat.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/task.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_random.h"
|
||||
|
||||
#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 {
|
||||
STATE_IDLE,
|
||||
@@ -64,10 +72,19 @@ typedef struct {
|
||||
TaskHandle_t worker_task;
|
||||
TaskHandle_t rx_task;
|
||||
|
||||
// Hardware
|
||||
struct Device* i2s_dev;
|
||||
|
||||
// WebSocket
|
||||
// Hardware/audio service
|
||||
struct Device* audio_stream_dev;
|
||||
AudioStreamHandle input_handle;
|
||||
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;
|
||||
bool ws_connected;
|
||||
bool ws_done;
|
||||
@@ -81,6 +98,77 @@ static void reynabot_rx_task(void* arg);
|
||||
static void update_ui(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 ─── */
|
||||
static bool parse_ws_url(const char* url, char* host, int* port, char* path) {
|
||||
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 ─── */
|
||||
static void load_config(ReynaBotCtx* ctx) {
|
||||
// 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->api_key, sizeof(ctx->api_key), "hmek_sXB7921bZ9FXTVKARExqUZ7ttBxtEoURHRU0JCB-gNY");
|
||||
ctx->api_key[0] = '\0';
|
||||
|
||||
char path[256];
|
||||
size_t path_size = sizeof(path);
|
||||
@@ -130,7 +218,7 @@ static void load_config(ReynaBotCtx* ctx) {
|
||||
// Write default config file
|
||||
file = fopen(path, "w");
|
||||
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);
|
||||
}
|
||||
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) {
|
||||
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) {
|
||||
ctx->state = STATE_LISTENING;
|
||||
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);
|
||||
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) {
|
||||
/* Legacy event: metadata must still arrive as `audio` before binary PCM. */
|
||||
ctx->state = STATE_SPEAKING;
|
||||
ctx->ui_update_pending = true;
|
||||
|
||||
// I2S is already configured globally at session startup
|
||||
} else if (strcmp(evt, "audio_end") == 0) {
|
||||
ESP_LOGI(TAG, "Audio response ended");
|
||||
} 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 ─── */
|
||||
static void reynabot_rx_task(void* 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) {
|
||||
ESP_LOGE(TAG, "Failed to allocate RX buffer");
|
||||
ctx->ws_done = true;
|
||||
@@ -238,7 +372,7 @@ static void reynabot_rx_task(void* arg) {
|
||||
ESP_LOGI(TAG, "WS Receive task started");
|
||||
|
||||
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 == -1) {
|
||||
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)
|
||||
rx_buf[r] = '\0';
|
||||
parse_json_message(ctx, (char*)rx_buf);
|
||||
} else if (opcode == 0x02) { // Binary frame (Audio data)
|
||||
if (ctx->state == STATE_SPEAKING && ctx->i2s_dev != NULL) {
|
||||
const uint8_t* payload_ptr = rx_buf;
|
||||
size_t payload_len = r;
|
||||
|
||||
// Skip WAV header if present in the first chunk
|
||||
if (payload_len > 44 && memcmp(payload_ptr, "RIFF", 4) == 0) {
|
||||
payload_ptr += 44;
|
||||
payload_len -= 44;
|
||||
}
|
||||
|
||||
} else if (opcode == 0x02) { // Audio response PCM
|
||||
if (ctx->output_handle == NULL || ctx->expected_audio_bytes == 0 || (size_t)r != ctx->expected_audio_bytes) {
|
||||
ESP_LOGE(TAG, "Audio frame does not match metadata: got=%d expected=%u", r, (unsigned)ctx->expected_audio_bytes);
|
||||
ctx->state = STATE_ERROR;
|
||||
snprintf(ctx->error_message, sizeof(ctx->error_message), "Invalid audio frame");
|
||||
ctx->ui_update_pending = true;
|
||||
} else {
|
||||
size_t written_total = 0;
|
||||
while (written_total < (size_t)r) {
|
||||
size_t written = 0;
|
||||
device_lock(ctx->i2s_dev);
|
||||
i2s_controller_write(ctx->i2s_dev, payload_ptr, payload_len, &written, pdMS_TO_TICKS(100));
|
||||
device_unlock(ctx->i2s_dev);
|
||||
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);
|
||||
ctx->expected_audio_bytes = 0;
|
||||
ctx->state = STATE_THINKING;
|
||||
ctx->ui_update_pending = true;
|
||||
}
|
||||
} else if (opcode == 0x09) { // PING frame
|
||||
ESP_LOGI(TAG, "WS PING received, sending PONG");
|
||||
@@ -408,8 +552,11 @@ static void reynabot_task(void* arg) {
|
||||
ctx->start_session = false;
|
||||
ctx->stop_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_response[0] = '\0';
|
||||
ctx->error_message[0] = '\0';
|
||||
@@ -435,28 +582,17 @@ static void reynabot_task(void* arg) {
|
||||
ctx->ws_fd = fd;
|
||||
ctx->ws_connected = true;
|
||||
|
||||
// Configure I2S on demand for this session (16 kHz, 16-bit, mono)
|
||||
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
|
||||
// Spawn background RX task to read and parse events.
|
||||
xTaskCreate(reynabot_rx_task, "reynabot_rx", 4096, ctx, 6, &ctx->rx_task);
|
||||
|
||||
// Send start event handshake
|
||||
char start_json[256];
|
||||
// The Kids LAN adapter requires protocol v1 and a fresh session id.
|
||||
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),
|
||||
"{\"event\":\"start\",\"device_id\":\"%s\",\"sample_rate\":16000,\"channels\":1,\"sample_width\":2,\"format\":\"pcm_s16le\"}",
|
||||
ctx->device_id);
|
||||
"{\"v\":1,\"event\":\"start\",\"session_id\":\"%s\",\"device_id\":\"%s\",\"audio\":{\"format\":\"pcm_s16le\",\"sample_rate\":16000,\"channels\":1,\"sample_width\":2}}",
|
||||
session_id, ctx->device_id);
|
||||
if (ws_send(ctx->ws_fd, (const uint8_t*)start_json, strlen(start_json), false) < 0) {
|
||||
ctx->state = STATE_ERROR;
|
||||
snprintf(ctx->error_message, sizeof(ctx->error_message), "Handshake send failed");
|
||||
@@ -486,11 +622,20 @@ static void reynabot_task(void* arg) {
|
||||
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) {
|
||||
ESP_LOGE(TAG, "Failed to allocate record buffer");
|
||||
close_input_stream(ctx);
|
||||
ctx->state = STATE_ERROR;
|
||||
snprintf(ctx->error_message, sizeof(ctx->error_message), "Out of memory");
|
||||
ws_close(ctx->ws_fd);
|
||||
@@ -501,11 +646,12 @@ static void reynabot_task(void* arg) {
|
||||
}
|
||||
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) {
|
||||
size_t bytes_read = 0;
|
||||
error_t r = i2s_controller_read(ctx->i2s_dev, buffer, 1024, &bytes_read, pdMS_TO_TICKS(100));
|
||||
if (r == ERROR_NONE && bytes_read > 0) {
|
||||
error_t r = audio_stream_read(ctx->input_handle, buffer, MAX_PCM_CHUNK_BYTES,
|
||||
&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) {
|
||||
ESP_LOGE(TAG, "Audio stream send failed");
|
||||
break;
|
||||
@@ -514,16 +660,19 @@ static void reynabot_task(void* arg) {
|
||||
}
|
||||
}
|
||||
|
||||
close_input_stream(ctx);
|
||||
free(buffer);
|
||||
|
||||
if (ctx->cancel_session || total_sent_bytes < 3200) {
|
||||
ESP_LOGI(TAG, "Cancelling audio session");
|
||||
const char* cancel_json = "{\"event\":\"cancel\"}";
|
||||
ws_send(ctx->ws_fd, (const uint8_t*)cancel_json, strlen(cancel_json), false);
|
||||
ctx->stop_sent = true;
|
||||
ctx->state = STATE_IDLE;
|
||||
} else {
|
||||
const char* stop_json = "{\"event\":\"stop\"}";
|
||||
ws_send(ctx->ws_fd, (const uint8_t*)stop_json, strlen(stop_json), false);
|
||||
ctx->stop_sent = true;
|
||||
ctx->state = STATE_THINKING;
|
||||
update_ui(ctx);
|
||||
|
||||
@@ -550,12 +699,8 @@ static void reynabot_task(void* arg) {
|
||||
ctx->ws_connected = false;
|
||||
ws_close(fd_to_close);
|
||||
|
||||
// Reset I2S controller to release DMA and stop white noise
|
||||
if (ctx->i2s_dev != NULL) {
|
||||
device_lock(ctx->i2s_dev);
|
||||
i2s_controller_reset(ctx->i2s_dev);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
}
|
||||
close_input_stream(ctx);
|
||||
close_output_stream(ctx);
|
||||
|
||||
// Wait for receive task to exit
|
||||
int rx_timeout = 100;
|
||||
@@ -611,10 +756,9 @@ static void on_show(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
|
||||
load_config(ctx);
|
||||
|
||||
// Find I2S controller
|
||||
ctx->i2s_dev = device_find_by_name("i2s0");
|
||||
if (ctx->i2s_dev == NULL) {
|
||||
ESP_LOGE(TAG, "I2S controller 'i2s0' not found!");
|
||||
// Find the firmware Audio System service; it owns codec/native-rate conversion.
|
||||
if (!find_audio_stream_device(ctx)) {
|
||||
ESP_LOGE(TAG, "audio-stream device not found!");
|
||||
}
|
||||
|
||||
// Style the parent screen
|
||||
@@ -800,12 +944,8 @@ static void on_hide(AppHandle app, void* data) {
|
||||
ws_close(fd_to_close);
|
||||
}
|
||||
|
||||
// Reset I2S controller to stop DMA and looping noise
|
||||
if (ctx->i2s_dev != NULL) {
|
||||
device_lock(ctx->i2s_dev);
|
||||
i2s_controller_reset(ctx->i2s_dev);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
}
|
||||
close_input_stream(ctx);
|
||||
close_output_stream(ctx);
|
||||
|
||||
// Wait briefly for tasks to exit
|
||||
int timeout = 100;
|
||||
|
||||
@@ -56,8 +56,8 @@ int ws_connect(const char* host, int port, const char* path, const char* device_
|
||||
"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"
|
||||
"Authorization: Bearer %s\\r\\n"
|
||||
"X-Device-ID: %s\\r\\n"
|
||||
"\r\n",
|
||||
path, host, port, auth_key, device_id);
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ extern "C" {
|
||||
|
||||
/**
|
||||
* Connect to a WebSocket server.
|
||||
* @param host Server IP address (e.g. "192.168.68.126")
|
||||
* @param port Port number (e.g. 8642)
|
||||
* @param host Server IP address (for example, the Mac LAN host 192.168.68.112)
|
||||
* @param port Port number (e.g. 8643 for the Kids gateway)
|
||||
* @param path WebSocket path (e.g. "/api/esp32/voice/ws")
|
||||
* @param device_id Unique device identifier
|
||||
* @param auth_key Hermes Bearer API key
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
#include <cstring>
|
||||
#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
|
||||
#define M_PI 3.14159265358979323846f
|
||||
#endif
|
||||
|
||||
@@ -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())
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Source-level contract for the Live Captions external Tactility app.
|
||||
|
||||
The app is ELF-loaded firmware code, so this test guards the device protocol and
|
||||
persistence requirements before an ESP32-S3 build/install test is available.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
APP = ROOT / "Apps" / "LiveCaptions"
|
||||
|
||||
|
||||
class LiveCaptionsContractTests(unittest.TestCase):
|
||||
def test_manifest_identifies_the_captions_app(self):
|
||||
manifest = (APP / "manifest.properties").read_text()
|
||||
self.assertIn("app.id=one.tactility.livecaptions", manifest)
|
||||
self.assertIn("app.name=Live Captions", manifest)
|
||||
self.assertIn("target.platforms=esp32s3", manifest)
|
||||
|
||||
def test_app_streams_audio_and_logs_final_caption_to_daily_text_file(self):
|
||||
source = (APP / "main" / "Source" / "main.c").read_text()
|
||||
self.assertIn("audio_stream_open_input", source)
|
||||
self.assertIn("/sdcard/captions", source)
|
||||
self.assertIn('"%Y-%m-%d.txt"', source)
|
||||
self.assertIn("append_final_caption", source)
|
||||
self.assertIn('"draft"', source)
|
||||
self.assertIn('"interim_transcript"', source)
|
||||
self.assertIn('"transcript"', source)
|
||||
self.assertIn('"final"', source)
|
||||
self.assertIn("const char* stop", 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):
|
||||
source = (APP / "main" / "Source" / "main.c").read_text()
|
||||
self.assertIn("CAPTION_FAILED", source)
|
||||
self.assertIn("Fail to connect", source)
|
||||
self.assertNotIn("retry_attempt", source)
|
||||
self.assertNotIn("retry scheduled", source)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user