From b868b92a5c57e9eb171d47f2ec8030258859d0e4 Mon Sep 17 00:00:00 2001 From: Atsushi Morimoto <74th.tech@gmail.com> Date: Sun, 10 May 2026 16:37:54 +0900 Subject: [PATCH] feat: add support for stored file transfer and wake word sound playback - Introduced new protobuf messages for handling stored file transfers, including `StoredFileStart`, `FileChunk`, and `StoredFileEnd`. - Implemented methods to encode and send stored file messages over WebSocket. - Added functionality to load and normalize a wake word sound from an environment variable, ensuring it meets the required audio specifications. - Enhanced the `WsProxy` class to send the default wake word sound upon connection if configured. - Included a new WAV file for the wake word sound in the repository. --- .vscode/settings.json | 3 +- README.md | 10 +- docs/websocket_protocols_ja.md | 24 + firmware/include/protocols.hpp | 8 + firmware/include/stored_files.hpp | 73 +++ .../generated_protobuf/websocket-message.pb.c | 9 + .../generated_protobuf/websocket-message.pb.h | 81 ++- firmware/src/listening.cpp | 6 + firmware/src/main.cpp | 186 ++++++- firmware/src/protocols.cpp | 28 + firmware/src/stored_files.cpp | 494 ++++++++++++++++++ misc/wake_sound/wake_sound.wav | Bin 0 -> 38478 bytes protobuf/websocket-message.options | 3 + protobuf/websocket-message.proto | 18 + stackchan_server/app.py | 6 + .../websocket_message_pb2.py | 90 ++-- stackchan_server/protobuf_ws.py | 45 ++ stackchan_server/wakeword_sound.py | 201 +++++++ stackchan_server/ws_proxy.py | 83 +++ 19 files changed, 1318 insertions(+), 50 deletions(-) create mode 100644 firmware/include/stored_files.hpp create mode 100644 firmware/src/stored_files.cpp create mode 100644 misc/wake_sound/wake_sound.wav create mode 100644 stackchan_server/wakeword_sound.py diff --git a/.vscode/settings.json b/.vscode/settings.json index c02be26..b7cce9f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,6 +11,7 @@ "istream": "cpp", "numeric": "cpp", "ostream": "cpp", - "sstream": "cpp" + "sstream": "cpp", + "chrono": "cpp" } } diff --git a/README.md b/README.md index 1d5e197..6ece298 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,14 @@ async def talk_session(proxy: WsProxy): await proxy.speak(resp.text) ``` +`StackChanApp()` は既定で、WebSocket 接続直後に WakeWord 検出通知音をデバイスへ送信しようとします。 +送信する音は環境変数 `STACKCHAN_WAKEWORD_SOUND_PATH` で指定した WAV ファイルから読み込みます。 +読み込んだ WAV は送信前に 16-bit PCM / 24kHz / mono へ正規化されます。 +さらに短い通知音でも再生しやすいよう、送信前に前後へ短い無音を付与し、最小再生長を確保します。 +この値が未設定なら通知音は送信されません。 +送信された音はデバイス側で SPIFFS に保存され、WakeWord 検出時にローカル再生されます。 +接続時送信の機能自体を無効化したい場合は `StackChanApp(send_wakeword_sound_on_connect=False)` を使ってください。 + ## セットアップ 以下を確認ください。 @@ -101,7 +109,7 @@ async def talk_session(proxy: WsProxy): - M5Stack CoreS3(SKU:K128, K128-Lite, K128-SE) - M5Stack Atom S3R(SKU:C126) + Atomic Echo Base(SKU:A149) - M5Stack公式StackChan(SKU:K151) - + - M5Stack Atom EchoS3R - サーボ(なくても動作します): - Tower Pro SG90 - FEETECH SCS0009 diff --git a/docs/websocket_protocols_ja.md b/docs/websocket_protocols_ja.md index b816c13..ee765e2 100644 --- a/docs/websocket_protocols_ja.md +++ b/docs/websocket_protocols_ja.md @@ -35,6 +35,7 @@ | `SpeakDoneEvt` | CoreS3 → Server | 音声再生完了通知 | | `ServoCmd` | Server → CoreS3 | サーボ動作シーケンス指示 | | `ServoDoneEvt` | CoreS3 → Server | サーボ動作完了通知 | +| `StoredFile` | Server → CoreS3 | SPIFFS 保存用の汎用ファイル転送 | ### `MessageType` 一覧 @@ -136,6 +137,29 @@ - CoreS3 側の音声再生完了を通知します。 - Server はこの通知を待って `proxy.speak()` を完了させます。 +## 保存ファイル転送 `StoredFile` + +- 方向: Server → CoreS3 +- 用途: バイナリファイルを WebSocket 経由で配布し、CoreS3 側で SPIFFS に保存するための汎用転送です。 +- 1 転送の流れは `StoredFileStart` → `FileChunk` 複数回 → `StoredFileEnd` です。 + +### body 形式 + +| messageType | body | +| --- | --- | +| `START` | `StoredFileStart { file_id, content_type, total_size, sample_rate, channels }` | +| `DATA` | `FileChunk { chunk_bytes }` | +| `END` | `StoredFileEnd {}` | + +### 現行実装メモ + +- `file_id` はファイルの論理名です。現在の実装では `wakeword-detected-sound` が WakeUpWord 検出音に使われます。 +- `content_type` は現在 `audio/pcm` をサポートします。 +- `sample_rate` / `channels` は PCM 再生用の追加メタデータです。 +- CoreS3 は受信完了後に SPIFFS へ保存し、**その WebSocket 接続中に受信したファイルだけ** を有効化します。 +- 再接続後にサーバーが同じファイルを再送しない場合、SPIFFS に過去データが残っていても再生には使いません。 +- WakeUpWord 検出時は、該当サウンドが現在の接続で有効になっている場合のみローカル再生してから `WakeWordEvt` を送信します。 + ## サーボ動作指示 `ServoCmd` - 方向: Server → CoreS3 diff --git a/firmware/include/protocols.hpp b/firmware/include/protocols.hpp index 89cb7d6..c5de57f 100644 --- a/firmware/include/protocols.hpp +++ b/firmware/include/protocols.hpp @@ -8,6 +8,7 @@ #include "../lib/generated_protobuf/websocket-message.pb.h" constexpr size_t kProtoAudioChunkMaxBytes = 4096; +constexpr size_t kProtoFileChunkMaxBytes = 4096; constexpr size_t kProtoServoCommandMaxCount = 255; constexpr size_t kMaxEncodedWebSocketMessageBytes = stackchan_websocket_v1_WebSocketMessage_size; @@ -18,6 +19,13 @@ bool setProtoAudioChunk( const uint8_t *getProtoAudioChunkBytes(const stackchan_websocket_v1_AudioChunk &chunk); size_t getProtoAudioChunkSize(const stackchan_websocket_v1_AudioChunk &chunk); +bool setProtoFileChunk( + stackchan_websocket_v1_FileChunk &chunk, + const uint8_t *data, + size_t data_len); +const uint8_t *getProtoFileChunkBytes(const stackchan_websocket_v1_FileChunk &chunk); +size_t getProtoFileChunkSize(const stackchan_websocket_v1_FileChunk &chunk); + bool encodeWebSocketMessage( const stackchan_websocket_v1_WebSocketMessage &message, std::vector &encoded); diff --git a/firmware/include/stored_files.hpp b/firmware/include/stored_files.hpp new file mode 100644 index 0000000..7778d56 --- /dev/null +++ b/firmware/include/stored_files.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include +#include +#include +#include + +#include "protocols.hpp" + +struct StoredFileView +{ + const uint8_t *data = nullptr; + size_t size = 0; + uint32_t sample_rate = 0; + uint16_t channels = 0; +}; + +class StoredFiles +{ +public: + void init(); + void resetSession(); + + bool handleStart(uint32_t seq, const stackchan_websocket_v1_StoredFileStart &start); + bool handleData(uint32_t seq, const uint8_t *data, size_t data_len); + bool handleEnd(uint32_t seq); + + bool getActivePcmFile(const char *fileId, StoredFileView &view); + +private: + static constexpr size_t kMaxStoredFiles = 4; + static constexpr size_t kMaxStoredFileBytes = 256 * 1024; + + struct PersistedSlot + { + bool used = false; + char file_id[64] = ""; + char content_type[64] = ""; + uint32_t sample_rate = 0; + uint32_t channels = 0; + uint32_t size = 0; + }; + + struct TransferState + { + bool active = false; + uint32_t next_seq = 0; + int slot_index = -1; + uint32_t received_bytes = 0; + uint32_t chunk_count = 0; + PersistedSlot slot{}; + std::vector payload; + }; + + bool storage_ready_ = false; + std::array slots_{}; + std::array session_active_{}; + int cached_slot_index_ = -1; + std::vector cached_payload_; + TransferState transfer_; + + bool mountSpiffs(); + bool loadIndex(); + bool persistIndex(); + bool persistSlotPayload(int slotIndex, const std::vector &payload); + bool loadSlotPayload(int slotIndex, std::vector &payload); + int findSlotById(const char *fileId) const; + int selectSlotForId(const char *fileId); + void resetTransfer(); + bool activateSlot(int slotIndex, const std::vector &payload); + static const char *payloadPathForSlot(int slotIndex); + static const char *indexPath(); +}; diff --git a/firmware/lib/generated_protobuf/websocket-message.pb.c b/firmware/lib/generated_protobuf/websocket-message.pb.c index f70a79e..2d976af 100644 --- a/firmware/lib/generated_protobuf/websocket-message.pb.c +++ b/firmware/lib/generated_protobuf/websocket-message.pb.c @@ -24,6 +24,9 @@ PB_BIND(stackchan_websocket_v1_AudioWavEnd, stackchan_websocket_v1_AudioWavEnd, PB_BIND(stackchan_websocket_v1_AudioChunk, stackchan_websocket_v1_AudioChunk, 4) +PB_BIND(stackchan_websocket_v1_FileChunk, stackchan_websocket_v1_FileChunk, 4) + + PB_BIND(stackchan_websocket_v1_StateCommand, stackchan_websocket_v1_StateCommand, AUTO) @@ -36,6 +39,12 @@ PB_BIND(stackchan_websocket_v1_StateEvent, stackchan_websocket_v1_StateEvent, AU PB_BIND(stackchan_websocket_v1_SpeakDoneEvent, stackchan_websocket_v1_SpeakDoneEvent, AUTO) +PB_BIND(stackchan_websocket_v1_StoredFileStart, stackchan_websocket_v1_StoredFileStart, AUTO) + + +PB_BIND(stackchan_websocket_v1_StoredFileEnd, stackchan_websocket_v1_StoredFileEnd, AUTO) + + PB_BIND(stackchan_websocket_v1_ServoCommandSequence, stackchan_websocket_v1_ServoCommandSequence, 2) diff --git a/firmware/lib/generated_protobuf/websocket-message.pb.h b/firmware/lib/generated_protobuf/websocket-message.pb.h index 8e0c222..a668a7e 100644 --- a/firmware/lib/generated_protobuf/websocket-message.pb.h +++ b/firmware/lib/generated_protobuf/websocket-message.pb.h @@ -21,7 +21,8 @@ typedef enum _stackchan_websocket_v1_MessageKind { stackchan_websocket_v1_MessageKind_MESSAGE_KIND_SERVO_CMD = 7, stackchan_websocket_v1_MessageKind_MESSAGE_KIND_SERVO_DONE_EVT = 8, stackchan_websocket_v1_MessageKind_MESSAGE_KIND_FIRMWARE_METADATA = 9, - stackchan_websocket_v1_MessageKind_MESSAGE_KIND_SERVER_METADATA = 10 + stackchan_websocket_v1_MessageKind_MESSAGE_KIND_SERVER_METADATA = 10, + stackchan_websocket_v1_MessageKind_MESSAGE_KIND_STORED_FILE = 11 } stackchan_websocket_v1_MessageKind; typedef enum _stackchan_websocket_v1_MessageType { @@ -81,6 +82,11 @@ typedef struct _stackchan_websocket_v1_AudioChunk { stackchan_websocket_v1_AudioChunk_pcm_bytes_t pcm_bytes; } stackchan_websocket_v1_AudioChunk; +typedef PB_BYTES_ARRAY_T(4096) stackchan_websocket_v1_FileChunk_chunk_bytes_t; +typedef struct _stackchan_websocket_v1_FileChunk { + stackchan_websocket_v1_FileChunk_chunk_bytes_t chunk_bytes; +} stackchan_websocket_v1_FileChunk; + typedef struct _stackchan_websocket_v1_StateCommand { stackchan_websocket_v1_StackchanState state; } stackchan_websocket_v1_StateCommand; @@ -97,6 +103,18 @@ typedef struct _stackchan_websocket_v1_SpeakDoneEvent { bool done; } stackchan_websocket_v1_SpeakDoneEvent; +typedef struct _stackchan_websocket_v1_StoredFileStart { + char file_id[64]; + char content_type[64]; + uint32_t total_size; + uint32_t sample_rate; + uint32_t channels; +} stackchan_websocket_v1_StoredFileStart; + +typedef struct _stackchan_websocket_v1_StoredFileEnd { + char dummy_field; +} stackchan_websocket_v1_StoredFileEnd; + typedef struct _stackchan_websocket_v1_ServoCommand { stackchan_websocket_v1_ServoOperation op; int32_t angle; /* used by MOVE_X / MOVE_Y */ @@ -155,6 +173,9 @@ typedef struct _stackchan_websocket_v1_WebSocketMessage { stackchan_websocket_v1_ServoDoneEvent servo_done_evt; stackchan_websocket_v1_FirmwareMetadata firmware_metadata; stackchan_websocket_v1_ServerMetadata server_metadata; + stackchan_websocket_v1_StoredFileStart stored_file_start; + stackchan_websocket_v1_FileChunk stored_file_data; + stackchan_websocket_v1_StoredFileEnd stored_file_end; } body; } stackchan_websocket_v1_WebSocketMessage; @@ -165,8 +186,8 @@ extern "C" { /* Helper constants for enums */ #define _stackchan_websocket_v1_MessageKind_MIN stackchan_websocket_v1_MessageKind_MESSAGE_KIND_UNSPECIFIED -#define _stackchan_websocket_v1_MessageKind_MAX stackchan_websocket_v1_MessageKind_MESSAGE_KIND_SERVER_METADATA -#define _stackchan_websocket_v1_MessageKind_ARRAYSIZE ((stackchan_websocket_v1_MessageKind)(stackchan_websocket_v1_MessageKind_MESSAGE_KIND_SERVER_METADATA+1)) +#define _stackchan_websocket_v1_MessageKind_MAX stackchan_websocket_v1_MessageKind_MESSAGE_KIND_STORED_FILE +#define _stackchan_websocket_v1_MessageKind_ARRAYSIZE ((stackchan_websocket_v1_MessageKind)(stackchan_websocket_v1_MessageKind_MESSAGE_KIND_STORED_FILE+1)) #define _stackchan_websocket_v1_MessageType_MIN stackchan_websocket_v1_MessageType_MESSAGE_TYPE_UNSPECIFIED #define _stackchan_websocket_v1_MessageType_MAX stackchan_websocket_v1_MessageType_MESSAGE_TYPE_END @@ -196,6 +217,7 @@ extern "C" { + #define stackchan_websocket_v1_StateCommand_state_ENUMTYPE stackchan_websocket_v1_StackchanState @@ -203,6 +225,8 @@ extern "C" { + + #define stackchan_websocket_v1_ServoCommand_op_ENUMTYPE stackchan_websocket_v1_ServoOperation @@ -218,10 +242,13 @@ extern "C" { #define stackchan_websocket_v1_AudioWavStart_init_default {0, 0} #define stackchan_websocket_v1_AudioWavEnd_init_default {0} #define stackchan_websocket_v1_AudioChunk_init_default {{0, {0}}} +#define stackchan_websocket_v1_FileChunk_init_default {{0, {0}}} #define stackchan_websocket_v1_StateCommand_init_default {_stackchan_websocket_v1_StackchanState_MIN} #define stackchan_websocket_v1_WakeWordEvent_init_default {0} #define stackchan_websocket_v1_StateEvent_init_default {_stackchan_websocket_v1_StackchanState_MIN} #define stackchan_websocket_v1_SpeakDoneEvent_init_default {0} +#define stackchan_websocket_v1_StoredFileStart_init_default {"", "", 0, 0, 0} +#define stackchan_websocket_v1_StoredFileEnd_init_default {0} #define stackchan_websocket_v1_ServoCommandSequence_init_default {0, {stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default, stackchan_websocket_v1_ServoCommand_init_default}} #define stackchan_websocket_v1_ServoCommand_init_default {_stackchan_websocket_v1_ServoOperation_MIN, 0, 0} #define stackchan_websocket_v1_ServoDoneEvent_init_default {0} @@ -233,10 +260,13 @@ extern "C" { #define stackchan_websocket_v1_AudioWavStart_init_zero {0, 0} #define stackchan_websocket_v1_AudioWavEnd_init_zero {0} #define stackchan_websocket_v1_AudioChunk_init_zero {{0, {0}}} +#define stackchan_websocket_v1_FileChunk_init_zero {{0, {0}}} #define stackchan_websocket_v1_StateCommand_init_zero {_stackchan_websocket_v1_StackchanState_MIN} #define stackchan_websocket_v1_WakeWordEvent_init_zero {0} #define stackchan_websocket_v1_StateEvent_init_zero {_stackchan_websocket_v1_StackchanState_MIN} #define stackchan_websocket_v1_SpeakDoneEvent_init_zero {0} +#define stackchan_websocket_v1_StoredFileStart_init_zero {"", "", 0, 0, 0} +#define stackchan_websocket_v1_StoredFileEnd_init_zero {0} #define stackchan_websocket_v1_ServoCommandSequence_init_zero {0, {stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero, stackchan_websocket_v1_ServoCommand_init_zero}} #define stackchan_websocket_v1_ServoCommand_init_zero {_stackchan_websocket_v1_ServoOperation_MIN, 0, 0} #define stackchan_websocket_v1_ServoDoneEvent_init_zero {0} @@ -247,10 +277,16 @@ extern "C" { #define stackchan_websocket_v1_AudioWavStart_sample_rate_tag 1 #define stackchan_websocket_v1_AudioWavStart_channels_tag 2 #define stackchan_websocket_v1_AudioChunk_pcm_bytes_tag 1 +#define stackchan_websocket_v1_FileChunk_chunk_bytes_tag 1 #define stackchan_websocket_v1_StateCommand_state_tag 1 #define stackchan_websocket_v1_WakeWordEvent_detected_tag 1 #define stackchan_websocket_v1_StateEvent_state_tag 1 #define stackchan_websocket_v1_SpeakDoneEvent_done_tag 1 +#define stackchan_websocket_v1_StoredFileStart_file_id_tag 1 +#define stackchan_websocket_v1_StoredFileStart_content_type_tag 2 +#define stackchan_websocket_v1_StoredFileStart_total_size_tag 3 +#define stackchan_websocket_v1_StoredFileStart_sample_rate_tag 4 +#define stackchan_websocket_v1_StoredFileStart_channels_tag 5 #define stackchan_websocket_v1_ServoCommand_op_tag 1 #define stackchan_websocket_v1_ServoCommand_angle_tag 2 #define stackchan_websocket_v1_ServoCommand_duration_ms_tag 3 @@ -283,6 +319,9 @@ extern "C" { #define stackchan_websocket_v1_WebSocketMessage_servo_done_evt_tag 35 #define stackchan_websocket_v1_WebSocketMessage_firmware_metadata_tag 36 #define stackchan_websocket_v1_WebSocketMessage_server_metadata_tag 37 +#define stackchan_websocket_v1_WebSocketMessage_stored_file_start_tag 40 +#define stackchan_websocket_v1_WebSocketMessage_stored_file_data_tag 41 +#define stackchan_websocket_v1_WebSocketMessage_stored_file_end_tag 42 /* Struct field encoding specification for nanopb */ #define stackchan_websocket_v1_WebSocketMessage_FIELDLIST(X, a) \ @@ -302,7 +341,10 @@ X(a, STATIC, ONEOF, MESSAGE, (body,speak_done_evt,body.speak_done_evt), 3 X(a, STATIC, ONEOF, MESSAGE, (body,servo_cmd,body.servo_cmd), 34) \ X(a, STATIC, ONEOF, MESSAGE, (body,servo_done_evt,body.servo_done_evt), 35) \ X(a, STATIC, ONEOF, MESSAGE, (body,firmware_metadata,body.firmware_metadata), 36) \ -X(a, STATIC, ONEOF, MESSAGE, (body,server_metadata,body.server_metadata), 37) +X(a, STATIC, ONEOF, MESSAGE, (body,server_metadata,body.server_metadata), 37) \ +X(a, STATIC, ONEOF, MESSAGE, (body,stored_file_start,body.stored_file_start), 40) \ +X(a, STATIC, ONEOF, MESSAGE, (body,stored_file_data,body.stored_file_data), 41) \ +X(a, STATIC, ONEOF, MESSAGE, (body,stored_file_end,body.stored_file_end), 42) #define stackchan_websocket_v1_WebSocketMessage_CALLBACK NULL #define stackchan_websocket_v1_WebSocketMessage_DEFAULT NULL #define stackchan_websocket_v1_WebSocketMessage_body_audio_pcm_start_MSGTYPE stackchan_websocket_v1_AudioPcmStart @@ -319,6 +361,9 @@ X(a, STATIC, ONEOF, MESSAGE, (body,server_metadata,body.server_metadata), #define stackchan_websocket_v1_WebSocketMessage_body_servo_done_evt_MSGTYPE stackchan_websocket_v1_ServoDoneEvent #define stackchan_websocket_v1_WebSocketMessage_body_firmware_metadata_MSGTYPE stackchan_websocket_v1_FirmwareMetadata #define stackchan_websocket_v1_WebSocketMessage_body_server_metadata_MSGTYPE stackchan_websocket_v1_ServerMetadata +#define stackchan_websocket_v1_WebSocketMessage_body_stored_file_start_MSGTYPE stackchan_websocket_v1_StoredFileStart +#define stackchan_websocket_v1_WebSocketMessage_body_stored_file_data_MSGTYPE stackchan_websocket_v1_FileChunk +#define stackchan_websocket_v1_WebSocketMessage_body_stored_file_end_MSGTYPE stackchan_websocket_v1_StoredFileEnd #define stackchan_websocket_v1_AudioPcmStart_FIELDLIST(X, a) \ @@ -346,6 +391,11 @@ X(a, STATIC, SINGULAR, BYTES, pcm_bytes, 1) #define stackchan_websocket_v1_AudioChunk_CALLBACK NULL #define stackchan_websocket_v1_AudioChunk_DEFAULT NULL +#define stackchan_websocket_v1_FileChunk_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, BYTES, chunk_bytes, 1) +#define stackchan_websocket_v1_FileChunk_CALLBACK NULL +#define stackchan_websocket_v1_FileChunk_DEFAULT NULL + #define stackchan_websocket_v1_StateCommand_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, UENUM, state, 1) #define stackchan_websocket_v1_StateCommand_CALLBACK NULL @@ -366,6 +416,20 @@ X(a, STATIC, SINGULAR, BOOL, done, 1) #define stackchan_websocket_v1_SpeakDoneEvent_CALLBACK NULL #define stackchan_websocket_v1_SpeakDoneEvent_DEFAULT NULL +#define stackchan_websocket_v1_StoredFileStart_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, STRING, file_id, 1) \ +X(a, STATIC, SINGULAR, STRING, content_type, 2) \ +X(a, STATIC, SINGULAR, UINT32, total_size, 3) \ +X(a, STATIC, SINGULAR, UINT32, sample_rate, 4) \ +X(a, STATIC, SINGULAR, UINT32, channels, 5) +#define stackchan_websocket_v1_StoredFileStart_CALLBACK NULL +#define stackchan_websocket_v1_StoredFileStart_DEFAULT NULL + +#define stackchan_websocket_v1_StoredFileEnd_FIELDLIST(X, a) \ + +#define stackchan_websocket_v1_StoredFileEnd_CALLBACK NULL +#define stackchan_websocket_v1_StoredFileEnd_DEFAULT NULL + #define stackchan_websocket_v1_ServoCommandSequence_FIELDLIST(X, a) \ X(a, STATIC, REPEATED, MESSAGE, commands, 1) #define stackchan_websocket_v1_ServoCommandSequence_CALLBACK NULL @@ -408,10 +472,13 @@ extern const pb_msgdesc_t stackchan_websocket_v1_AudioPcmEnd_msg; extern const pb_msgdesc_t stackchan_websocket_v1_AudioWavStart_msg; extern const pb_msgdesc_t stackchan_websocket_v1_AudioWavEnd_msg; extern const pb_msgdesc_t stackchan_websocket_v1_AudioChunk_msg; +extern const pb_msgdesc_t stackchan_websocket_v1_FileChunk_msg; extern const pb_msgdesc_t stackchan_websocket_v1_StateCommand_msg; extern const pb_msgdesc_t stackchan_websocket_v1_WakeWordEvent_msg; extern const pb_msgdesc_t stackchan_websocket_v1_StateEvent_msg; extern const pb_msgdesc_t stackchan_websocket_v1_SpeakDoneEvent_msg; +extern const pb_msgdesc_t stackchan_websocket_v1_StoredFileStart_msg; +extern const pb_msgdesc_t stackchan_websocket_v1_StoredFileEnd_msg; extern const pb_msgdesc_t stackchan_websocket_v1_ServoCommandSequence_msg; extern const pb_msgdesc_t stackchan_websocket_v1_ServoCommand_msg; extern const pb_msgdesc_t stackchan_websocket_v1_ServoDoneEvent_msg; @@ -425,10 +492,13 @@ extern const pb_msgdesc_t stackchan_websocket_v1_ServerMetadata_msg; #define stackchan_websocket_v1_AudioWavStart_fields &stackchan_websocket_v1_AudioWavStart_msg #define stackchan_websocket_v1_AudioWavEnd_fields &stackchan_websocket_v1_AudioWavEnd_msg #define stackchan_websocket_v1_AudioChunk_fields &stackchan_websocket_v1_AudioChunk_msg +#define stackchan_websocket_v1_FileChunk_fields &stackchan_websocket_v1_FileChunk_msg #define stackchan_websocket_v1_StateCommand_fields &stackchan_websocket_v1_StateCommand_msg #define stackchan_websocket_v1_WakeWordEvent_fields &stackchan_websocket_v1_WakeWordEvent_msg #define stackchan_websocket_v1_StateEvent_fields &stackchan_websocket_v1_StateEvent_msg #define stackchan_websocket_v1_SpeakDoneEvent_fields &stackchan_websocket_v1_SpeakDoneEvent_msg +#define stackchan_websocket_v1_StoredFileStart_fields &stackchan_websocket_v1_StoredFileStart_msg +#define stackchan_websocket_v1_StoredFileEnd_fields &stackchan_websocket_v1_StoredFileEnd_msg #define stackchan_websocket_v1_ServoCommandSequence_fields &stackchan_websocket_v1_ServoCommandSequence_msg #define stackchan_websocket_v1_ServoCommand_fields &stackchan_websocket_v1_ServoCommand_msg #define stackchan_websocket_v1_ServoDoneEvent_fields &stackchan_websocket_v1_ServoDoneEvent_msg @@ -442,6 +512,7 @@ extern const pb_msgdesc_t stackchan_websocket_v1_ServerMetadata_msg; #define stackchan_websocket_v1_AudioPcmStart_size 0 #define stackchan_websocket_v1_AudioWavEnd_size 0 #define stackchan_websocket_v1_AudioWavStart_size 12 +#define stackchan_websocket_v1_FileChunk_size 4099 #define stackchan_websocket_v1_FirmwareMetadata_size 87 #define stackchan_websocket_v1_ServerMetadata_size 67 #define stackchan_websocket_v1_ServoCommandSequence_size 4080 @@ -450,6 +521,8 @@ extern const pb_msgdesc_t stackchan_websocket_v1_ServerMetadata_msg; #define stackchan_websocket_v1_SpeakDoneEvent_size 2 #define stackchan_websocket_v1_StateCommand_size 2 #define stackchan_websocket_v1_StateEvent_size 2 +#define stackchan_websocket_v1_StoredFileEnd_size 0 +#define stackchan_websocket_v1_StoredFileStart_size 148 #define stackchan_websocket_v1_WakeWordEvent_size 2 #define stackchan_websocket_v1_WebSocketMessage_size 4113 diff --git a/firmware/src/listening.cpp b/firmware/src/listening.cpp index edb2e35..d7e6aa6 100644 --- a/firmware/src/listening.cpp +++ b/firmware/src/listening.cpp @@ -41,6 +41,12 @@ void Listening::init() void Listening::begin() { + if (M5.Speaker.isPlaying()) + { + log_i("Stopping speaker playback before listening start"); + M5.Speaker.stop(); + delay(20); + } M5.Mic.begin(); startStreaming(); } diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index 6fb95c4..0d838d5 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -24,6 +24,7 @@ #include "../include/wake_up_word.hpp" #include "../include/display.hpp" #include "../include/servo.hpp" +#include "../include/stored_files.hpp" //////////////////// 設定 //////////////////// const char *WIFI_SSID = WIFI_SSID_H; @@ -32,6 +33,11 @@ const char *SERVER_HOST = SERVER_HOST_H; const int SERVER_PORT = SERVER_PORT_H; const char *SERVER_PATH = SERVER_PATH_H; // WebSocket エンドポイント const int SAMPLE_RATE = 16000; // 16kHz モノラル +const int SPEAKER_OUTPUT_SAMPLE_RATE = 24000; +const int SPEAKER_VOLUME = 160; // 0-255 +const size_t SPEAKER_DMA_BUF_LEN = 512; +const size_t SPEAKER_DMA_BUF_COUNT = 12; +const uint8_t SPEAKER_TASK_PRIORITY = 3; ///////////////////////////////////////////// StateMachine stateMachine; @@ -42,6 +48,7 @@ static Listening listening(wsClient, stateMachine, SAMPLE_RATE); static WakeUpWord wakeUpWord(stateMachine, SAMPLE_RATE); static Display display(stateMachine); static BodyServo servo; +static StoredFiles storedFiles; // Protocol types are defined in include/protocols.hpp namespace @@ -51,8 +58,12 @@ uint32_t g_last_comm_ms = 0; uint32_t g_last_local_wake_word_ms = 0; constexpr uint32_t kCommTimeoutMs = 60000; constexpr uint32_t kLocalWakeWordCooldownMs = 750; +constexpr const char *kWakeWordDetectedSoundFileId = "wakeword-detected-sound"; stackchan_websocket_v1_WebSocketMessage g_tx_message = stackchan_websocket_v1_WebSocketMessage_init_zero; stackchan_websocket_v1_WebSocketMessage g_rx_message = stackchan_websocket_v1_WebSocketMessage_init_zero; +bool g_pending_device_wake_word_feedback = false; +bool g_feedback_sound_playing = false; +bool g_notify_after_feedback_sound = false; void markCommunicationActive() { @@ -156,6 +167,106 @@ void triggerLocalWakeWord(const char *source) notifyWakeWordDetected(); } +void clearFeedbackSoundState(bool stopPlayback) +{ + g_pending_device_wake_word_feedback = false; + g_notify_after_feedback_sound = false; + if (stopPlayback && g_feedback_sound_playing && M5.Speaker.isPlaying()) + { + M5.Speaker.stop(); + } + g_feedback_sound_playing = false; +} + +bool startWakeWordFeedbackSoundPlayback() +{ + StoredFileView sound; + if (!storedFiles.getActivePcmFile(kWakeWordDetectedSoundFileId, sound)) + { + log_w("Wake-word feedback sound is not available for current session id=%s", kWakeWordDetectedSoundFileId); + return false; + } + + if (sound.size == 0 || (sound.size % sizeof(int16_t)) != 0) + { + log_w("Stored wake-word sound has invalid size=%u", static_cast(sound.size)); + return false; + } + + if (sound.sample_rate == 0 || sound.channels == 0) + { + log_w("Stored wake-word sound missing format sample_rate=%u channels=%u", + static_cast(sound.sample_rate), + static_cast(sound.channels)); + return false; + } + + wakeUpWord.end(); + if (M5.Speaker.isPlaying()) + { + M5.Speaker.stop(); + } + + const int16_t *samples = reinterpret_cast(sound.data); + size_t sample_len = sound.size / sizeof(int16_t); + log_i("Starting wake-word feedback playback sample_len=%u sample_rate=%u channels=%u", + static_cast(sample_len), + static_cast(sound.sample_rate), + static_cast(sound.channels)); + M5.Speaker.playRaw(samples, sample_len, sound.sample_rate, sound.channels > 1, 1, 0); + g_feedback_sound_playing = true; + log_i("Playing stored wake-word sound bytes=%u", static_cast(sound.size)); + return true; +} + +void finishWakeWordFeedbackSoundPlayback() +{ + log_i("Wake-word feedback playback finished notify_after=%u", + static_cast(g_notify_after_feedback_sound)); + g_feedback_sound_playing = false; + if (g_notify_after_feedback_sound) + { + g_notify_after_feedback_sound = false; + notifyWakeWordDetected(); + } + + if (stateMachine.getState() == StateMachine::Idle && shouldUseDeviceWakeWord()) + { + wakeUpWord.begin(); + } +} + +void processPendingDeviceWakeWordFeedback() +{ + if (!g_pending_device_wake_word_feedback || stateMachine.getState() != StateMachine::Idle) + { + return; + } + + g_pending_device_wake_word_feedback = false; + if (startWakeWordFeedbackSoundPlayback()) + { + g_notify_after_feedback_sound = true; + log_i("Wake-word feedback playback scheduled before uplink event"); + return; + } + + log_w("Wake-word feedback playback skipped; sending WakeWordEvt immediately"); + notifyWakeWordDetected(); +} + +void handleDeviceWakeWordDetected() +{ + if (!canTriggerLocalWakeWord()) + { + return; + } + + g_last_local_wake_word_ms = millis(); + log_i("Local wake-word trigger from device wake word"); + g_pending_device_wake_word_feedback = true; +} + void handleTouchWakeWordInput() { #if USE_STACKCHAN_BSP @@ -324,11 +435,15 @@ void handleWsEvent(WStype_t type, uint8_t *payload, size_t length) // M5.Display.println("WS: disconnected"); log_i("WS disconnected"); resetServerMetadata(); + storedFiles.resetSession(); + clearFeedbackSoundState(true); stateMachine.setState(StateMachine::Disconnected); break; case WStype_CONNECTED: // M5.Display.printf("WS: connected %s\n", SERVER_PATH); log_i("WS connected to %s", SERVER_PATH); + storedFiles.resetSession(); + clearFeedbackSoundState(true); if (stateMachine.getState() == StateMachine::Disconnected) { stateMachine.setState(StateMachine::Idle); @@ -427,6 +542,49 @@ void handleWsEvent(WStype_t type, uint8_t *payload, size_t length) log_w("ServerMetadata protobuf body mismatch type=%u body=%u", (unsigned)rx.message_type, (unsigned)rx.which_body); } break; + case stackchan_websocket_v1_MessageKind_MESSAGE_KIND_STORED_FILE: + if (rx.message_type == stackchan_websocket_v1_MessageType_MESSAGE_TYPE_START && + rx.which_body == stackchan_websocket_v1_WebSocketMessage_stored_file_start_tag) + { + log_i("Received stored file start id=%s seq=%u size=%u sample_rate=%u channels=%u", + rx.body.stored_file_start.file_id, + static_cast(rx.seq), + static_cast(rx.body.stored_file_start.total_size), + static_cast(rx.body.stored_file_start.sample_rate), + static_cast(rx.body.stored_file_start.channels)); + if (!storedFiles.handleStart(rx.seq, rx.body.stored_file_start)) + { + log_w("Stored file start rejected"); + } + } + else if (rx.message_type == stackchan_websocket_v1_MessageType_MESSAGE_TYPE_DATA && + rx.which_body == stackchan_websocket_v1_WebSocketMessage_stored_file_data_tag) + { + log_i("Received stored file chunk seq=%u bytes=%u", + static_cast(rx.seq), + static_cast(getProtoFileChunkSize(rx.body.stored_file_data))); + if (!storedFiles.handleData( + rx.seq, + getProtoFileChunkBytes(rx.body.stored_file_data), + getProtoFileChunkSize(rx.body.stored_file_data))) + { + log_w("Stored file data rejected"); + } + } + else if (rx.message_type == stackchan_websocket_v1_MessageType_MESSAGE_TYPE_END && + rx.which_body == stackchan_websocket_v1_WebSocketMessage_stored_file_end_tag) + { + log_i("Received stored file end seq=%u", static_cast(rx.seq)); + if (!storedFiles.handleEnd(rx.seq)) + { + log_w("Stored file end rejected"); + } + } + else + { + log_w("StoredFile protobuf body mismatch type=%u body=%u", (unsigned)rx.message_type, (unsigned)rx.which_body); + } + break; default: // M5.Display.printf("WS bin kind=%u len=%d\n", (unsigned)rx.kind, (int)length); break; @@ -459,6 +617,18 @@ void setup() M5.begin(cfg); #endif + auto spk_cfg = M5.Speaker.config(); + spk_cfg.sample_rate = SPEAKER_OUTPUT_SAMPLE_RATE; + spk_cfg.dma_buf_len = SPEAKER_DMA_BUF_LEN; + spk_cfg.dma_buf_count = SPEAKER_DMA_BUF_COUNT; + spk_cfg.task_priority = SPEAKER_TASK_PRIORITY; + M5.Speaker.config(spk_cfg); + log_i("Speaker config sample_rate=%u dma_buf_len=%u dma_buf_count=%u task_priority=%u", + static_cast(spk_cfg.sample_rate), + static_cast(spk_cfg.dma_buf_len), + static_cast(spk_cfg.dma_buf_count), + static_cast(spk_cfg.task_priority)); + auto mic_cfg = M5.Mic.config(); mic_cfg.sample_rate = SAMPLE_RATE; mic_cfg.dma_buf_len = 256; @@ -468,6 +638,7 @@ void setup() listening.init(); speaking.init(); + storedFiles.init(); speaking.setSpeakFinishedCallback([]() { notifySpeakDone(); }); @@ -477,7 +648,7 @@ void setup() }); wakeUpWord.init(); wakeUpWord.setWakeWordDetectedCallback([]() { - notifyWakeWordDetected(); + handleDeviceWakeWordDetected(); }); display.init(); initializeFirmwareMetadata(); @@ -485,7 +656,7 @@ void setup() connectWiFi(); // Mic/Speaking setup - M5.Speaker.setVolume(200); // 0-255 + M5.Speaker.setVolume(SPEAKER_VOLUME); // 0-255 wsClient.begin(SERVER_HOST, SERVER_PORT, SERVER_PATH); markCommunicationActive(); @@ -507,6 +678,7 @@ void setup() stateMachine.addStateEntryEvent(StateMachine::Listening, [](StateMachine::State, StateMachine::State) { notifyCurrentState(StateMachine::Listening); + clearFeedbackSoundState(true); listening.begin(); }); stateMachine.addStateExitEvent(StateMachine::Listening, [](StateMachine::State, StateMachine::State) { @@ -541,11 +713,21 @@ void loop() switch (current) { case StateMachine::Idle: + if (g_feedback_sound_playing) + { + if (!M5.Speaker.isPlaying()) + { + finishWakeWordFeedbackSoundPlayback(); + } + break; + } + handleTouchWakeWordInput(); if (shouldUseDeviceWakeWord()) { wakeUpWord.loop(); } + processPendingDeviceWakeWordFeedback(); break; case StateMachine::Listening: listening.loop(); diff --git a/firmware/src/protocols.cpp b/firmware/src/protocols.cpp index 16af592..d6aa03b 100644 --- a/firmware/src/protocols.cpp +++ b/firmware/src/protocols.cpp @@ -33,6 +33,34 @@ size_t getProtoAudioChunkSize(const stackchan_websocket_v1_AudioChunk &chunk) return chunk.pcm_bytes.size; } +bool setProtoFileChunk( + stackchan_websocket_v1_FileChunk &chunk, + const uint8_t *data, + size_t data_len) +{ + if (data_len > kProtoFileChunkMaxBytes) + { + return false; + } + + chunk.chunk_bytes.size = static_cast(data_len); + if (data_len > 0 && data != nullptr) + { + memcpy(chunk.chunk_bytes.bytes, data, data_len); + } + return true; +} + +const uint8_t *getProtoFileChunkBytes(const stackchan_websocket_v1_FileChunk &chunk) +{ + return chunk.chunk_bytes.bytes; +} + +size_t getProtoFileChunkSize(const stackchan_websocket_v1_FileChunk &chunk) +{ + return chunk.chunk_bytes.size; +} + bool encodeWebSocketMessage( const stackchan_websocket_v1_WebSocketMessage &message, std::vector &encoded) diff --git a/firmware/src/stored_files.cpp b/firmware/src/stored_files.cpp new file mode 100644 index 0000000..dff459e --- /dev/null +++ b/firmware/src/stored_files.cpp @@ -0,0 +1,494 @@ +#include "stored_files.hpp" + +#include +#include + +#include +#include +#include + +namespace +{ +constexpr const char *kIndexFilePath = "/wsfiles.idx"; +constexpr const char *kPayloadPaths[] = { + "/wsfile0.bin", + "/wsfile1.bin", + "/wsfile2.bin", + "/wsfile3.bin", +}; +} // namespace + +void StoredFiles::init() +{ + session_active_.fill(false); + cached_slot_index_ = -1; + cached_payload_.clear(); + resetTransfer(); + + if (!mountSpiffs()) + { + return; + } + + log_i( + "SPIFFS mounted total=%u used=%u", + static_cast(SPIFFS.totalBytes()), + static_cast(SPIFFS.usedBytes())); + + if (!loadIndex()) + { + slots_ = {}; + persistIndex(); + } +} + +void StoredFiles::resetSession() +{ + session_active_.fill(false); + cached_slot_index_ = -1; + cached_payload_.clear(); + resetTransfer(); +} + +bool StoredFiles::handleStart(uint32_t seq, const stackchan_websocket_v1_StoredFileStart &start) +{ + if (!mountSpiffs()) + { + return false; + } + + resetTransfer(); + + if (start.file_id[0] == '\0') + { + log_w("Stored file start missing file_id"); + return false; + } + + size_t total_size = static_cast(start.total_size); + if (total_size > kMaxStoredFileBytes) + { + log_w( + "Stored file too large for SPIFFS-backed transfer id=%s size=%u limit=%u", + start.file_id, + static_cast(start.total_size), + static_cast(kMaxStoredFileBytes)); + return false; + } + + int slot_index = selectSlotForId(start.file_id); + if (slot_index < 0) + { + log_w("No slot available for stored file id=%s", start.file_id); + return false; + } + + size_t reclaimable_bytes = slots_[slot_index].used ? slots_[slot_index].size : 0; + size_t total_bytes = SPIFFS.totalBytes(); + size_t used_bytes = SPIFFS.usedBytes(); + size_t free_bytes = total_bytes > used_bytes ? total_bytes - used_bytes : 0; + size_t available_bytes = free_bytes + reclaimable_bytes; + if (total_bytes > 0 && total_size > available_bytes) + { + log_w( + "Insufficient SPIFFS space for stored file id=%s requested=%u free=%u reclaimable=%u", + start.file_id, + static_cast(total_size), + static_cast(free_bytes), + static_cast(reclaimable_bytes)); + return false; + } + + transfer_.active = true; + transfer_.next_seq = seq + 1; + transfer_.slot_index = slot_index; + transfer_.slot = PersistedSlot{}; + transfer_.slot.used = true; + snprintf(transfer_.slot.file_id, sizeof(transfer_.slot.file_id), "%s", start.file_id); + snprintf(transfer_.slot.content_type, sizeof(transfer_.slot.content_type), "%s", start.content_type); + transfer_.slot.sample_rate = start.sample_rate; + transfer_.slot.channels = start.channels; + transfer_.slot.size = static_cast(total_size); + transfer_.received_bytes = 0; + transfer_.chunk_count = 0; + transfer_.payload.clear(); + transfer_.payload.reserve(total_size); + + log_i( + "Stored file start id=%s type=%s size=%u sample_rate=%u channels=%u slot=%d spiffs_free=%u", + transfer_.slot.file_id, + transfer_.slot.content_type, + static_cast(transfer_.slot.size), + static_cast(transfer_.slot.sample_rate), + static_cast(transfer_.slot.channels), + slot_index, + static_cast(free_bytes)); + return true; +} + +bool StoredFiles::handleData(uint32_t seq, const uint8_t *data, size_t data_len) +{ + if (!transfer_.active) + { + log_w("Stored file data without active transfer"); + return false; + } + + if (seq != transfer_.next_seq) + { + log_w("Stored file seq gap got=%u expected=%u", static_cast(seq), static_cast(transfer_.next_seq)); + resetTransfer(); + return false; + } + transfer_.next_seq++; + + size_t next_size = transfer_.payload.size() + data_len; + if (next_size > transfer_.slot.size || next_size > kMaxStoredFileBytes) + { + log_w("Stored file payload too large id=%s size=%u expected=%u", + transfer_.slot.file_id, + static_cast(next_size), + static_cast(transfer_.slot.size)); + resetTransfer(); + return false; + } + + transfer_.payload.insert(transfer_.payload.end(), data, data + data_len); + transfer_.received_bytes += static_cast(data_len); + transfer_.chunk_count++; + log_i( + "Stored file chunk id=%s chunk=%u bytes=%u total=%u/%u", + transfer_.slot.file_id, + static_cast(transfer_.chunk_count), + static_cast(data_len), + static_cast(transfer_.received_bytes), + static_cast(transfer_.slot.size)); + return true; +} + +bool StoredFiles::handleEnd(uint32_t seq) +{ + if (!transfer_.active) + { + log_w("Stored file end without active transfer"); + return false; + } + + if (seq != transfer_.next_seq) + { + log_w("Stored file end seq gap got=%u expected=%u", static_cast(seq), static_cast(transfer_.next_seq)); + resetTransfer(); + return false; + } + + if (transfer_.payload.size() != transfer_.slot.size) + { + log_w("Stored file size mismatch id=%s actual=%u expected=%u", + transfer_.slot.file_id, + static_cast(transfer_.payload.size()), + static_cast(transfer_.slot.size)); + resetTransfer(); + return false; + } + + int slot_index = transfer_.slot_index; + PersistedSlot slot = transfer_.slot; + std::vector payload = transfer_.payload; + + if (!persistSlotPayload(slot_index, payload)) + { + resetTransfer(); + return false; + } + + slots_[slot_index] = slot; + if (!persistIndex()) + { + resetTransfer(); + return false; + } + + bool activated = activateSlot(slot_index, payload); + log_i( + "Stored file saved to SPIFFS id=%s slot=%d chunks=%u bytes=%u activated=%u used=%u", + slot.file_id, + slot_index, + static_cast(transfer_.chunk_count), + static_cast(payload.size()), + static_cast(activated), + static_cast(SPIFFS.usedBytes())); + resetTransfer(); + return activated; +} + +bool StoredFiles::getActivePcmFile(const char *fileId, StoredFileView &view) +{ + view = StoredFileView{}; + + int slot_index = findSlotById(fileId); + if (slot_index < 0 || !session_active_[slot_index]) + { + log_i("Stored file inactive or not received in this session id=%s", fileId); + return false; + } + + const PersistedSlot &slot = slots_[slot_index]; + if (strcmp(slot.content_type, "audio/pcm") != 0) + { + log_w("Stored file id=%s has unsupported content_type=%s", slot.file_id, slot.content_type); + return false; + } + + if (cached_slot_index_ != slot_index) + { + std::vector payload; + if (!loadSlotPayload(slot_index, payload)) + { + log_w("Failed to load stored file payload id=%s slot=%d", fileId, slot_index); + return false; + } + cached_slot_index_ = slot_index; + cached_payload_ = std::move(payload); + log_i("Loaded stored file payload from SPIFFS into cache id=%s slot=%d bytes=%u", + fileId, + slot_index, + static_cast(cached_payload_.size())); + } + + if (cached_payload_.empty() && slot.size != 0) + { + return false; + } + + view.data = cached_payload_.data(); + view.size = cached_payload_.size(); + view.sample_rate = slot.sample_rate; + view.channels = static_cast(slot.channels); + log_i("Stored file ready for playback id=%s slot=%d bytes=%u sample_rate=%u channels=%u", + fileId, + slot_index, + static_cast(view.size), + static_cast(view.sample_rate), + static_cast(view.channels)); + return true; +} + +bool StoredFiles::mountSpiffs() +{ + if (storage_ready_) + { + return true; + } + + storage_ready_ = SPIFFS.begin(true); + if (!storage_ready_) + { + log_w("Failed to mount SPIFFS"); + } + return storage_ready_; +} + +bool StoredFiles::loadIndex() +{ + if (!storage_ready_) + { + return false; + } + + if (!SPIFFS.exists(indexPath())) + { + slots_ = {}; + return true; + } + + File file = SPIFFS.open(indexPath(), "r"); + if (!file) + { + log_w("Failed to open SPIFFS index file path=%s", indexPath()); + return false; + } + + size_t index_size = file.size(); + if (index_size != sizeof(slots_)) + { + log_w("Stored file index size mismatch actual=%u expected=%u", + static_cast(index_size), + static_cast(sizeof(slots_))); + file.close(); + return false; + } + + size_t bytes_read = file.read(reinterpret_cast(slots_.data()), sizeof(slots_)); + file.close(); + return bytes_read == sizeof(slots_); +} + +bool StoredFiles::persistIndex() +{ + if (!storage_ready_) + { + return false; + } + + File file = SPIFFS.open(indexPath(), "w"); + if (!file) + { + log_w("Failed to open SPIFFS index file for write path=%s", indexPath()); + return false; + } + + size_t bytes_written = file.write( + reinterpret_cast(slots_.data()), + sizeof(slots_)); + file.close(); + if (bytes_written != sizeof(slots_)) + { + log_w("Failed to persist stored file index written=%u expected=%u", + static_cast(bytes_written), + static_cast(sizeof(slots_))); + return false; + } + return true; +} + +bool StoredFiles::persistSlotPayload(int slotIndex, const std::vector &payload) +{ + if (!storage_ready_) + { + return false; + } + + File file = SPIFFS.open(payloadPathForSlot(slotIndex), "w"); + if (!file) + { + log_w("Failed to open SPIFFS payload file for write slot=%d path=%s", + slotIndex, + payloadPathForSlot(slotIndex)); + return false; + } + + size_t bytes_written = 0; + if (!payload.empty()) + { + bytes_written = file.write(payload.data(), payload.size()); + } + file.close(); + if (bytes_written != payload.size()) + { + log_w("Failed to persist stored file payload slot=%d written=%u expected=%u", + slotIndex, + static_cast(bytes_written), + static_cast(payload.size())); + return false; + } + return true; +} + +bool StoredFiles::loadSlotPayload(int slotIndex, std::vector &payload) +{ + payload.clear(); + if (!storage_ready_) + { + return false; + } + + File file = SPIFFS.open(payloadPathForSlot(slotIndex), "r"); + if (!file) + { + log_w("Failed to open SPIFFS payload file for read slot=%d path=%s", + slotIndex, + payloadPathForSlot(slotIndex)); + return false; + } + + size_t payload_size = file.size(); + if (payload_size != slots_[slotIndex].size) + { + log_w("Stored file payload size mismatch slot=%d actual=%u expected=%u", + slotIndex, + static_cast(payload_size), + static_cast(slots_[slotIndex].size)); + file.close(); + return false; + } + + payload.resize(payload_size); + size_t bytes_read = 0; + if (payload_size > 0) + { + bytes_read = file.read(payload.data(), payload_size); + } + file.close(); + return bytes_read == payload_size; +} + +int StoredFiles::findSlotById(const char *fileId) const +{ + for (size_t i = 0; i < slots_.size(); ++i) + { + if (!slots_[i].used) + { + continue; + } + if (strcmp(slots_[i].file_id, fileId) == 0) + { + return static_cast(i); + } + } + return -1; +} + +int StoredFiles::selectSlotForId(const char *fileId) +{ + int existing_slot = findSlotById(fileId); + if (existing_slot >= 0) + { + return existing_slot; + } + + for (size_t i = 0; i < slots_.size(); ++i) + { + if (!slots_[i].used) + { + return static_cast(i); + } + } + + return 0; +} + +void StoredFiles::resetTransfer() +{ + transfer_ = TransferState{}; +} + +bool StoredFiles::activateSlot(int slotIndex, const std::vector &payload) +{ + if (slotIndex < 0 || slotIndex >= static_cast(slots_.size())) + { + return false; + } + + session_active_[slotIndex] = true; + cached_slot_index_ = slotIndex; + cached_payload_ = payload; + log_i("Stored file activated for current session id=%s slot=%d bytes=%u", + slots_[slotIndex].file_id, + slotIndex, + static_cast(cached_payload_.size())); + return true; +} + +const char *StoredFiles::payloadPathForSlot(int slotIndex) +{ + if (slotIndex < 0 || slotIndex >= static_cast(sizeof(kPayloadPaths) / sizeof(kPayloadPaths[0]))) + { + return kPayloadPaths[0]; + } + return kPayloadPaths[slotIndex]; +} + +const char *StoredFiles::indexPath() +{ + return kIndexFilePath; +} diff --git a/misc/wake_sound/wake_sound.wav b/misc/wake_sound/wake_sound.wav new file mode 100644 index 0000000000000000000000000000000000000000..a1f37c51f4d1dcb2ae7eb6994f168668467ea227 GIT binary patch literal 38478 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

a3^0Cy-b)DmoA=4 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 None: self.speech_recognizer = speech_recognizer or create_speech_recognizer() self.speech_synthesizer = speech_synthesizer or create_speech_synthesizer() + self.send_wakeword_sound_on_connect = send_wakeword_sound_on_connect self.fastapi = FastAPI(title="StackChan WebSocket Server") self._setup_fn: Optional[Callable[[WsProxy], Awaitable[None]]] = None self._talk_session_fn: Optional[Callable[[WsProxy], Awaitable[None]]] = None @@ -96,6 +99,9 @@ async def _handle_ws(self, websocket: WebSocket) -> None: await existing.close() try: + if self.send_wakeword_sound_on_connect: + await proxy.send_default_wake_word_sound() + if self._setup_fn: await self._setup_fn(proxy) diff --git a/stackchan_server/generated_protobuf/websocket_message_pb2.py b/stackchan_server/generated_protobuf/websocket_message_pb2.py index a7d7a4e..9d4a505 100644 --- a/stackchan_server/generated_protobuf/websocket_message_pb2.py +++ b/stackchan_server/generated_protobuf/websocket_message_pb2.py @@ -24,53 +24,59 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17websocket-message.proto\x12\x16stackchan.websocket.v1\"\x96\x08\n\x10WebSocketMessage\x12\x31\n\x04kind\x18\x01 \x01(\x0e\x32#.stackchan.websocket.v1.MessageKind\x12\x39\n\x0cmessage_type\x18\x02 \x01(\x0e\x32#.stackchan.websocket.v1.MessageType\x12\x0b\n\x03seq\x18\x03 \x01(\r\x12@\n\x0f\x61udio_pcm_start\x18\n \x01(\x0b\x32%.stackchan.websocket.v1.AudioPcmStartH\x00\x12<\n\x0e\x61udio_pcm_data\x18\x0b \x01(\x0b\x32\".stackchan.websocket.v1.AudioChunkH\x00\x12<\n\raudio_pcm_end\x18\x0c \x01(\x0b\x32#.stackchan.websocket.v1.AudioPcmEndH\x00\x12@\n\x0f\x61udio_wav_start\x18\x14 \x01(\x0b\x32%.stackchan.websocket.v1.AudioWavStartH\x00\x12<\n\x0e\x61udio_wav_data\x18\x15 \x01(\x0b\x32\".stackchan.websocket.v1.AudioChunkH\x00\x12<\n\raudio_wav_end\x18\x16 \x01(\x0b\x32#.stackchan.websocket.v1.AudioWavEndH\x00\x12\x39\n\tstate_cmd\x18\x1e \x01(\x0b\x32$.stackchan.websocket.v1.StateCommandH\x00\x12>\n\rwake_word_evt\x18\x1f \x01(\x0b\x32%.stackchan.websocket.v1.WakeWordEventH\x00\x12\x37\n\tstate_evt\x18 \x01(\x0b\x32\".stackchan.websocket.v1.StateEventH\x00\x12@\n\x0espeak_done_evt\x18! \x01(\x0b\x32&.stackchan.websocket.v1.SpeakDoneEventH\x00\x12\x41\n\tservo_cmd\x18\" \x01(\x0b\x32,.stackchan.websocket.v1.ServoCommandSequenceH\x00\x12@\n\x0eservo_done_evt\x18# \x01(\x0b\x32&.stackchan.websocket.v1.ServoDoneEventH\x00\x12\x45\n\x11\x66irmware_metadata\x18$ \x01(\x0b\x32(.stackchan.websocket.v1.FirmwareMetadataH\x00\x12\x41\n\x0fserver_metadata\x18% \x01(\x0b\x32&.stackchan.websocket.v1.ServerMetadataH\x00\x42\x06\n\x04\x62ody\"\x0f\n\rAudioPcmStart\"\r\n\x0b\x41udioPcmEnd\"6\n\rAudioWavStart\x12\x13\n\x0bsample_rate\x18\x01 \x01(\r\x12\x10\n\x08\x63hannels\x18\x02 \x01(\r\"\r\n\x0b\x41udioWavEnd\"\x1f\n\nAudioChunk\x12\x11\n\tpcm_bytes\x18\x01 \x01(\x0c\"E\n\x0cStateCommand\x12\x35\n\x05state\x18\x01 \x01(\x0e\x32&.stackchan.websocket.v1.StackchanState\"!\n\rWakeWordEvent\x12\x10\n\x08\x64\x65tected\x18\x01 \x01(\x08\"C\n\nStateEvent\x12\x35\n\x05state\x18\x01 \x01(\x0e\x32&.stackchan.websocket.v1.StackchanState\"\x1e\n\x0eSpeakDoneEvent\x12\x0c\n\x04\x64one\x18\x01 \x01(\x08\"N\n\x14ServoCommandSequence\x12\x36\n\x08\x63ommands\x18\x01 \x03(\x0b\x32$.stackchan.websocket.v1.ServoCommand\"f\n\x0cServoCommand\x12\x32\n\x02op\x18\x01 \x01(\x0e\x32&.stackchan.websocket.v1.ServoOperation\x12\r\n\x05\x61ngle\x18\x02 \x01(\x11\x12\x13\n\x0b\x64uration_ms\x18\x03 \x01(\x11\"\x1e\n\x0eServoDoneEvent\x12\x0c\n\x04\x64one\x18\x01 \x01(\x08\"\x99\x02\n\x10\x46irmwareMetadata\x12\x37\n\x0b\x64\x65vice_type\x18\x01 \x01(\x0e\x32\".stackchan.websocket.v1.DeviceType\x12\x15\n\rdisplay_width\x18\x02 \x01(\r\x12\x16\n\x0e\x64isplay_height\x18\x03 \x01(\r\x12\x1c\n\x14has_device_wake_word\x18\x04 \x01(\x08\x12\x0f\n\x07has_led\x18\x05 \x01(\x08\x12\x35\n\nservo_type\x18\x06 \x01(\x0e\x32!.stackchan.websocket.v1.ServoType\x12\x1d\n\x15supports_audio_duplex\x18\x07 \x01(\x08\x12\x18\n\x10\x66irmware_version\x18\x08 \x01(\t\"F\n\x0eServerMetadata\x12\x1c\n\x14has_server_wake_word\x18\x01 \x01(\x08\x12\x16\n\x0eserver_version\x18\x02 \x01(\t*\xdf\x02\n\x0bMessageKind\x12\x1c\n\x18MESSAGE_KIND_UNSPECIFIED\x10\x00\x12\x1a\n\x16MESSAGE_KIND_AUDIO_PCM\x10\x01\x12\x1a\n\x16MESSAGE_KIND_AUDIO_WAV\x10\x02\x12\x1a\n\x16MESSAGE_KIND_STATE_CMD\x10\x03\x12\x1e\n\x1aMESSAGE_KIND_WAKE_WORD_EVT\x10\x04\x12\x1a\n\x16MESSAGE_KIND_STATE_EVT\x10\x05\x12\x1f\n\x1bMESSAGE_KIND_SPEAK_DONE_EVT\x10\x06\x12\x1a\n\x16MESSAGE_KIND_SERVO_CMD\x10\x07\x12\x1f\n\x1bMESSAGE_KIND_SERVO_DONE_EVT\x10\x08\x12\"\n\x1eMESSAGE_KIND_FIRMWARE_METADATA\x10\t\x12 \n\x1cMESSAGE_KIND_SERVER_METADATA\x10\n*p\n\x0bMessageType\x12\x1c\n\x18MESSAGE_TYPE_UNSPECIFIED\x10\x00\x12\x16\n\x12MESSAGE_TYPE_START\x10\x01\x12\x15\n\x11MESSAGE_TYPE_DATA\x10\x02\x12\x14\n\x10MESSAGE_TYPE_END\x10\x03*\x85\x01\n\x0eStackchanState\x12\x18\n\x14STACKCHAN_STATE_IDLE\x10\x00\x12\x1d\n\x19STACKCHAN_STATE_LISTENING\x10\x01\x12\x1c\n\x18STACKCHAN_STATE_THINKING\x10\x02\x12\x1c\n\x18STACKCHAN_STATE_SPEAKING\x10\x03*c\n\x0eServoOperation\x12\x19\n\x15SERVO_OPERATION_SLEEP\x10\x00\x12\x1a\n\x16SERVO_OPERATION_MOVE_X\x10\x01\x12\x1a\n\x16SERVO_OPERATION_MOVE_Y\x10\x02*\x85\x01\n\nDeviceType\x12\x1b\n\x17\x44\x45VICE_TYPE_UNSPECIFIED\x10\x00\x12\x1e\n\x1a\x44\x45VICE_TYPE_M5STACK_CORES3\x10\x01\x12\x1a\n\x16\x44\x45VICE_TYPE_M5ATOM_S3R\x10\x02\x12\x1e\n\x1a\x44\x45VICE_TYPE_M5ATOM_ECHOS3R\x10\x03*i\n\tServoType\x12\x1a\n\x16SERVO_TYPE_UNSPECIFIED\x10\x00\x12\x13\n\x0fSERVO_TYPE_NONE\x10\x01\x12\x13\n\x0fSERVO_TYPE_SG90\x10\x02\x12\x16\n\x12SERVO_TYPE_SCS0009\x10\x03\x62\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17websocket-message.proto\x12\x16stackchan.websocket.v1\"\xdd\t\n\x10WebSocketMessage\x12\x31\n\x04kind\x18\x01 \x01(\x0e\x32#.stackchan.websocket.v1.MessageKind\x12\x39\n\x0cmessage_type\x18\x02 \x01(\x0e\x32#.stackchan.websocket.v1.MessageType\x12\x0b\n\x03seq\x18\x03 \x01(\r\x12@\n\x0f\x61udio_pcm_start\x18\n \x01(\x0b\x32%.stackchan.websocket.v1.AudioPcmStartH\x00\x12<\n\x0e\x61udio_pcm_data\x18\x0b \x01(\x0b\x32\".stackchan.websocket.v1.AudioChunkH\x00\x12<\n\raudio_pcm_end\x18\x0c \x01(\x0b\x32#.stackchan.websocket.v1.AudioPcmEndH\x00\x12@\n\x0f\x61udio_wav_start\x18\x14 \x01(\x0b\x32%.stackchan.websocket.v1.AudioWavStartH\x00\x12<\n\x0e\x61udio_wav_data\x18\x15 \x01(\x0b\x32\".stackchan.websocket.v1.AudioChunkH\x00\x12<\n\raudio_wav_end\x18\x16 \x01(\x0b\x32#.stackchan.websocket.v1.AudioWavEndH\x00\x12\x39\n\tstate_cmd\x18\x1e \x01(\x0b\x32$.stackchan.websocket.v1.StateCommandH\x00\x12>\n\rwake_word_evt\x18\x1f \x01(\x0b\x32%.stackchan.websocket.v1.WakeWordEventH\x00\x12\x37\n\tstate_evt\x18 \x01(\x0b\x32\".stackchan.websocket.v1.StateEventH\x00\x12@\n\x0espeak_done_evt\x18! \x01(\x0b\x32&.stackchan.websocket.v1.SpeakDoneEventH\x00\x12\x41\n\tservo_cmd\x18\" \x01(\x0b\x32,.stackchan.websocket.v1.ServoCommandSequenceH\x00\x12@\n\x0eservo_done_evt\x18# \x01(\x0b\x32&.stackchan.websocket.v1.ServoDoneEventH\x00\x12\x45\n\x11\x66irmware_metadata\x18$ \x01(\x0b\x32(.stackchan.websocket.v1.FirmwareMetadataH\x00\x12\x41\n\x0fserver_metadata\x18% \x01(\x0b\x32&.stackchan.websocket.v1.ServerMetadataH\x00\x12\x44\n\x11stored_file_start\x18( \x01(\x0b\x32\'.stackchan.websocket.v1.StoredFileStartH\x00\x12=\n\x10stored_file_data\x18) \x01(\x0b\x32!.stackchan.websocket.v1.FileChunkH\x00\x12@\n\x0fstored_file_end\x18* \x01(\x0b\x32%.stackchan.websocket.v1.StoredFileEndH\x00\x42\x06\n\x04\x62ody\"\x0f\n\rAudioPcmStart\"\r\n\x0b\x41udioPcmEnd\"6\n\rAudioWavStart\x12\x13\n\x0bsample_rate\x18\x01 \x01(\r\x12\x10\n\x08\x63hannels\x18\x02 \x01(\r\"\r\n\x0b\x41udioWavEnd\"\x1f\n\nAudioChunk\x12\x11\n\tpcm_bytes\x18\x01 \x01(\x0c\" \n\tFileChunk\x12\x13\n\x0b\x63hunk_bytes\x18\x01 \x01(\x0c\"E\n\x0cStateCommand\x12\x35\n\x05state\x18\x01 \x01(\x0e\x32&.stackchan.websocket.v1.StackchanState\"!\n\rWakeWordEvent\x12\x10\n\x08\x64\x65tected\x18\x01 \x01(\x08\"C\n\nStateEvent\x12\x35\n\x05state\x18\x01 \x01(\x0e\x32&.stackchan.websocket.v1.StackchanState\"\x1e\n\x0eSpeakDoneEvent\x12\x0c\n\x04\x64one\x18\x01 \x01(\x08\"s\n\x0fStoredFileStart\x12\x0f\n\x07\x66ile_id\x18\x01 \x01(\t\x12\x14\n\x0c\x63ontent_type\x18\x02 \x01(\t\x12\x12\n\ntotal_size\x18\x03 \x01(\r\x12\x13\n\x0bsample_rate\x18\x04 \x01(\r\x12\x10\n\x08\x63hannels\x18\x05 \x01(\r\"\x0f\n\rStoredFileEnd\"N\n\x14ServoCommandSequence\x12\x36\n\x08\x63ommands\x18\x01 \x03(\x0b\x32$.stackchan.websocket.v1.ServoCommand\"f\n\x0cServoCommand\x12\x32\n\x02op\x18\x01 \x01(\x0e\x32&.stackchan.websocket.v1.ServoOperation\x12\r\n\x05\x61ngle\x18\x02 \x01(\x11\x12\x13\n\x0b\x64uration_ms\x18\x03 \x01(\x11\"\x1e\n\x0eServoDoneEvent\x12\x0c\n\x04\x64one\x18\x01 \x01(\x08\"\x99\x02\n\x10\x46irmwareMetadata\x12\x37\n\x0b\x64\x65vice_type\x18\x01 \x01(\x0e\x32\".stackchan.websocket.v1.DeviceType\x12\x15\n\rdisplay_width\x18\x02 \x01(\r\x12\x16\n\x0e\x64isplay_height\x18\x03 \x01(\r\x12\x1c\n\x14has_device_wake_word\x18\x04 \x01(\x08\x12\x0f\n\x07has_led\x18\x05 \x01(\x08\x12\x35\n\nservo_type\x18\x06 \x01(\x0e\x32!.stackchan.websocket.v1.ServoType\x12\x1d\n\x15supports_audio_duplex\x18\x07 \x01(\x08\x12\x18\n\x10\x66irmware_version\x18\x08 \x01(\t\"F\n\x0eServerMetadata\x12\x1c\n\x14has_server_wake_word\x18\x01 \x01(\x08\x12\x16\n\x0eserver_version\x18\x02 \x01(\t*\xfd\x02\n\x0bMessageKind\x12\x1c\n\x18MESSAGE_KIND_UNSPECIFIED\x10\x00\x12\x1a\n\x16MESSAGE_KIND_AUDIO_PCM\x10\x01\x12\x1a\n\x16MESSAGE_KIND_AUDIO_WAV\x10\x02\x12\x1a\n\x16MESSAGE_KIND_STATE_CMD\x10\x03\x12\x1e\n\x1aMESSAGE_KIND_WAKE_WORD_EVT\x10\x04\x12\x1a\n\x16MESSAGE_KIND_STATE_EVT\x10\x05\x12\x1f\n\x1bMESSAGE_KIND_SPEAK_DONE_EVT\x10\x06\x12\x1a\n\x16MESSAGE_KIND_SERVO_CMD\x10\x07\x12\x1f\n\x1bMESSAGE_KIND_SERVO_DONE_EVT\x10\x08\x12\"\n\x1eMESSAGE_KIND_FIRMWARE_METADATA\x10\t\x12 \n\x1cMESSAGE_KIND_SERVER_METADATA\x10\n\x12\x1c\n\x18MESSAGE_KIND_STORED_FILE\x10\x0b*p\n\x0bMessageType\x12\x1c\n\x18MESSAGE_TYPE_UNSPECIFIED\x10\x00\x12\x16\n\x12MESSAGE_TYPE_START\x10\x01\x12\x15\n\x11MESSAGE_TYPE_DATA\x10\x02\x12\x14\n\x10MESSAGE_TYPE_END\x10\x03*\x85\x01\n\x0eStackchanState\x12\x18\n\x14STACKCHAN_STATE_IDLE\x10\x00\x12\x1d\n\x19STACKCHAN_STATE_LISTENING\x10\x01\x12\x1c\n\x18STACKCHAN_STATE_THINKING\x10\x02\x12\x1c\n\x18STACKCHAN_STATE_SPEAKING\x10\x03*c\n\x0eServoOperation\x12\x19\n\x15SERVO_OPERATION_SLEEP\x10\x00\x12\x1a\n\x16SERVO_OPERATION_MOVE_X\x10\x01\x12\x1a\n\x16SERVO_OPERATION_MOVE_Y\x10\x02*\x85\x01\n\nDeviceType\x12\x1b\n\x17\x44\x45VICE_TYPE_UNSPECIFIED\x10\x00\x12\x1e\n\x1a\x44\x45VICE_TYPE_M5STACK_CORES3\x10\x01\x12\x1a\n\x16\x44\x45VICE_TYPE_M5ATOM_S3R\x10\x02\x12\x1e\n\x1a\x44\x45VICE_TYPE_M5ATOM_ECHOS3R\x10\x03*i\n\tServoType\x12\x1a\n\x16SERVO_TYPE_UNSPECIFIED\x10\x00\x12\x13\n\x0fSERVO_TYPE_NONE\x10\x01\x12\x13\n\x0fSERVO_TYPE_SG90\x10\x02\x12\x16\n\x12SERVO_TYPE_SCS0009\x10\x03\x62\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'websocket_message_pb2', _globals) if not _descriptor._USE_C_DESCRIPTORS: DESCRIPTOR._loaded_options = None - _globals['_MESSAGEKIND']._serialized_start=2016 - _globals['_MESSAGEKIND']._serialized_end=2367 - _globals['_MESSAGETYPE']._serialized_start=2369 - _globals['_MESSAGETYPE']._serialized_end=2481 - _globals['_STACKCHANSTATE']._serialized_start=2484 - _globals['_STACKCHANSTATE']._serialized_end=2617 - _globals['_SERVOOPERATION']._serialized_start=2619 - _globals['_SERVOOPERATION']._serialized_end=2718 - _globals['_DEVICETYPE']._serialized_start=2721 - _globals['_DEVICETYPE']._serialized_end=2854 - _globals['_SERVOTYPE']._serialized_start=2856 - _globals['_SERVOTYPE']._serialized_end=2961 + _globals['_MESSAGEKIND']._serialized_start=2383 + _globals['_MESSAGEKIND']._serialized_end=2764 + _globals['_MESSAGETYPE']._serialized_start=2766 + _globals['_MESSAGETYPE']._serialized_end=2878 + _globals['_STACKCHANSTATE']._serialized_start=2881 + _globals['_STACKCHANSTATE']._serialized_end=3014 + _globals['_SERVOOPERATION']._serialized_start=3016 + _globals['_SERVOOPERATION']._serialized_end=3115 + _globals['_DEVICETYPE']._serialized_start=3118 + _globals['_DEVICETYPE']._serialized_end=3251 + _globals['_SERVOTYPE']._serialized_start=3253 + _globals['_SERVOTYPE']._serialized_end=3358 _globals['_WEBSOCKETMESSAGE']._serialized_start=52 - _globals['_WEBSOCKETMESSAGE']._serialized_end=1098 - _globals['_AUDIOPCMSTART']._serialized_start=1100 - _globals['_AUDIOPCMSTART']._serialized_end=1115 - _globals['_AUDIOPCMEND']._serialized_start=1117 - _globals['_AUDIOPCMEND']._serialized_end=1130 - _globals['_AUDIOWAVSTART']._serialized_start=1132 - _globals['_AUDIOWAVSTART']._serialized_end=1186 - _globals['_AUDIOWAVEND']._serialized_start=1188 - _globals['_AUDIOWAVEND']._serialized_end=1201 - _globals['_AUDIOCHUNK']._serialized_start=1203 - _globals['_AUDIOCHUNK']._serialized_end=1234 - _globals['_STATECOMMAND']._serialized_start=1236 - _globals['_STATECOMMAND']._serialized_end=1305 - _globals['_WAKEWORDEVENT']._serialized_start=1307 - _globals['_WAKEWORDEVENT']._serialized_end=1340 - _globals['_STATEEVENT']._serialized_start=1342 - _globals['_STATEEVENT']._serialized_end=1409 - _globals['_SPEAKDONEEVENT']._serialized_start=1411 - _globals['_SPEAKDONEEVENT']._serialized_end=1441 - _globals['_SERVOCOMMANDSEQUENCE']._serialized_start=1443 - _globals['_SERVOCOMMANDSEQUENCE']._serialized_end=1521 - _globals['_SERVOCOMMAND']._serialized_start=1523 - _globals['_SERVOCOMMAND']._serialized_end=1625 - _globals['_SERVODONEEVENT']._serialized_start=1627 - _globals['_SERVODONEEVENT']._serialized_end=1657 - _globals['_FIRMWAREMETADATA']._serialized_start=1660 - _globals['_FIRMWAREMETADATA']._serialized_end=1941 - _globals['_SERVERMETADATA']._serialized_start=1943 - _globals['_SERVERMETADATA']._serialized_end=2013 + _globals['_WEBSOCKETMESSAGE']._serialized_end=1297 + _globals['_AUDIOPCMSTART']._serialized_start=1299 + _globals['_AUDIOPCMSTART']._serialized_end=1314 + _globals['_AUDIOPCMEND']._serialized_start=1316 + _globals['_AUDIOPCMEND']._serialized_end=1329 + _globals['_AUDIOWAVSTART']._serialized_start=1331 + _globals['_AUDIOWAVSTART']._serialized_end=1385 + _globals['_AUDIOWAVEND']._serialized_start=1387 + _globals['_AUDIOWAVEND']._serialized_end=1400 + _globals['_AUDIOCHUNK']._serialized_start=1402 + _globals['_AUDIOCHUNK']._serialized_end=1433 + _globals['_FILECHUNK']._serialized_start=1435 + _globals['_FILECHUNK']._serialized_end=1467 + _globals['_STATECOMMAND']._serialized_start=1469 + _globals['_STATECOMMAND']._serialized_end=1538 + _globals['_WAKEWORDEVENT']._serialized_start=1540 + _globals['_WAKEWORDEVENT']._serialized_end=1573 + _globals['_STATEEVENT']._serialized_start=1575 + _globals['_STATEEVENT']._serialized_end=1642 + _globals['_SPEAKDONEEVENT']._serialized_start=1644 + _globals['_SPEAKDONEEVENT']._serialized_end=1674 + _globals['_STOREDFILESTART']._serialized_start=1676 + _globals['_STOREDFILESTART']._serialized_end=1791 + _globals['_STOREDFILEEND']._serialized_start=1793 + _globals['_STOREDFILEEND']._serialized_end=1808 + _globals['_SERVOCOMMANDSEQUENCE']._serialized_start=1810 + _globals['_SERVOCOMMANDSEQUENCE']._serialized_end=1888 + _globals['_SERVOCOMMAND']._serialized_start=1890 + _globals['_SERVOCOMMAND']._serialized_end=1992 + _globals['_SERVODONEEVENT']._serialized_start=1994 + _globals['_SERVODONEEVENT']._serialized_end=2024 + _globals['_FIRMWAREMETADATA']._serialized_start=2027 + _globals['_FIRMWAREMETADATA']._serialized_end=2308 + _globals['_SERVERMETADATA']._serialized_start=2310 + _globals['_SERVERMETADATA']._serialized_end=2380 # @@protoc_insertion_point(module_scope) diff --git a/stackchan_server/protobuf_ws.py b/stackchan_server/protobuf_ws.py index 8569004..e84d2d1 100644 --- a/stackchan_server/protobuf_ws.py +++ b/stackchan_server/protobuf_ws.py @@ -92,6 +92,48 @@ def encode_audio_wav_end_message(seq: int) -> bytes: return message.SerializeToString() +def encode_stored_file_start_message( + seq: int, + *, + file_id: str, + content_type: str, + total_size: int, + sample_rate: int = 0, + channels: int = 0, +) -> bytes: + message = _new_message( + ws_pb2.MESSAGE_KIND_STORED_FILE, + ws_pb2.MESSAGE_TYPE_START, + seq, + ) + message.stored_file_start.file_id = file_id + message.stored_file_start.content_type = content_type + message.stored_file_start.total_size = int(total_size) + message.stored_file_start.sample_rate = int(sample_rate) + message.stored_file_start.channels = int(channels) + return message.SerializeToString() + + +def encode_stored_file_data_message(seq: int, chunk_bytes: bytes) -> bytes: + message = _new_message( + ws_pb2.MESSAGE_KIND_STORED_FILE, + ws_pb2.MESSAGE_TYPE_DATA, + seq, + ) + message.stored_file_data.chunk_bytes = chunk_bytes + return message.SerializeToString() + + +def encode_stored_file_end_message(seq: int) -> bytes: + message = _new_message( + ws_pb2.MESSAGE_KIND_STORED_FILE, + ws_pb2.MESSAGE_TYPE_END, + seq, + ) + message.stored_file_end.SetInParent() + return message.SerializeToString() + + def encode_state_command_message(seq: int, state_id: int) -> bytes: message = _new_message( ws_pb2.MESSAGE_KIND_STATE_CMD, @@ -181,6 +223,9 @@ def encode_servo_command_message(seq: int, commands: Sequence[ServoCommand]) -> "encode_audio_wav_start_message", "encode_server_metadata_message", "encode_servo_command_message", + "encode_stored_file_data_message", + "encode_stored_file_end_message", + "encode_stored_file_start_message", "encode_state_command_message", "parse_websocket_message", "ws_pb2", diff --git a/stackchan_server/wakeword_sound.py b/stackchan_server/wakeword_sound.py new file mode 100644 index 0000000..e746f0c --- /dev/null +++ b/stackchan_server/wakeword_sound.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +import os +import sys +import wave +from array import array +from dataclasses import dataclass +from logging import getLogger +from pathlib import Path + +DEFAULT_WAKE_WORD_SOUND_FILE_ID = "wakeword-detected-sound" +DEFAULT_WAKE_WORD_SOUND_CONTENT_TYPE = "audio/pcm" +WAKEWORD_SOUND_PATH_ENV_VAR = "STACKCHAN_WAKEWORD_SOUND_PATH" +WAKEWORD_SOUND_TARGET_SAMPLE_RATE = 24000 +WAKEWORD_SOUND_TARGET_CHANNELS = 1 +WAKEWORD_SOUND_PREROLL_MS = 40 +WAKEWORD_SOUND_POSTROLL_MS = 180 +WAKEWORD_SOUND_MIN_DURATION_MS = 700 + +logger = getLogger(__name__) + + +@dataclass(frozen=True) +class WakeWordSound: + file_id: str + content_type: str + payload: bytes + sample_rate: int + channels: int + + +def _decode_pcm16le(payload: bytes) -> array[int]: + samples = array("h") + samples.frombytes(payload) + if sys.byteorder != "little": + samples.byteswap() + return samples + + +def _encode_pcm16le(samples: array[int]) -> bytes: + encoded = array("h", samples) + if sys.byteorder != "little": + encoded.byteswap() + return encoded.tobytes() + + +def _clamp_pcm16(value: float) -> int: + return max(-32768, min(32767, int(round(value)))) + + +def _pcm16_silence(sample_count: int) -> array[int]: + if sample_count <= 0: + return array("h") + return array("h", [0]) * sample_count + + +def _mix_to_mono(samples: array[int], channels: int) -> array[int]: + if channels <= 1: + return array("h", samples) + + mono_samples = array("h") + frame_count = len(samples) // channels + for frame_index in range(frame_count): + base = frame_index * channels + mixed = sum(samples[base + channel_index] for channel_index in range(channels)) / channels + mono_samples.append(_clamp_pcm16(mixed)) + return mono_samples + + +def _resample_mono_pcm16(samples: array[int], src_rate: int, dst_rate: int) -> array[int]: + if src_rate == dst_rate or len(samples) <= 1: + return array("h", samples) + + dst_length = max(1, round(len(samples) * dst_rate / src_rate)) + resampled = array("h") + for dst_index in range(dst_length): + src_position = dst_index * src_rate / dst_rate + left_index = int(src_position) + if left_index >= len(samples) - 1: + resampled.append(samples[-1]) + continue + + right_index = left_index + 1 + fraction = src_position - left_index + interpolated = samples[left_index] + (samples[right_index] - samples[left_index]) * fraction + resampled.append(_clamp_pcm16(interpolated)) + return resampled + + +def _normalize_pcm16_payload(payload: bytes, sample_rate: int, channels: int) -> tuple[bytes, int, int]: + samples = _decode_pcm16le(payload) + mono_samples = _mix_to_mono(samples, channels) + normalized_samples = _resample_mono_pcm16( + mono_samples, + sample_rate, + WAKEWORD_SOUND_TARGET_SAMPLE_RATE, + ) + return ( + _encode_pcm16le(normalized_samples), + WAKEWORD_SOUND_TARGET_SAMPLE_RATE, + WAKEWORD_SOUND_TARGET_CHANNELS, + ) + + +def _pad_pcm16_for_short_playback(payload: bytes, sample_rate: int) -> bytes: + samples = _decode_pcm16le(payload) + preroll_samples = round(sample_rate * WAKEWORD_SOUND_PREROLL_MS / 1000) + postroll_samples = round(sample_rate * WAKEWORD_SOUND_POSTROLL_MS / 1000) + min_duration_samples = round(sample_rate * WAKEWORD_SOUND_MIN_DURATION_MS / 1000) + + padded = _pcm16_silence(preroll_samples) + padded.extend(samples) + padded.extend(_pcm16_silence(postroll_samples)) + + if len(padded) < min_duration_samples: + padded.extend(_pcm16_silence(min_duration_samples - len(padded))) + + return _encode_pcm16le(padded) + + +def load_wake_word_detected_sound_from_env() -> WakeWordSound | None: + raw_path = os.getenv(WAKEWORD_SOUND_PATH_ENV_VAR, "").strip() + if not raw_path: + logger.info( + "Wake-word sound WAV path is not configured: env=%s", + WAKEWORD_SOUND_PATH_ENV_VAR, + ) + return None + + wav_path = Path(raw_path).expanduser() + if not wav_path.is_absolute(): + wav_path = Path.cwd() / wav_path + + resolved_path = wav_path.resolve() + logger.info("Loading wake-word sound WAV from %s", resolved_path) + if not resolved_path.is_file(): + raise FileNotFoundError( + f"wake word sound wav file not found: {resolved_path}" + ) + + with wave.open(str(resolved_path), "rb") as wav_fp: + channels = wav_fp.getnchannels() + sample_width = wav_fp.getsampwidth() + sample_rate = wav_fp.getframerate() + payload = wav_fp.readframes(wav_fp.getnframes()) + + if sample_width != 2: + raise ValueError( + "wake word notification sound wav must be 16-bit PCM" + ) + if sample_rate <= 0: + raise ValueError("wake word notification sound wav has invalid sample rate") + if channels <= 0: + raise ValueError("wake word notification sound wav has invalid channels") + if not payload: + raise ValueError("wake word notification sound wav is empty") + + normalized_payload, normalized_sample_rate, normalized_channels = _normalize_pcm16_payload( + payload, + sample_rate, + channels, + ) + playback_ready_payload = _pad_pcm16_for_short_playback( + normalized_payload, + normalized_sample_rate, + ) + + logger.info( + "Loaded wake-word sound WAV path=%s source_sample_rate=%d source_channels=%d source_bytes=%d normalized_sample_rate=%d normalized_channels=%d normalized_bytes=%d playback_ready_bytes=%d preroll_ms=%d postroll_ms=%d min_duration_ms=%d", + resolved_path, + sample_rate, + channels, + len(payload), + normalized_sample_rate, + normalized_channels, + len(normalized_payload), + len(playback_ready_payload), + WAKEWORD_SOUND_PREROLL_MS, + WAKEWORD_SOUND_POSTROLL_MS, + WAKEWORD_SOUND_MIN_DURATION_MS, + ) + + return WakeWordSound( + file_id=DEFAULT_WAKE_WORD_SOUND_FILE_ID, + content_type=DEFAULT_WAKE_WORD_SOUND_CONTENT_TYPE, + payload=playback_ready_payload, + sample_rate=normalized_sample_rate, + channels=normalized_channels, + ) + + +__all__ = [ + "DEFAULT_WAKE_WORD_SOUND_CONTENT_TYPE", + "DEFAULT_WAKE_WORD_SOUND_FILE_ID", + "WAKEWORD_SOUND_PATH_ENV_VAR", + "WAKEWORD_SOUND_MIN_DURATION_MS", + "WAKEWORD_SOUND_POSTROLL_MS", + "WAKEWORD_SOUND_PREROLL_MS", + "WakeWordSound", + "load_wake_word_detected_sound_from_env", +] diff --git a/stackchan_server/ws_proxy.py b/stackchan_server/ws_proxy.py index 1c45236..39dcbab 100644 --- a/stackchan_server/ws_proxy.py +++ b/stackchan_server/ws_proxy.py @@ -21,11 +21,18 @@ encode_server_metadata_message, encode_servo_command_message, encode_state_command_message, + encode_stored_file_data_message, + encode_stored_file_end_message, + encode_stored_file_start_message, parse_websocket_message, ) from .speak import SpeakHandler from .static import LISTEN_AUDIO_FORMAT from .types import SpeechRecognizer, SpeechSynthesizer +from .wakeword_sound import ( + WAKEWORD_SOUND_PATH_ENV_VAR, + load_wake_word_detected_sound_from_env, +) logger = getLogger(__name__) @@ -35,6 +42,7 @@ _RECORDINGS_DIR = _BASE_DIR / "recordings" _DOWN_WAV_CHUNK = 4096 # bytes per WebSocket frame for synthesized audio (raw PCM) +_DOWN_FILE_CHUNK = 4096 # bytes per WebSocket frame for stored-file transfer _DOWN_SEGMENT_MILLIS = ( 2000 # duration of a single START-DATA-END segment in milliseconds ) @@ -191,6 +199,81 @@ async def speak(self, text: str) -> None: async def send_state_command(self, state_id: int | FirmwareState) -> None: await self._send_state_command(state_id) + async def send_file( + self, + *, + file_id: str, + content_type: str, + payload: bytes, + sample_rate: int = 0, + channels: int = 0, + ) -> None: + if not file_id: + raise ValueError("file_id must not be empty") + if not content_type: + raise ValueError("content_type must not be empty") + + logger.info( + "Sending stored file id=%s type=%s bytes=%d sample_rate=%d channels=%d", + file_id, + content_type, + len(payload), + sample_rate, + channels, + ) + await self.ws.send_bytes( + encode_stored_file_start_message( + self._next_down_seq(), + file_id=file_id, + content_type=content_type, + total_size=len(payload), + sample_rate=sample_rate, + channels=channels, + ) + ) + offset = 0 + chunk_count = 0 + while offset < len(payload): + chunk = payload[offset : offset + _DOWN_FILE_CHUNK] + await self.ws.send_bytes( + encode_stored_file_data_message(self._next_down_seq(), chunk) + ) + offset += len(chunk) + chunk_count += 1 + logger.info( + "Stored file payload sent id=%s chunks=%d bytes=%d", + file_id, + chunk_count, + len(payload), + ) + await self.ws.send_bytes(encode_stored_file_end_message(self._next_down_seq())) + logger.info("Stored file transfer completed id=%s", file_id) + + async def send_default_wake_word_sound(self) -> None: + sound = load_wake_word_detected_sound_from_env() + if sound is None: + logger.info( + "Wake-word sound upload skipped because %s is not set", + WAKEWORD_SOUND_PATH_ENV_VAR, + ) + return + + logger.info( + "Uploading wake-word sound id=%s sample_rate=%d channels=%d bytes=%d", + sound.file_id, + sound.sample_rate, + sound.channels, + len(sound.payload), + ) + + await self.send_file( + file_id=sound.file_id, + content_type=sound.content_type, + payload=sound.payload, + sample_rate=sound.sample_rate, + channels=sound.channels, + ) + async def reset_state(self) -> None: await self.send_state_command(FirmwareState.IDLE)