Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
コミットメッセージは英語で書いてください。日本語のコミットメッセージは避けてください。
49 changes: 29 additions & 20 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,49 +5,57 @@
## 全体像

- CoreS3 側は `firmware/`、Python サーバー側は `stackchan_server/`。
- WebSocket の on-wire 形式は手書きバイナリヘッダではなく `protobuf/websocket-message.proto` で定義した protobuf。
- 音声 uplink は `AudioPcm`、音声 downlink は `AudioWav`(実体は raw PCM)。
- サーバーは FastAPI を公開し、WebSocket と REST API の両方を持つ。
- サーボ制御が追加済みで、WebSocket プロトコルには `ServoCmd` / `ServoDoneEvt` がある。

## 状態遷移の要点

- ファームウェア状態: `Idle`, `Listening`, `Thinking`, `Speaking`, `Disconnected`
- サーバーから指示できるのは `StateCmd` の `0..3` (`Idle`〜`Speaking`)
- サーバーから指示できるのは `StateCmd` の `Idle` / `Listening` / `Thinking` / `Speaking`
- `Disconnected` はファームウェア内部状態で、WebSocket 切断時に入る
- `WakeWordEvt` を受けるか、REST API の wakeword 擬似発火で talk session が始まる

## WebSocket プロトコル要約

- 共通ヘッダ: `WsHeader` (`<B B B H H>`, packed, little-endian)
- 1 WebSocket binary frame = 1 protobuf `WebSocketMessage`
- protobuf 定義: `protobuf/websocket-message.proto`
- package: `stackchan.websocket.v1`
- envelope fields
- `kind`
- `message_type`
- `seq`
- `oneof body`
- `kind`
- `1=AudioPcm`
- `2=AudioWav`
- `3=StateCmd`
- `4=WakeWordEvt`
- `5=StateEvt`
- `6=SpeakDoneEvt`
- `7=ServoCmd`
- `8=ServoDoneEvt`
- `AudioPcm`
- `AudioWav`
- `StateCmd`
- `WakeWordEvt`
- `StateEvt`
- `SpeakDoneEvt`
- `ServoCmd`
- `ServoDoneEvt`
- `messageType`
- `1=START`
- `2=DATA`
- `3=END`
- `START`
- `DATA`
- `END`

### 現行挙動

- `AudioPcm`
- PCM16LE / 16kHz / 1ch
- `START -> DATA* -> END`
- `AudioPcmStart -> AudioChunk* -> AudioPcmEnd`
- `DATA` は 2000 samples(4000 bytes, 約 125ms)ごと
- 無音 3 秒で自動終了
- `AudioWav`
- 名前に反して WAV コンテナではなく PCM ストリーム
- `START` payload は `<uint32 sample_rate><uint16 channels>`
- `AudioWavStart.sample_rate` / `AudioWavStart.channels` を送る
- `DATA` chunk は既定 4096 bytes
- 約 2 秒セグメントで送信し、2 本目は約 1 秒後に先行開始
- `ServoCmd`
- payload: `<uint8 count><commands...>`
- op: `0=Sleep`, `1=MoveX`, `2=MoveY`
- `ServoCommandSequence.commands[]`
- op: `Sleep`, `MoveX`, `MoveY`
- 新規コマンド受信時は実行中シーケンスを置き換える

## サーバー側 (`stackchan_server/`)
Expand Down Expand Up @@ -82,15 +90,16 @@

- `src/main.cpp`
- Wi-Fi 接続後、`/ws/stackchan` に接続
- `AudioWav`, `StateCmd`, `ServoCmd` を受信処理
- protobuf `WebSocketMessage` を decode して `AudioWav`, `StateCmd`, `ServoCmd` を受信処理
- 通信が 60 秒止まると `Thinking` / `Speaking` から `Idle` に戻す
- `src/listening.cpp`
- マイク読み取り 256 サンプル単位
- 2 秒リングバッファ
- protobuf の `AudioPcmStart/Data/End` を送信
- 無音 3 秒で停止
- `src/speaking.cpp`
- 3 本バッファで TTS セグメント受信
- `END` 後に `M5.Speaker.playRaw()` で再生
- 3 本バッファで protobuf `AudioWavStart/Data/End` を受信
- `AudioWavEnd` 後に `M5.Speaker.playRaw()` で再生
- 再生完了時に `SpeakDoneEvt`
- `src/servo.cpp`
- `ServoCmd` を非同期実行
Expand Down
26 changes: 26 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,33 @@
UV ?= uv
PROTO_DIR := protobuf
PROTO_FILE := $(PROTO_DIR)/websocket-message.proto
PY_PROTO_OUT_DIR := stackchan_server/generated_protobuf
FW_PROTO_OUT_DIR := firmware/lib/generated_protobuf
NANOPB_GENERATOR := .pio/libdeps/m5stack-cores3-m5unified/Nanopb/generator/nanopb_generator.py

.PHONY: lint lint-fix protobuf protobuf-python protobuf-firmware clean-protobuf

lint:
uv run ruff check stackchan_server example_apps
uv run ty check stackchan_server example_apps

lint-fix:
uv run ruff check --fix stackchan_server example_apps
uv run ty check stackchan_server example_apps

protobuf: protobuf-python protobuf-firmware

protobuf-python: $(PROTO_FILE)
mkdir -p $(PY_PROTO_OUT_DIR)
touch $(PY_PROTO_OUT_DIR)/__init__.py
$(UV) run python -m grpc_tools.protoc -I$(PROTO_DIR) --python_out=$(PY_PROTO_OUT_DIR) $(PROTO_FILE)

protobuf-firmware: $(PROTO_FILE)
@test -f $(NANOPB_GENERATOR) || (echo "nanopb generator not found: $(NANOPB_GENERATOR)" && exit 1)
mkdir -p $(FW_PROTO_OUT_DIR)
$(UV) run python $(NANOPB_GENERATOR) --proto-path=$(PROTO_DIR) --output-dir=$(FW_PROTO_OUT_DIR) $(PROTO_FILE)

clean-protobuf:
rm -f $(PY_PROTO_OUT_DIR)/websocket_message_pb2.py
rm -f stackchan_server/generated/websocket_message_pb2.py
rm -f $(FW_PROTO_OUT_DIR)/websocket-message.pb.h $(FW_PROTO_OUT_DIR)/websocket-message.pb.c
149 changes: 79 additions & 70 deletions docs/websocket_protocols_ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,47 +2,56 @@
コーディングエージェント向け指示: このディレクトリにはプロトコルのみを記述し、CPP、Pythonの実装コードの例を記述する必要はありません。どんなプロトコルが実装されているか確認するために用います。
-->

# WebSocket バイナリプロトコル仕様
# WebSocket protobuf プロトコル仕様

このドキュメントは、CoreS3 ファームウェアと Python サーバーがやり取りする WebSocket バイナリプロトコルの現行実装をまとめたものです
このドキュメントは、CoreS3 ファームウェアと Python サーバーがやり取りする WebSocket プロトコルの現行実装をまとめたものです

## 共通ヘッダ
現行実装では、1 回の WebSocket binary frame に 1 つの protobuf `WebSocketMessage` を格納します。

共通ヘッダ `WsHeader` は `firmware/include/protocols.hpp` で定義されています。
## protobuf 定義

- packed
- little-endian
- 構造: `<B B B H H>`
- proto file: `protobuf/websocket-message.proto`
- package: `stackchan.websocket.v1`
- top-level message: `WebSocketMessage`

### `WebSocketMessage`

| フィールド | 型 | 説明 |
| --- | --- | --- |
| `kind` | `uint8` | メッセージ種別 |
| `messageType` | `uint8` | `1=START`, `2=DATA`, `3=END` |
| `reserved` | `uint8` | 現在は常に `0` |
| `seq` | `uint16` | 送信側でインクリメントするシーケンス番号 |
| `payloadBytes` | `uint16` | ヘッダ直後に続く payload のバイト数 |

### `kind` 一覧

| kind | 名前 | 方向 | 用途 |
| --- | --- | --- | --- |
| `1` | `AudioPcm` | CoreS3 → Server | マイク音声 PCM ストリーム |
| `2` | `AudioWav` | Server → CoreS3 | TTS 音声 PCM ストリーム |
| `3` | `StateCmd` | Server → CoreS3 | 状態遷移指示 |
| `4` | `WakeWordEvt` | CoreS3 → Server | ウェイクワード検出通知 |
| `5` | `StateEvt` | CoreS3 → Server | 現在状態通知 |
| `6` | `SpeakDoneEvt` | CoreS3 → Server | 音声再生完了通知 |
| `7` | `ServoCmd` | Server → CoreS3 | サーボ動作シーケンス指示 |
| `8` | `ServoDoneEvt` | CoreS3 → Server | サーボ動作完了通知 |

## `AudioPcm` (`kind=1`)
| `kind` | `MessageKind` | メッセージ種別 |
| `message_type` | `MessageType` | `START` / `DATA` / `END` |
| `seq` | `uint32` | 送信側でインクリメントするシーケンス番号 |
| `body` | `oneof` | `kind` / `message_type` に対応する typed body |

### `MessageKind` 一覧

| 名前 | 方向 | 用途 |
| --- | --- | --- |
| `AudioPcm` | CoreS3 → Server | マイク音声 PCM ストリーム |
| `AudioWav` | Server → CoreS3 | TTS 音声 PCM ストリーム |
| `StateCmd` | Server → CoreS3 | 状態遷移指示 |
| `WakeWordEvt` | CoreS3 → Server | ウェイクワード検出通知 |
| `StateEvt` | CoreS3 → Server | 現在状態通知 |
| `SpeakDoneEvt` | CoreS3 → Server | 音声再生完了通知 |
| `ServoCmd` | Server → CoreS3 | サーボ動作シーケンス指示 |
| `ServoDoneEvt` | CoreS3 → Server | サーボ動作完了通知 |

### `MessageType` 一覧

| 名前 | 用途 |
| --- | --- |
| `START` | ストリームまたはセグメント開始 |
| `DATA` | データ本体 |
| `END` | ストリームまたはセグメント終了 |

## マイク入力 `AudioPcm`

- 方向: CoreS3 → Server
- フォーマット: PCM16LE / 16kHz / 1ch
- シーケンス: `START` → `DATA` 複数回 → `END`
- `START` payload: なし
- `DATA` payload: PCM16LE 生データ
- `END` payload: 現行ファームウェアではなし
- シーケンス: `AudioPcmStart` → `AudioChunk` 複数回 → `AudioPcmEnd`
- `START` body: `AudioPcmStart {}`
- `DATA` body: `AudioChunk { bytes pcm_bytes; }`
- `END` body: `AudioPcmEnd {}`

### 現行実装メモ

Expand All @@ -53,19 +62,20 @@
- 無音判定は平均絶対振幅 `<= 200` が 3 秒継続したときに発火します。
- 停止時は未送信サンプルを `DATA` で flush してから `END` を送ります。

## `AudioWav` (`kind=2`)
## スピーカ再生 `AudioWav`

- 方向: Server → CoreS3
- 名前は `AudioWav` ですが、実際に送っているのは WAV コンテナではなく PCM16LE ストリームです。
- 1 セグメントの流れは `START` → `DATA` 複数回 → `END` です。
- 1 セグメントの流れは `AudioWavStart` → `AudioChunk` 複数回 → `AudioWavEnd` です。

### payload 形式
### body 形式

| messageType | payload |
| messageType | body |
| --- | --- |
| `START` | `<uint32 sample_rate><uint16 channels>` |
| `DATA` | PCM16LE 生データ |
| `END` | なし |
- `START` | `AudioWavStart { sample_rate, channels }` |
| `DATA` | `AudioChunk { bytes pcm_bytes; }` |
| `DATA` | `AudioChunk { pcm_bytes }` |
| `END` | `AudioWavEnd {}` |

### 現行実装メモ

Expand All @@ -75,73 +85,72 @@
- CoreS3 は 3 本の受信バッファを持ち、`END` 到達後に `M5.Speaker.playRaw()` で再生します。
- `seq` の欠損は検知しますが、TCP 前提のため再送制御は行いません。

## `StateCmd` (`kind=3`)
## 状態指示 `StateCmd`

- 方向: Server → CoreS3
- `messageType`: `DATA` のみ
- payload: 1 byte の target state id
- body: `StateCommand { state }`

| 値 | 状態 |
| --- | --- |
| `0` | `Idle` |
| `1` | `Listening` |
| `2` | `Thinking` |
| `3` | `Speaking` |
利用する状態名:

- `Idle`
- `Listening`
- `Thinking`
- `Speaking`

### 現行実装メモ

- `proxy.listen()` 開始時に Server が `Listening` を指示します。
- 音声 uplink の `END` を受けると、Server は `Thinking` を指示します。
- `proxy.speak()` 完了後、Server は `Idle` を指示します。

## `WakeWordEvt` (`kind=4`)
## ウェイクワード検出 `WakeWordEvt`

- 方向: CoreS3 → Server
- `messageType`: `DATA` のみ
- payload: 1 byte (`1=detected`)
- body: `WakeWordEvent { detected }`
- `Idle` 中のウェイクワード検出をサーバー側に通知します。
- REST API の `POST /v1/stackchan/{ip}/wakeword` は、このイベントをサーバー内部で擬似発火させます。

## `StateEvt` (`kind=5`)
## 状態通知 `StateEvt`

- 方向: CoreS3 → Server
- `messageType`: `DATA` のみ
- payload: 1 byte の current state id
- body: `StateEvent { state }`

| 値 | 状態 |
| --- | --- |
| `0` | `Idle` |
| `1` | `Listening` |
| `2` | `Thinking` |
| `3` | `Speaking` |
利用する状態名:

- `Idle`
- `Listening`
- `Thinking`
- `Speaking`

- CoreS3 は状態遷移の entry hook で送信します。
- WebSocket 切断中は `Disconnected` 状態になりますが、切断時は uplink 送信できないため `StateEvt` では通知されません。

## `SpeakDoneEvt` (`kind=6`)
## 発話完了通知 `SpeakDoneEvt`

- 方向: CoreS3 → Server
- `messageType`: `DATA` のみ
- payload: 1 byte (`1=done`)
- body: `SpeakDoneEvent { done }`
- CoreS3 側の音声再生完了を通知します。
- Server はこの通知を待って `proxy.speak()` を完了させます。

## `ServoCmd` (`kind=7`)
## サーボ動作指示 `ServoCmd`

- 方向: Server → CoreS3
- `messageType`: `DATA` のみ
- payload はサーボ動作シーケンス全体です。
- body: `ServoCommandSequence { commands }`

### payload 構造
### body 構造

- 先頭 1 byte: `<uint8 command_count>`
- 続いて `command_count` 個のコマンド
- `commands` は最大 255 個まで(`protobuf/websocket-message.options` で nanopb の `max_count:255` を指定)

| op | 名前 | payload |
| --- | --- | --- |
| `0` | `Sleep` | `<uint8 op><int16 duration_ms>` |
| `1` | `MoveX` | `<uint8 op><int8 angle><int16 duration_ms>` |
| `2` | `MoveY` | `<uint8 op><int8 angle><int16 duration_ms>` |
| 名前 | `ServoCommand` のフィールド |
| --- | --- |
| `Sleep` | `op`, `duration_ms` |
| `MoveX` | `op`, `angle`, `duration_ms` |
| `MoveY` | `op`, `angle`, `duration_ms` |

### 現行実装メモ

Expand All @@ -150,10 +159,10 @@
- `duration_ms <= 0` は即時反映になります。
- 新しい `ServoCmd` を受けると、実行中シーケンスは置き換えられます。

## `ServoDoneEvt` (`kind=8`)
## サーボ動作完了通知 `ServoDoneEvt`

- 方向: CoreS3 → Server
- `messageType`: `DATA` のみ
- payload: 1 byte (`1=done`)
- body: `ServoDoneEvent { done }`
- 直前に受信したサーボシーケンスの完了通知です。
- Server は `proxy.wait_servo_complete()` でこの完了を待てます。
4 changes: 2 additions & 2 deletions firmware/include/listening.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class Listening

private:
void updateLevelStats(const int16_t *samples, size_t sampleCount);
bool sendPacket(MessageType type, const int16_t *samples, size_t sampleCount);
bool sendPacket(stackchan_websocket_v1_MessageType type, const int16_t *samples, size_t sampleCount);
void ringPush(const int16_t *src, size_t samples);
size_t ringPop(int16_t *dst, size_t samples);

Expand All @@ -53,7 +53,7 @@ class Listening
size_t ring_read_ = 0;
size_t ring_available_ = 0;

uint16_t seq_counter_ = 0;
uint32_t seq_counter_ = 0;
bool streaming_ = false;
bool events_registered_ = false;

Expand Down
Loading
Loading