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
10 changes: 10 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ STACKCHAN_GOOGLE_CLOUD_STT_LANGUAGE_CODE="ja-JP"
# STACKCHAN_USE_WHISPER_SERVER=1
# STACKCHAN_WHISPER_SERVER_URL="http://127.0.0.1:8080/inference"
# STACKCHAN_WHISPER_SERVER_MODEL=
# STACKCHAN_WHISPER_SERVER_PROMPT=
# STACKCHAN_WHISPER_SERVER_LANGUAGE="ja"

# -- Speech Syntheis --
# Google Cloud TTS
Expand All @@ -34,6 +36,14 @@ STACKCHAN_GOOGLE_CLOUD_TTS_VOICE_NAME="Despina"
STACKCHAN_VOICEVOX_URL="http://localhost:50021"
STACKCHAN_VOICEVOX_SPEAKER=1

# -- Server-side Wakeup Word Detection --
# Whisper Server
# STACKCHAN_USE_WWD_WHISPER_SERVER=1
# STACKCHAN_WWD_WHISPER_SERVER_URL="http://127.0.0.1:8080/inference"
# STACKCHAN_WWD_WHISPER_SERVER_MODEL=
# STACKCHAN_WWD_WHISPER_SERVER_LANGUAGE="ja"
# STACKCHAN_WWD_WHISPER_SERVER_PROMPT="日本語で、スタックチャンという名前で、話しかけらるので、話しかけられたことを検出してください"

# -- Claude Agent SDK --
# using Google Cloud Vertex AI
CLAUDE_CODE_USE_VERTEX=1
Expand Down
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@

## 状態遷移の要点

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

Expand Down Expand Up @@ -75,6 +75,7 @@
- `websocket.client.host` を StackChan の識別子として使う
- 同一 IP の再接続時は既存接続を置き換える
- `listen()` は `Listening` 指示後、音声 uplink 完了を待つ
- サーバーサイド wakeword 検出中は `ServerWwd` を指示する
- `speak()` は TTS downlink 送信後、`SpeakDoneEvt` を待つ
- `move_servo()` / `wait_servo_complete()` を公開

Expand Down Expand Up @@ -106,7 +107,7 @@
- `MoveX`, `MoveY`, `Sleep` を順次処理
- 完了時に `ServoDoneEvt`
- `src/display.cpp`
- `Idle=濃いグレー`, `Listening=青`, `Thinking=オレンジ`, `Speaking=緑`, `Disconnected=赤`
- `Idle=濃いグレー`, `Listening=青`, `Thinking=オレンジ`, `Speaking=緑`, `ServerWwd=Idle(Server-WWD)`, `Disconnected=赤`

## サンプルアプリ

Expand Down
26 changes: 26 additions & 0 deletions docs/server_ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ STACKCHAN_WHISPER_CLI_VAD_MODEL_PATH="/path/to/whisper.cpp/ggml-silero-v5.1.2.bi

`STACKCHAN_WHISPER_SERVER_URL` に Whisper Server の推論エンドポイント URL をそのまま指定します。
未設定時は `http://127.0.0.1:8080/inference` を利用します。
`STACKCHAN_WHISPER_SERVER_LANGUAGE` を設定すると、その値を `language` パラメータとして各リクエストに含めます。未設定または空文字の場合は `language` を送信しません。
また、`STACKCHAN_WHISPER_SERVER_PROMPT` を設定すると、whisper-server の各リクエストに `prompt` フィールドとして送信します。

#### 例: Whisper.cppのwhisper-serverの設定

Expand All @@ -74,6 +76,8 @@ whisper.cpp/examples/server: https://github.com/ggml-org/whisper.cpp/tree/master
STACKCHAN_USE_WHISPER_SERVER=1
STACKCHAN_WHISPER_SERVER_URL="http://127.0.0.1:8080/inference"
STACKCHAN_WHISPER_SERVER_MODEL=
STACKCHAN_WHISPER_SERVER_LANGUAGE="ja"
STACKCHAN_WHISPER_SERVER_PROMPT=""
```

#### 例: [Lemonade](https://lemonade-server.ai/) を使う場合
Expand All @@ -84,6 +88,28 @@ Lemonade: https://lemonade-server.ai/
STACKCHAN_USE_WHISPER_SERVER=1
STACKCHAN_WHISPER_SERVER_URL=http://localhost:13305/api/v1/audio/transcriptions
STACKCHAN_WHISPER_SERVER_MODEL=Whisper-Large-v3-Turbo
STACKCHAN_WHISPER_SERVER_LANGUAGE="ja"
STACKCHAN_WHISPER_SERVER_PROMPT=""
```

### (オプション) サーバーサイド wakeword 用 Whisper Server の設定

サーバーサイド wakeword 検出を有効にするには、以下を設定します。

- `STACKCHAN_USE_WWD_WHISPER_SERVER`: `1`
- `STACKCHAN_WWD_WHISPER_SERVER_URL`: wakeword 検出専用 Whisper Server の推論エンドポイント URL
- `STACKCHAN_WWD_WHISPER_SERVER_MODEL`: wakeword 検出専用に利用するモデル名
- `STACKCHAN_WWD_WHISPER_SERVER_LANGUAGE`: wakeword 検出専用 Whisper Server リクエストへ渡す language
- `STACKCHAN_WWD_WHISPER_SERVER_PROMPT`: wakeword 検出専用 Whisper Server リクエストへ渡す prompt

通常の音声認識で使う `STACKCHAN_WHISPER_SERVER_URL` / `STACKCHAN_WHISPER_SERVER_MODEL` とは別設定です。

```
STACKCHAN_USE_WWD_WHISPER_SERVER=1
STACKCHAN_WWD_WHISPER_SERVER_URL="http://127.0.0.1:8080/inference"
STACKCHAN_WWD_WHISPER_SERVER_MODEL=
STACKCHAN_WWD_WHISPER_SERVER_LANGUAGE="ja"
STACKCHAN_WWD_WHISPER_SERVER_PROMPT="日本語で、スタックチャンという名前で、話しかけらるので、話しかけられたことを検出してください"
```

## 音声合成の設定
Expand Down
49 changes: 48 additions & 1 deletion docs/websocket_protocols_ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,16 @@
| 名前 | 方向 | 用途 |
| --- | --- | --- |
| `AudioPcm` | CoreS3 → Server | マイク音声 PCM ストリーム |
| `ServerWwdPcm` | CoreS3 → Server | サーバーサイド wakeword 検出専用 PCM ストリーム |
| `AudioWav` | Server → CoreS3 | TTS 音声 PCM ストリーム |
| `StateCmd` | Server → CoreS3 | 状態遷移指示 |
| `WakeWordEvt` | CoreS3 → Server | ウェイクワード検出通知 |
| `StateEvt` | CoreS3 → Server | 現在状態通知 |
| `SpeakDoneEvt` | CoreS3 → Server | 音声再生完了通知 |
| `ServoCmd` | Server → CoreS3 | サーボ動作シーケンス指示 |
| `ServoDoneEvt` | CoreS3 → Server | サーボ動作完了通知 |
| `FirmwareMetadata` | CoreS3 → Server | クライアント能力通知 |
| `ServerMetadata` | Server → CoreS3 | サーバー能力通知 |

### `MessageType` 一覧

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

## サーバーサイド wakeword 入力 `ServerWwdPcm`

- 方向: CoreS3 → Server
- フォーマット: PCM16LE / 16kHz / 1ch
- シーケンス: `AudioPcmStart` → `AudioChunk` 複数回 → `AudioPcmEnd`
- `kind`: `MESSAGE_KIND_SERVER_WWD_PCM`
- body は `AudioPcm` と同じ `AudioPcmStart` / `AudioChunk` / `AudioPcmEnd` を使います。

### 現行実装メモ

- `StateCmd(ServerWwd)` を受けた CoreS3 は、この kind で uplink を開始します。
- 無音 3 秒によるクライアント側自動終了は行いません。
- サーバーはこの kind だけを server-side wakeword detector にルーティングします。

## スピーカ再生 `AudioWav`

- 方向: Server → CoreS3
Expand Down Expand Up @@ -97,13 +114,18 @@
- `Listening`
- `Thinking`
- `Speaking`
- `ServerWwd`

### 現行実装メモ

- `proxy.listen()` 開始時に Server が `Listening` を指示します。
- `proxy.listen()` 開始時に Server が `StateCmd(Listening)` を指示します。
- サーバーサイド wakeword 検出開始時は `StateCmd(ServerWwd)` を指示します。
- 音声 uplink の `END` を受けると、Server は `Thinking` を指示します。
- `proxy.speak()` 完了後、Server は `Idle` を指示します。

> [!NOTE]
> `ServerWwd` の場合、CoreS3 は内部的にマイク uplink を開始しますが、表示は `Idle(Server-WWD)` にし、無音 3 秒による自動終了も行いません。

## ウェイクワード検出 `WakeWordEvt`

- 方向: CoreS3 → Server
Expand All @@ -112,6 +134,30 @@
- `Idle` 中のウェイクワード検出をサーバー側に通知します。
- REST API の `POST /v1/stackchan/{ip}/wakeword` は、このイベントをサーバー内部で擬似発火させます。

## メタデータ交換 `FirmwareMetadata` / `ServerMetadata`

WebSocket 接続後、能力情報を相互交換します。

- CoreS3 → Server: `FirmwareMetadata`
- `has_device_wake_word`: クライアント側 wakeword 対応有無
- そのほか `device_type`, `display_width`, `display_height`, `has_led`, `servo_type`, `supports_audio_duplex`, `firmware_version`
- Server → CoreS3: `ServerMetadata`
- `has_server_wake_word`: サーバー側 wakeword 対応有無
- `server_version`

CoreS3 側は `has_server_wake_word=true` を受けると、デバイス側 wakeword を使わずにサーバー側検出モードで待機します(表示は `Idle(Server-WWD)`)。

## サーバーサイド wakeword 検出フロー

- 環境変数 `STACKCHAN_USE_WWD_WHISPER_SERVER=1` の場合、サーバーは `@app.setup()` 完了後と `Idle` 復帰後に自動でサーバーサイド wakeword 検出を開始します。
- サーバーは `StateCmd(ServerWwd)` を送信して `MESSAGE_KIND_SERVER_WWD_PCM` のマイク uplink を受信します。
- 受信した音声の直近 3 秒窓を 0.5 秒ごとに音声認識へ渡し、
定義キーワード(例: `スタクチャン`)を含むか判定します。
- 各判定タイミングの認識結果はすべてログ出力されます。
- キーワード検出時は内部 wakeword イベントを発火し、通常の `talk_session` フローに進みます。
- 検出完了時(検出/未検出を問わず)は `StateCmd(Idle)` で待機状態に戻します。
- この間、CoreS3 の画面表示は `Listening` ではなく `Idle(Server-WWD)` を維持します。

## 状態通知 `StateEvt`

- 方向: CoreS3 → Server
Expand All @@ -124,6 +170,7 @@
- `Listening`
- `Thinking`
- `Speaking`
- `ServerWwd`

- CoreS3 は状態遷移の entry hook で送信します。
- WebSocket 切断中は `Disconnected` 状態になりますが、切断時は uplink 送信できないため `StateEvt` では通知されません。
Expand Down
16 changes: 16 additions & 0 deletions firmware/include/listening.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@
class Listening
{
public:
enum class SessionMode
{
Speech,
WakeWord,
};

Listening(WebSocketsClient &ws, StateMachine &sm, int sampleRate);

// allocate buffers / reset counters; call once from setup
Expand All @@ -19,6 +25,10 @@ class Listening
void begin();
void end();

// Idle(Server-WWD) のままマイク uplink を開始/終了する
bool beginWakeWordStreaming();
void endWakeWordStreaming();

// begin a new streaming session (sends START); returns false if WS not connected
bool startStreaming();

Expand All @@ -34,7 +44,11 @@ class Listening
// 無音が所定時間続いているか判定
bool shouldStopForSilence() const;

bool isWakeWordStreaming() const { return streaming_ && session_mode_ == SessionMode::WakeWord; }

private:
bool beginStreamingSession(SessionMode mode, bool auto_stop_for_silence);
void stopMicrophoneOnly();
void updateLevelStats(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);
Expand All @@ -56,6 +70,8 @@ class Listening
uint32_t seq_counter_ = 0;
bool streaming_ = false;
bool events_registered_ = false;
SessionMode session_mode_ = SessionMode::Speech;
bool auto_stop_for_silence_ = true;

// 無音判定関連
int32_t last_level_ = 0;
Expand Down
1 change: 1 addition & 0 deletions firmware/include/metadata.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ extern ServerMetadataState g_server_metadata;
void initializeFirmwareMetadata();
void resetServerMetadata();
bool shouldUseDeviceWakeWord();
bool shouldUseServerWakeWord();
void setFirmwareMetadataMessage(
stackchan_websocket_v1_WebSocketMessage &message,
uint32_t seq);
Expand Down
8 changes: 5 additions & 3 deletions firmware/include/state_machine.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ class StateMachine
Listening = 1,
Thinking = 2,
Speaking = 3,
Disconnected = 4,
ServerWwd = 4,
Disconnected = 5,
};

StateMachine() = default;
Expand All @@ -25,6 +26,7 @@ class StateMachine
bool isListening() const;
bool isThinking() const;
bool isSpeaking() const;
bool isServerWwd() const;
bool isDisconnected() const;

using Callback = std::function<void(State prev, State next)>;
Expand All @@ -33,8 +35,8 @@ class StateMachine

private:
State state_ = Disconnected;
std::array<std::vector<Callback>, 5> entry_events_{};
std::array<std::vector<Callback>, 5> exit_events_{};
std::array<std::vector<Callback>, 6> entry_events_{};
std::array<std::vector<Callback>, 6> exit_events_{};
};

const char *stateToString(StateMachine::State state);
14 changes: 8 additions & 6 deletions firmware/lib/generated_protobuf/websocket-message.pb.h
Original file line number Diff line number Diff line change
Expand Up @@ -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_SERVER_WWD_PCM = 11
} stackchan_websocket_v1_MessageKind;

typedef enum _stackchan_websocket_v1_MessageType {
Expand All @@ -35,7 +36,8 @@ typedef enum _stackchan_websocket_v1_StackchanState {
stackchan_websocket_v1_StackchanState_STACKCHAN_STATE_IDLE = 0,
stackchan_websocket_v1_StackchanState_STACKCHAN_STATE_LISTENING = 1,
stackchan_websocket_v1_StackchanState_STACKCHAN_STATE_THINKING = 2,
stackchan_websocket_v1_StackchanState_STACKCHAN_STATE_SPEAKING = 3
stackchan_websocket_v1_StackchanState_STACKCHAN_STATE_SPEAKING = 3,
stackchan_websocket_v1_StackchanState_STACKCHAN_STATE_SERVER_WWD = 4
} stackchan_websocket_v1_StackchanState;

typedef enum _stackchan_websocket_v1_ServoOperation {
Expand Down Expand Up @@ -165,16 +167,16 @@ 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_SERVER_WWD_PCM
#define _stackchan_websocket_v1_MessageKind_ARRAYSIZE ((stackchan_websocket_v1_MessageKind)(stackchan_websocket_v1_MessageKind_MESSAGE_KIND_SERVER_WWD_PCM+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
#define _stackchan_websocket_v1_MessageType_ARRAYSIZE ((stackchan_websocket_v1_MessageType)(stackchan_websocket_v1_MessageType_MESSAGE_TYPE_END+1))

#define _stackchan_websocket_v1_StackchanState_MIN stackchan_websocket_v1_StackchanState_STACKCHAN_STATE_IDLE
#define _stackchan_websocket_v1_StackchanState_MAX stackchan_websocket_v1_StackchanState_STACKCHAN_STATE_SPEAKING
#define _stackchan_websocket_v1_StackchanState_ARRAYSIZE ((stackchan_websocket_v1_StackchanState)(stackchan_websocket_v1_StackchanState_STACKCHAN_STATE_SPEAKING+1))
#define _stackchan_websocket_v1_StackchanState_MAX stackchan_websocket_v1_StackchanState_STACKCHAN_STATE_SERVER_WWD
#define _stackchan_websocket_v1_StackchanState_ARRAYSIZE ((stackchan_websocket_v1_StackchanState)(stackchan_websocket_v1_StackchanState_STACKCHAN_STATE_SERVER_WWD+1))

#define _stackchan_websocket_v1_ServoOperation_MIN stackchan_websocket_v1_ServoOperation_SERVO_OPERATION_SLEEP
#define _stackchan_websocket_v1_ServoOperation_MAX stackchan_websocket_v1_ServoOperation_SERVO_OPERATION_MOVE_Y
Expand Down
6 changes: 6 additions & 0 deletions firmware/src/display.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

#include "config.h"
#include "display.hpp"
#include "metadata.hpp"

#if USE_STACKCHAN_BSP
#define GFXModule M5StackChan.Display()
Expand Down Expand Up @@ -119,6 +120,11 @@ void Display::drawForState(StateMachine::State state)
font_color = TFT_BLACK;
led_color = Adafruit_NeoPixel::ColorHSV(kLedHueGreen, 255, ledValueFromBrightness());
break;
case StateMachine::ServerWwd:
bg_color = TFT_DARKGRAY;
font_color = TFT_WHITE;
led_color = Adafruit_NeoPixel::ColorHSV(0, 0, 0);
break;
case StateMachine::Disconnected:
bg_color = TFT_RED;
font_color = TFT_WHITE;
Expand Down
Loading
Loading