はじめに
StackChan は M5Stack CoreS3 上で動くかわいい手のひらサイズのデスクトップ AI ロボットです。公式の StackChan World アプリを使えばデフォルトのサーバーに接続し無料で会話できますが、自前サーバーを立てることで以下のことができます。
- 好きな AI プロバイダー(Gemini / OpenAI)を使える
- Web 検索(Search grounding)を有効化できる
- Home Assistant との連携ができる (私は試してませんが)
本記事では GCP の always-free (無料) 枠に Go サーバーを構築し、Gemini Live API で会話する環境を作るまでの手順と、ハマりポイントをまとめます。
システム構成
StackChan (M5Stack CoreS3)
↕ WebSocket (Xiaozhi v3 プロトコル)
GCE e2-micro (us-central1-a / always-free)
└ Docker: stackchan-server (Go)
↕ WebSocket
Gemini Live API (gemini-2.5-flash-native-audio-latest)
| 項目 | 値 |
|---|---|
| GCP プロジェクト(GCE用) | 任意(本記事では <your-gce-project-id> と表記) |
| マシンタイプ | e2-micro(always-free 対象) |
| OS | Debian 12 |
| AI プロバイダー | Gemini Live API |
| モデル | gemini-2.5-flash-native-audio-latest |
本記事では 2つの GCP プロジェクト を使用します。
- GCE プロジェクト (
<your-gce-project-id>): インスタンスのホスティング用 - AI Studio プロジェクト (
<ai-studio-project-id>): Gemini API キー発行用(AI Studio が自動生成するプロジェクト)
1. GCE インスタンス作成
always-free 条件を満たす設定で作成します。
always-free 条件:
e2-microus-central1-
pd-standard30GB
gcloud projects create <your-gce-project-id> --name="StackChan HA"
gcloud billing projects link <your-gce-project-id> --billing-account=<billing-account-id>
gcloud services enable compute.googleapis.com --project=<your-gce-project-id>
gcloud compute instances create stackchan-ha \
--project=<your-gce-project-id> \
--zone=us-central1-a \
--machine-type=e2-micro \
--image-family=debian-12 \
--image-project=debian-cloud \
--boot-disk-size=30GB \
--boot-disk-type=pd-standard \
--tags=stackchan-ha
e2-micro は RAM 1GB のため swap を設定します:
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
2. Docker と stackchan-server のセットアップ
サーバーのソースコードは rudyll/stackchan_ha_addons(MIT License)をベースに会話履歴・Search grounding 対応を追加したものを kter/stackchan-gemini-server で公開しています。
# Docker インストール
curl -fsSL https://get.docker.com | sudo sh
# ソース取得とビルド
git clone https://github.com/kter/stackchan-gemini-server.git
cd stackchan-gemini-server/stackchan_ha_addons/stackchan-server
sudo docker build -t stackchan-server .
# Docker ネットワーク作成
sudo docker network create stackchan-net
コンテナ起動時に run.sh が /data/options.json を読み込み、config.yaml を自動生成してサーバーを起動します。upstream からの変更差分は以下の通りです(会話履歴・Search grounding 対応を追加)。
--- a/stackchan-server/run.sh
+++ b/stackchan-server/run.sh
@@ -5,6 +5,8 @@
OPTIONS=/data/options.json
get() { jq -r --arg k "$1" --arg d "$2" '.[$k] // $d' "$OPTIONS"; }
+# jq // treats false as missing; use this for boolean keys
+get_bool() { jq -r --arg k "$1" --arg d "$2" 'if .[$k] != null then (.[$k] | tostring) else $d end' "$OPTIONS"; }
LOCAL_HOST=$(get local_host "127.0.0.1")
HA_MCP_TOKEN=$(get ha_mcp_token "")
@@ -15,7 +17,11 @@
GEMINI_KEY=$(get gemini_api_key "")
GEMINI_MODEL=$(get gemini_model "gemini-2.5-flash-native-audio-latest")
GEMINI_VOICE=$(get gemini_voice "Aoede")
-GEMINI_ENABLE_TOOLS=$(get gemini_enable_tools "true")
+GEMINI_ENABLE_TOOLS=$(get_bool gemini_enable_tools "true")
+GEMINI_ENABLE_SEARCH=$(get_bool gemini_enable_search "false")
+HISTORY_ENABLED=$(get_bool history_enabled "true")
+HISTORY_MAX_RECENT=$(get history_max_recent "30")
+HISTORY_SUMMARY_MODEL=$(get history_summary_model "gemini-2.5-flash")
SYSTEM_PROMPT=$(get system_prompt "You are StackChan, a friendly desktop robot assistant. Keep replies concise.")
@@ -62,6 +68,10 @@
gemini_model: "${GEMINI_MODEL}"
gemini_voice: "${GEMINI_VOICE}"
gemini_enable_tools: ${GEMINI_ENABLE_TOOLS}
+ gemini_enable_search: ${GEMINI_ENABLE_SEARCH}
+ history_enabled: ${HISTORY_ENABLED}
+ history_max_recent: ${HISTORY_MAX_RECENT}
+ history_summary_model: "${HISTORY_SUMMARY_MODEL}"
system_prompt: "${SYSTEM_PROMPT}"
EOF
3. Gemini API キーの作成
API キーは AI Studio プロジェクト(<ai-studio-project-id>)で作成します。これは GCE 用プロジェクト(<your-gce-project-id>)とは別のプロジェクトです。
AI Studio プロジェクトのプロジェクト ID は Google AI Studio にアクセスして確認できます(URL やプロジェクト設定に表示されます)。
# AI Studio プロジェクトでキーを作成
gcloud beta services api-keys create \
--display-name="StackChan Gemini Key" \
--api-target=service=generativelanguage.googleapis.com \
--project=<ai-studio-project-id> 2>&1 | grep keyString
# IP 制限を追加(GCE インスタンスの IP のみ許可)
gcloud beta services api-keys update <KEY_RESOURCE_NAME> \
--project=<ai-studio-project-id> \
--allowed-ips=<your-gce-ip> \
--api-target=service=generativelanguage.googleapis.com
4. 設定ファイル(/data/options.json)
{
"local_host": "<your-gce-ip>",
"ha_mcp_token": "",
"ai_provider": "gemini",
"gemini_api_key": "<your-api-key>",
"gemini_model": "gemini-2.5-flash-native-audio-latest",
"gemini_voice": "Zephyr",
"gemini_enable_tools": false,
"gemini_enable_search": true,
"system_prompt": "あなたはStackChan(スタックチャン)です。日本語で返答し、簡潔で親しみやすい口調を心がけてください。",
"history_enabled": true,
"history_max_recent": 30,
"history_summary_model": "gemini-2.5-flash"
}
音声(gemini_voice)
Gemini Live API で使用可能な音声の一覧です。性別のみ記載しています。実際の音声は Gemini-TTS の公式ドキュメント で試聴できます。
| 声名 | 性別 |
|---|---|
| Aoede | 女性 |
| Charon | 男性 |
| Fenrir | 男性 |
| Kore | 女性 |
| Leda | 女性 |
| Orus | 男性 |
| Puck | 男性 |
| Zephyr | 女性 |
その他多数のボイスが利用可能です。詳細は上記ドキュメントを参照してください。
5. コンテナ起動
sudo docker run -d \
--name stackchan-server \
--restart=unless-stopped \
--network=stackchan-net \
-v /data:/data \
-p 12800:12800 \
stackchan-server
6. ファイアウォール設定(自宅 IP のみ許可)
gcloud compute firewall-rules create stackchan-ws \
--project=<your-gce-project-id> \
--target-tags=stackchan-ha \
--allow=tcp:12800 \
--source-ranges=<your-home-ip>/32
会話履歴の永続化
セッションが切れるたびに会話を忘れる問題を解決するため、デバイスごとに発話を JSON ファイルに保存し、次回セッション開始時に system prompt に注入します。
データ構造
{
"device_id": "44:1b:f6:e2:06:00",
"summary": "ユーザーは今日のニュース、天気について質問。",
"summary_until": "2026-05-24T02:00:00Z",
"recent": [
{"ts": "2026-05-24T02:09:13Z", "text": "今日のニュースは?"},
{"ts": "2026-05-24T02:13:50Z", "text": "天気を教えて"}
]
}
-
recentがhistory_max_recentを超えると古い半分を Gemini REST API で要約してsummaryにマージ - 次回セッション開始時、
summary+recentを system prompt に追記
プロンプト合成イメージ
[元の system_prompt]
## これまでの会話の要約(2026-05-24 まで):
ユーザーは今日のニュース、天気について質問。
## 直近のユーザー発話:
- [2026-05-24 02:09] 今日のニュースは?
- [2026-05-24 02:13] 天気を教えて
実装の変更点
history.go は upstream に存在しない新規ファイルです。全文は公開リポジトリで確認できます。
kter/stackchan-gemini-server — history.go
その他 3 ファイルの upstream からの差分です。
gemini_client.go — inputAudioTranscription を setup トップレベルに追加
--- a/server/internal/service/ai/gemini_client.go
+++ b/server/internal/service/ai/gemini_client.go
@@ -95,6 +95,11 @@
},
},
},
+ // inputAudioTranscription goes at setup level (not inside generationConfig).
+ // Gemini Live sends input_transcription events best-effort; no opt-in needed
+ // for native-audio models — existing handler at gemini_client.go:343 picks
+ // them up when the model emits them.
+ "inputAudioTranscription": map[string]any{},
"systemInstruction": map[string]any{
"parts": []map[string]any{{"text": sysPrompt}},
},
provider.go — deviceID を受け取り、起動時に会話履歴を system prompt に注入
--- a/server/internal/service/ai/provider.go
+++ b/server/internal/service/ai/provider.go
@@ -51,12 +51,21 @@
ctx context.Context,
ha *haWSClient,
cb RealtimeCallbacks,
+ deviceID string,
) (RealtimeSession, error) {
cfg := g.Cfg()
provider := cfg.MustGet(ctx, "ai.provider", "openai").String()
sysPrompt := cfg.MustGet(ctx, "ai.system_prompt",
"You are StackChan, a friendly desktop robot assistant.").String()
+ if cfg.MustGet(ctx, "ai.history_enabled", true).Bool() && deviceID != "" {
+ h, err := LoadHistory(deviceID)
+ if err != nil {
+ g.Log().Warningf(ctx, "[HIST] device=%s load failed: %v", deviceID, err)
+ }
+ sysPrompt = BuildAugmentedPrompt(sysPrompt, h)
+ }
+
switch provider {
case "gemini":
apiKey := cfg.MustGet(ctx, "ai.gemini_api_key", "").String()
ws_simulator.go — STT トークンをバッファに蓄積し、発話終了時にまとめて履歴保存。HA 未設定時のクラッシュも修正。
--- a/server/internal/service/ai/ws_simulator.go
+++ b/server/internal/service/ai/ws_simulator.go
@@ -27,6 +27,7 @@
"encoding/binary"
"encoding/json"
"net/http"
+ "strings"
"sync"
"sync/atomic"
"time"
@@ -50,9 +51,10 @@
rt RealtimeSession
opusDec *opus.Decoder // device input decoder (16kHz, reset per utterance)
- mu sync.Mutex // protects opusEnc and isListening
+ mu sync.Mutex // protects opusEnc, isListening, sttBuf
opusEnc *opusStreamEncoder // non-nil only while model is speaking
isListening bool
+ sttBuf strings.Builder // accumulates incremental STT tokens for one turn
@@ -79,21 +81,28 @@
deviceID := r.Header.Get("Device-Id")
- g.Log().Infof(ctx, "[WS] device=%s connecting HA at %s", deviceID, haURL)
- ha, err := dialHAWebSocket(haURL, haToken)
- if err != nil {
- g.Log().Warningf(ctx, "[WS] device=%s HA connect failed: %v", deviceID, err)
- conn.Close()
- return
+ var ha *haWSClient
+ if haToken != "" {
+ g.Log().Infof(ctx, "[WS] device=%s connecting HA at %s", deviceID, haURL)
+ var haErr error
+ ha, haErr = dialHAWebSocket(haURL, haToken)
+ if haErr != nil {
+ g.Log().Warningf(ctx, "[WS] device=%s HA connect failed: %v", deviceID, haErr)
+ } else {
+ g.Log().Infof(ctx, "[WS] device=%s HA connected", deviceID)
+ }
+ } else {
+ g.Log().Infof(ctx, "[WS] device=%s HA skipped (ha_mcp_token not configured)", deviceID)
}
- g.Log().Infof(ctx, "[WS] device=%s HA connected", deviceID)
@@ -117,6 +126,10 @@
OnSTT: func(text string) {
_ = s.sendJSON(map[string]any{"type": "stt", "text": text})
+ // Accumulate incremental tokens; flush to history on OnStop.
+ s.mu.Lock()
+ s.sttBuf.WriteString(text)
+ s.mu.Unlock()
},
@@ -164,7 +177,15 @@
s.mu.Lock()
enc := s.opusEnc
s.opusEnc = nil
+ utterance := strings.TrimSpace(s.sttBuf.String())
+ s.sttBuf.Reset()
s.mu.Unlock()
+ // Save the complete user utterance accumulated from STT tokens.
+ if utterance != "" && deviceID != "" {
+ if err := AppendUserTurn(deviceID, utterance); err != nil {
+ g.Log().Warningf(ctx, "[WS] device=%s history append: %v", deviceID, err)
+ }
+ }
- rt, err := dialProvider(ctx, ha, cb)
+ rt, err := dialProvider(ctx, ha, cb, deviceID)
NVS 書き換え(接続先変更)
StackChan の接続先 URL は NVS(Non-Volatile Storage)パーティションに保存されています。リポジトリに含まれる flash_nvs.py で書き換えます。
前提:ESP-IDF のインストールと有効化
flash_nvs.py は ESP-IDF の parttool.py と nvs_partition_gen.py を使用するため、ESP-IDF のフルインストールが必要です。
# ESP-IDF のインストール(未インストールの場合)
git clone --recursive https://github.com/espressif/esp-idf.git ~/esp/esp-idf
~/esp/esp-idf/install.sh
# 環境の有効化(実行のたびに必要)
. ~/esp/esp-idf/export.sh
flash_nvs.py
flash_nvs.py は rudyll/stackchan_ha_addons のflash_nvs.pyをそのまま使用しています。
実行すると言語・ポート・IP を対話形式で入力し、ota_url=http://<your-gce-ip>:12800/xiaozhi/ota/ を NVS パーティションに書き込みます。
書き込み後はデバイスを再起動すると新しいサーバーに接続します。OTA アップデート後は再実行が必要です。
ハマりポイント集
① inputAudioTranscription の位置(1007 エラー)
STT(音声認識)を有効にするには Gemini Live の setup payload に inputAudioTranscription を含める必要がありますが、generationConfig の中に入れると 1007 エラーが発生してセッションが即座に終了します。
code=1007 reason="Invalid JSON payload received. Unknown name \"inputAudioTranscription\"
at 'setup.generation_config': Cannot find field."
正しい位置: setupBody のトップレベル(generationConfig の兄弟)
// NG: generationConfig の中
"generationConfig": map[string]any{
"inputAudioTranscription": map[string]any{},
}
// OK: setup トップレベル
setupBody := map[string]any{
"model": "models/" + model,
"generationConfig": map[string]any{...},
"systemInstruction": map[string]any{...},
"inputAudioTranscription": map[string]any{}, // ここ
}
② run.sh で boolean false が true になる
jq の // 演算子は false を null と同様に扱うため、false がデフォルト値にフォールバックしてしまいます。
# NG: false が true になる
get() { jq -r --arg k "$1" --arg d "$2" '.[$k] // $d' "$OPTIONS"; }
# OK: null チェックで安全に扱う
get_bool() { jq -r --arg k "$1" --arg d "$2" \
'if .[$k] != null then (.[$k] | tostring) else $d end' "$OPTIONS"; }
③ Search grounding で 1008 エラー
gemini_enable_search: true にすると code=1008 でセッション強制終了する場合があります。原因は AI Studio プロジェクトに請求先アカウントが未設定なことです。
Search grounding は実際に使用したクエリ数に応じて課金されます(請求先アカウントを設定しただけでは課金されません)。詳細は 公式料金ページ を参照してください。
④ STT が断片で記録される
Gemini Live API は音声認識テキストをストリーミングで断片送信します(「今」「日」「の」「天気」etc.)。各断片をそのまま保存すると履歴がバラバラになります。
// OnSTT: バッファに蓄積
OnSTT: func(text string) {
s.mu.Lock()
s.sttBuf.WriteString(text)
s.mu.Unlock()
},
// OnStop: 完全な発話をまとめて保存
OnStop: func() {
s.mu.Lock()
utterance := strings.TrimSpace(s.sttBuf.String())
s.sttBuf.Reset()
s.mu.Unlock()
if utterance != "" {
AppendUserTurn(deviceID, utterance)
}
},
まとめ
自前サーバーを立てることで StackChan World に頼らずに自由な構成で動かすことができました。Gemini 2.5 Flash のネイティブ音声モードは音声が崩れるときはありますが、十分だと感じています。何よりWeb検索ができるようになったのが良かったです。