1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

StackChan(M5Stack CoreS3)を GCE 無料枠 + Gemini Live API で動かす – 会話履歴永続化まで

1
Posted at

はじめに

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-micro
  • us-central1
  • pd-standard 30GB
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": "天気を教えて"}
  ]
}
  • recenthistory_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.goinputAudioTranscription を 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.godeviceID を受け取り、起動時に会話履歴を 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.pynvs_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.pyrudyll/stackchan_ha_addonsflash_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 falsetrue になる

jq の // 演算子は falsenull と同様に扱うため、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検索ができるようになったのが良かったです。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?