Repository navigation
Releases: Xenoah/stackchan-codex
Release list
v1.1.5 - Gaming RGB Edition
Gaming RGB Edition. 設定画面からのローカルLLMチャットUIと、顔・本体LEDの虹色循環演出を追加したリリースです。
Added
- 設定ページに Local LLM Chat カードを追加。ブラウザの
fetchから直接 Android Gateway のhttp://<TTS Host>:<TTS Port>/askへ質問をPOSTし、LLM回答(question/answer/stackchan)を画面表示しつつStackChanに発話させる(ESP32側に中継APIは作らず、JSON処理はブラウザ側で実施)。 - Gaming RGB: 顔(画面)と本体LEDを虹色にゆっくり循環させる演出(約6秒で1周、顔とLEDの色相を同期、colorDepth=1の2トーン描画では背景と前景を補色関係で循環)。
- Gaming RGB の有効/無効を設定ページのチェックボックスで切り替え(NVS
gaming_rgbに保存、初期値ON)。 /statusページの App カードに Gaming RGB の On/Off 表示を追加。
Changed
- ステータスLED表示を
showStatusLed()経由へ変更し、Gaming RGB有効時はイベント色(TTS=青/エラー=赤/通常=緑 等)を一定時間保持してから虹色サイクルへ復帰(状態表示の意味を維持)。
Build
- m5stack-cores3: RAM 18.2% / Flash 18.4%
🤖 Generated with Claude Code
update chat ui
StackChan × Android Termux Local LLM Gateway
StackChan × Android Termux Local LLM Gateway
Android Termux 上でローカル LLM と TTS Gateway を起動し、StackChan に喋らせるためのセットアップ手順です。
この構成では、PC またはスマホのブラウザ/curl から Android Gateway に質問を送り、Android 内のローカル LLM で回答を生成し、TTS で WAV を生成して StackChan に発話指示します。
1. 目的
StackChan を Android Termux 上のローカル LLM Gateway と連携させます。
最終フローは以下です。
Windows / Android Browser / curl
↓
Android Termux Gateway : http://Android_IP:50021/ask
↓
Android 内 llama-server : http://127.0.0.1:8080/v1/chat/completions
↓
LLM回答生成
↓
pykakasi で漢字混じり日本語をかな寄りに変換
↓
espeak / espeak-ng で WAV 生成
↓
Gateway が StackChan /api/speak へ発話命令
↓
StackChan が Gateway /synthesis から WAV を取得
↓
StackChan が喋る音声認識/STT は使用しません。
質問入力はブラウザまたは curl から行います。
2. リポジトリ
GitHub:
https://github.com/Xenoah/stackchan-codexベース:
release tag β3.5.0方針:
β3.5.0 simple_wav 構成をベースにする
main ブランチの ConversationController 系は使わない
StackChan 側は simple_wav TTS と /api/speak を使う
Android 側 Gateway が LLM/TTS を担当する3. 現在の動作確認済み環境
StackChan
StackChan IP : 192.168.0.190
TTS Engine : simple_wav
TTS Host : Android Termux Gateway の LAN IP
TTS Port : 50021
Mode : LOCAL LLM例:
StackChan IP : 192.168.0.190
Android Gateway IP : 192.168.0.106
Gateway Port : 50021
LLM Port : 8080Android Termux
使用する主なもの:
Termux
python
python-pip
tmux
curl
espeak または espeak-ng
pykakasi
llama-server
GGUF model確認済みモデル例:
~/stackchan-qwen/models/qwen2.5-1.5b-instruct-q4_k_m.ggufGateway が標準で探すモデルリンク:
~/models/model.gguf4. 仕様
4.1 Android Gateway 側 API
GET /
簡易 Web UI を表示します。
http://Android_IP:50021/GET /status
Gateway の状態を返します。
例:
curl http://192.168.0.106:50021/status期待レスポンス例:
{
"ok": true,
"name": "stackchan-android-llm-gateway-fixed",
"llm_ok": true,
"ask": "/ask",
"chat": "/chat",
"synthesis": "/synthesis",
"current_wav": "/data/data/com.termux/files/home/stackchan_gateway/current.wav",
"stackchan_host": "192.168.0.190"
}POST /ask
LLM に質問し、回答を WAV 化し、StackChan に喋らせます。
curl -X POST http://192.168.0.106:50021/ask --data-urlencode "text=自己紹介して"処理内容:
質問を受け取る
↓
ローカル LLM に投げる
↓
回答の文字間スペースを除去
↓
WAV 生成
↓
StackChan /api/speak に回答本文を送る
↓
StackChan が /synthesis から WAV を取得して発話POST /chat
/ask の別名です。
curl -X POST http://192.168.0.106:50021/chat --data-urlencode "text=なぜ空は青いの?"POST /synthesis
StackChan の simple_wav 用エンドポイントです。
受け取ったテキストを WAV にして返します。
curl -X POST http://192.168.0.106:50021/synthesis \
--data-urlencode "text=今日は良い天気です" \
--output tts_test.wavGET /current.wav
最後に生成した WAV を返します。
curl http://192.168.0.106:50021/current.wav --output current.wav4.2 StackChan 側 API
GET /api/status
StackChan の状態確認。
curl http://192.168.0.190/api/status見るべき項目:
{
"tts_host": "192.168.0.106",
"tts_port": 50021,
"tts_engine": "simple_wav"
}POST /api/speak
StackChan に発話命令を送ります。
curl -X POST http://192.168.0.190/api/speak --data-urlencode "text=今日は良い天気です"注意:
これは LLM を通りません。
StackChan に直接「この文字列を喋れ」と命令するだけです。LLM を介したい場合は、Android Gateway の /ask に送ります。
curl -X POST http://192.168.0.106:50021/ask --data-urlencode "text=自己紹介して"5. StackChan 本体側セットアップ
StackChan Setup 画面を開きます。
http://192.168.0.190/以下のように設定します。
TTS Engine Type : simple_wav
TTS Host : Android Termux Gateway の LAN IP
TTS Port : 50021
Speaker/Style ID: 3
Text to speak : __REASK_LAST__例:
TTS Engine Type : simple_wav
TTS Host : 192.168.0.106
TTS Port : 50021
Speaker/Style ID: 3
Text to speak : __REASK_LAST__重要:
__REASK_LAST__前後にアンダースコア2本ずつ必要です。
間違い例:
REASK_LAST
CURRENT
__CURRENT5.1 Text to speak の意味
__REASK_LAST__
Aボタンなどで最後の質問を再処理したい場合に使います。
流れ:
StackChan Aボタン
↓
Gateway /synthesis に "__REASK_LAST__" を送る
↓
Gateway が最後の質問を LLM に再送
↓
新しい回答を WAV 化
↓
StackChan が喋る__CURRENT__
最後に生成した current.wav をそのまま喋らせる用途です。
TTS復旧テスト中には便利ですが、運用時は __REASK_LAST__ 推奨です。
6. Android Termux セットアップ
6.1 Termux の準備
Termux を起動して、以下を実行します。
pkg update -y
pkg upgrade -y
pkg install -y git python python-pip tmux curl jq procps coreutils findutils iproute2 lsof clang make cmakeTTS 用に espeak を入れます。
pkg install -y espeak環境によっては espeak-ng という名前で入る場合があります。
以下で確認します。
command -v espeak
command -v espeak-ng
ls -l $PREFIX/bin/espeak*どちらかが存在していれば OK です。
6.2 Python パッケージ
python -m pip install --upgrade pip setuptools wheel
python -m pip install --no-cache-dir pykakasi==2.3.0pykakasi は、漢字混じり日本語を読み上げ用のかな寄りテキストへ変換するために使います。
例:
今日は良い天気です
↓
きょうはよいてんきです6.3 モデル配置
推奨モデル例:
qwen2.5-1.5b-instruct-q4_k_m.gguf配置例:
~/stackchan-qwen/models/qwen2.5-1.5b-instruct-q4_k_m.ggufGateway/llama-server 用に以下のリンクを作ります。
mkdir -p ~/models
ln -sf ~/stackchan-qwen/models/qwen2.5-1.5b-instruct-q4_k_m.gguf \
~/models/model.gguf
ls -lh ~/models/model.gguf注意:
以下のようなファイルは本体モデルではありません。
ggml-vocab-qwen2.gguf
ggml-vocab-llama-spm.gguf
ggml-vocab-gpt-neox.ggufggml-vocab-*.gguf は語彙テスト用であり、LLM 本体として使えません。
6.4 llama-server
Gateway は OpenAI互換APIとして llama-server を呼びます。
期待URL:
http://127.0.0.1:8080/v1/chat/completions手動起動例:
llama-server \
-m ~/models/model.gguf \
--host 127.0.0.1 \
--port 8080 \
-c 1024 \
-ngl 0確認:
curl http://127.0.0.1:8080/v1/modelsllama-server は Android 内部だけで使うため、127.0.0.1 で起動します。
LAN公開する必要はありません。
7. ワンショット起動スクリプト
以下は、Android Termux 側で Gateway を起動するためのスクリプトです。
役割:
既存 Gateway 停止
espeak / espeak-ng 検出
モデルリンク確認
llama-server 起動
LLM対応 Gateway 起動
/status テスト
/ask テスト作成:
cat > ~/stackchan_android_llm_start.sh <<'EOF'
#!/data/data/com.termux/files/usr/bin/bash
set -Eeuo pipefail
PORT=50021
LLM_PORT=8080
STACKCHAN_HOST="${STACKCHAN_HOST:-192.168.0.190}"
PREFIX="${PREFIX:-/data/data/com.termux/files/usr}"
BIN="$PREFIX/bin"
DATA="$HOME/stackchan_gateway"
SERVER="$HOME/stackchan_android_llm_gateway.py"
MODEL_LINK="$HOME/models/model.gguf"
log() {
echo
echo "=== $* ==="
}
log "1. stop old gateway"
tmux kill-session -t stackchan_gateway 2>/dev/null || true
tmux kill-session -t stackchan_llama 2>/dev/null || true
pkill -f "stackchan_android_llm_gateway.py" 2>/dev/null || true
pkill -f "llama-server" 2>/dev/null || true
sleep 1
log "2. packages"
pkg update -y
pkg install -y python python-pip tmux curl jq procps coreutils findutils iproute2 lsof git espeak 2>/dev/null || true
python -m pip install --upgrade pip setuptools wheel >/dev/null 2>&1 || true
python -m pip install --no-cache-dir pykakasi==2.3.0 >/dev/null 2>&1 || true
mkdir -p "$DATA/logs" "$DATA/history" "$HOME/models"
log "3. detect espeak"
ESPEAK_BIN=""
# 壊れた開発中ラッパー対策。espeak-ng.real.bak がある場合は優先して復元する。
if [ -x "$BIN/espeak-ng.real.bak" ]; then
cp -f "$BIN/espeak-ng.real.bak" "$BIN/espeak-ng"
chmod +x "$BIN/espeak-ng"
ln -sf "$BIN/espeak-ng" "$BIN/espeak"
fi
if command -v espeak-ng >/dev/null 2>&1; then
ESPEAK_BIN="$(command -v espeak-ng)"
elif command -v espeak >/dev/null 2>&1; then
ESPEAK_BIN="$(command -v espeak)"
fi
if [ -z "$ESPEAK_BIN" ]; then
echo "ERROR: espeak / espeak-ng not found"
echo "Try:"
echo " termux-change-repo"
echo " pkg update"
echo " pkg install espeak"
exit 1
fi
# espeak という名前でも呼べるようにする
if ! command -v espeak >/dev/null 2>&1; then
ln -sf "$ESPEAK_BIN" "$BIN/espeak"
fi
echo "ESPEAK_BIN=$ESPEAK_BIN"
"$ESPEAK_BIN" --version 2>&1 | head -n 3 || true
log "4. model link"
PREFERRED="$HOME/stackchan-qwen/models/qwen2.5-1.5b-instruct-q4_k_m.gguf"
MODEL_SRC=""
if [ -f "$PREFERRED" ]; then
MODEL_SRC="$PREFERRED"
else
MODEL_SRC="$(
find "$HOME" /sdcard/Download -type f -iname "*.gguf" 2>/dev/null \
| grep -vi "ggml-vocab" \
| grep -vi "/vocab" \
| while read -r f; do du -k "$f" 2>/dev/null || true; done \
| sort -nr \
| head -n 1 \
| cut -f2-
)"
fi
if [ -z "$MODEL_SRC" ] || [ ! -f "$MODEL_SRC" ]; then
echo "WARNING: model gguf not found. TTS will work, but LLM will be NG."
else
ln -sf "$MODEL_SRC" "$MODEL_LINK"
echo "MODEL_SRC=$MODEL_SRC"
ls -lh "$MODEL_LINK"
fi
log "5. find llama-server"
LLAMA_BIN=""
for d in "$HOME/stackchan-qwen" "$HOME/llama.cpp" "$HOME/stackchan-codex" "$HOME"; do
if [ -d "$d" ]; then
found="$(find "$d" -type f -name "llama-server" 2>/dev/null | head -n 1 || true)"
if [ -n "$found" ]; then
LLAMA_BIN="$found"
break
fi
fi
done
if [ -n "$LLAMA_BIN" ] && [ -x "$LLAMA_BIN" ] && [ -f "$MODEL_LINK" ]; then
log "6. start llama-server"
echo "LLAMA_BIN=$LLAMA_BIN"
tmux new-session -d -s stackchan_llama \
"'$LLAMA_BIN' -m '$MODEL_LINK' --host 127.0.0.1 --port $LLM_PORT -c 1024 -ngl 0"
sleep 5
curl -s --max-time 10 "http://127.0.0.1:$LLM_PORT/v1/models" | head -c 500 || true
echo
else
echo "WARNING: llama-server or model not found. LLM will be NG."
fi
log "7. create gateway"
cat > "$SERVER" <<PY
#!/data/data/com.termux/files/usr/bin/python
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathl...success
Termuxでこれを丸ごと実行してください。いま動いているLLMとTTSはなるべく壊さず、Gatewayだけ差し替えます。
cat > ~/stackchan_fix_spacing_and_current.sh <<'EOF'
#!/data/data/com.termux/files/usr/bin/bash
set -Eeuo pipefail
PORT=50021
LLM_PORT=8080
STACKCHAN_HOST="192.168.0.190"
PREFIX="${PREFIX:-/data/data/com.termux/files/usr}"
BIN="$PREFIX/bin"
DATA="$HOME/stackchan_gateway"
SERVER="$HOME/stackchan_android_llm_gateway.py"
echo
echo "=== 1. Gateway停止 LLMは止めない ==="
tmux kill-session -t stackchan_gateway 2>/dev/null || true
pkill -f "stackchan_android_llm_gateway.py" 2>/dev/null || true
mkdir -p "$DATA/logs" "$DATA/history"
echo
echo "=== 2. espeak確認 ==="
if [ ! -x "$BIN/espeak" ] && [ -x "$BIN/espeak-ng.real.bak" ]; then
cp -f "$BIN/espeak-ng.real.bak" "$BIN/espeak-ng"
chmod +x "$BIN/espeak-ng"
ln -sf "$BIN/espeak-ng" "$BIN/espeak"
fi
if [ ! -x "$BIN/espeak" ] && [ -x "$BIN/espeak-ng" ]; then
ln -sf "$BIN/espeak-ng" "$BIN/espeak"
fi
if [ ! -x "$BIN/espeak" ]; then
echo "ERROR: espeak がありません"
ls -l "$BIN"/espeak* 2>/dev/null || true
exit 1
fi
ls -l "$BIN"/espeak*
echo
echo "=== 3. pykakasi確認 ==="
python -m pip install --no-cache-dir pykakasi==2.3.0 >/dev/null 2>&1 || true
echo
echo "=== 4. Gatewayを書き換え ==="
if [ -f "$SERVER" ]; then
cp "$SERVER" "$SERVER.bak.$(date +%Y%m%d_%H%M%S)"
fi
cat > "$SERVER" <<PY
#!/data/data/com.termux/files/usr/bin/python
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlencode
import urllib.request
import json
import subprocess
import tempfile
import re
import time
import html
PORT = $PORT
LLM_PORT = $LLM_PORT
STACKCHAN_HOST = "$STACKCHAN_HOST"
BASE = Path.home() / "stackchan_gateway"
BASE.mkdir(parents=True, exist_ok=True)
(BASE / "logs").mkdir(exist_ok=True)
(BASE / "history").mkdir(exist_ok=True)
CURRENT_WAV = BASE / "current.wav"
CURRENT_JSON = BASE / "current.json"
LAST_TXT = BASE / "tts_last.txt"
LAST_QUESTION = BASE / "last_question.txt"
LAST_ANSWER = BASE / "last_answer.txt"
LOG_FILE = BASE / "logs" / "llm_gateway.log"
ESPEAK = "/data/data/com.termux/files/usr/bin/espeak"
PITCH = "70"
SPEED = "145"
VOLUME = "105"
JP = r"[\u3040-\u30ff\u3400-\u9fff々〆ヶー]"
def log(msg):
s = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n"
print(s, end="")
try:
with LOG_FILE.open("a", encoding="utf-8") as f:
f.write(s)
except Exception:
pass
def remove_japanese_char_spaces(text: str) -> str:
# 「こ ん に ち は」「ス タ ッ ク チ ャ ン」を「こんにちは」「スタックチャン」に戻す
old = None
while old != text:
old = text
text = re.sub(f"({JP})\\s+(?={JP})", r"\1", text)
return text
def clean_text(text: str) -> str:
text = text.replace("\x00", "")
text = re.sub(r".*?", " ", text, flags=re.S)
text = re.sub(r"https?://\S+", " ", text)
text = re.sub(r"[\r\n\t]+", " ", text)
text = remove_japanese_char_spaces(text)
text = re.sub(r"\s+", " ", text).strip()
if len(text) > 300:
text = text[:300] + "。"
return text
def token_kind(text: str) -> str:
t = text.strip()
t = t.replace("_", "_")
folded = re.sub(r"[^A-Za-z_]", "", t).upper()
if folded in ("CURRENT", "CURRENT"):
return "current"
if folded in ("REASKLAST", "REASK_LAST", "REASK_LAST"):
return "reask"
return ""
def to_kana(text: str) -> str:
text = remove_japanese_char_spaces(text)
try:
import pykakasi
kks = pykakasi.kakasi()
kana = "".join(x.get("hira") or x.get("orig") or "" for x in kks.convert(text))
return remove_japanese_char_spaces(kana)
except Exception as e:
log("pykakasi error: " + repr(e))
return text
def llm_health():
try:
with urllib.request.urlopen(f"http://127.0.0.1:{LLM_PORT}/v1/models", timeout=3) as res:
return True, res.read().decode("utf-8", errors="ignore")[:300]
except Exception as e:
return False, repr(e)
def ask_llm(question: str) -> str:
question = clean_text(question)
LAST_QUESTION.write_text(question, encoding="utf-8")
payload = {
"model": "local",
"messages": [
{
"role": "system",
"content": "あなたは手のひらサイズのコミュニケーションロボット、スタックチャンです。日本語で短く自然に答えてください。読み上げやすい文章にしてください。文字と文字の間に空白を入れないでください。"
},
{
"role": "user",
"content": question
}
],
"temperature": 0.7,
"max_tokens": 160,
"stream": False
}
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
f"http://127.0.0.1:{LLM_PORT}/v1/chat/completions",
data=data,
headers={"Content-Type": "application/json"},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=90) as res:
body = json.loads(res.read().decode("utf-8", errors="ignore"))
ans = body["choices"][0]["message"]["content"]
ans = clean_text(ans)
if not ans:
ans = "うまく返事を作れませんでした。"
except Exception as e:
log("LLM ERROR: " + repr(e))
ans = "えるえるえむの呼び出しに失敗しました。"
LAST_ANSWER.write_text(ans, encoding="utf-8")
return ans
def synth_wav(text: str) -> bytes:
raw = text
kind = token_kind(raw)
if kind == "current":
if CURRENT_WAV.exists():
return CURRENT_WAV.read_bytes()
original = "まだ音声がありません。"
elif kind == "reask":
if LAST_QUESTION.exists():
q = LAST_QUESTION.read_text(encoding="utf-8", errors="ignore").strip()
original = ask_llm(q) if q else "さいごの質問がありません。"
else:
original = "さいごの質問がありません。"
else:
original = clean_text(raw)
kana = clean_text(to_kana(original))
LAST_TXT.write_text(
"ORIG:\\n" + original + "\\n\\nKANA:\\n" + kana + "\\n",
encoding="utf-8"
)
with tempfile.TemporaryDirectory() as td:
td = Path(td)
txt = td / "in.txt"
wav = td / "out.wav"
txt.write_text(kana, encoding="utf-8")
cmd = [
ESPEAK,
"-v", "ja",
"-p", PITCH,
"-s", SPEED,
"-a", VOLUME,
"-w", str(wav),
"-f", str(txt)
]
r = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=30
)
if r.returncode != 0:
raise RuntimeError("espeak failed: " + r.stderr.decode("utf-8", errors="ignore"))
data = wav.read_bytes()
if not data.startswith(b"RIFF") or b"WAVE" not in data[:16]:
raise RuntimeError("not RIFF/WAVE")
CURRENT_WAV.write_bytes(data)
CURRENT_JSON.write_text(json.dumps({
"text": original,
"kana": kana,
"time": time.time()
}, ensure_ascii=False, indent=2), encoding="utf-8")
return data
def call_stackchan_text(text: str):
# CURRENT ではなく本文を渡す。current読み上げ化を回避する
text = clean_text(text)
data = urlencode({"text": text}).encode("utf-8")
req = urllib.request.Request(
f"http://{STACKCHAN_HOST}/api/speak",
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded; charset=utf-8"},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=8) as res:
return res.read().decode("utf-8", errors="ignore")
except Exception as e:
log("StackChan call error: " + repr(e))
return "stackchan call error: " + repr(e)
class Handler(BaseHTTPRequestHandler):
server_version = "StackChanLLMGateway/1.1"
def log_message(self, fmt, *args):
log(fmt % args)
def send_body(self, code, body, ctype="text/plain; charset=utf-8"):
if isinstance(body, str):
body = body.encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
def read_form(self):
n = int(self.headers.get("Content-Length", "0") or "0")
raw = self.rfile.read(n)
s = raw.decode("utf-8", errors="ignore")
qs = parse_qs(s)
return qs, s
def do_GET(self):
path = self.path.split("?", 1)[0]
if path == "/current.wav":
if CURRENT_WAV.exists():
return self.send_body(200, CURRENT_WAV.read_bytes(), "audio/wav")
return self.send_body(404, "no current.wav")
if path in ("/status", "/health"):
ok, detail = llm_health()
body = {
"ok": True,
"name": "stackchan-android-llm-gateway-fixed",
"llm_ok": ok,
"llm_detail": detail,
"ask": "/ask",
"chat": "/chat",
"synthesis": "/synthesis",
"current_wav": str(CURRENT_WAV),
"espeak": ESPEAK,
"stackchan_host": STACKCHAN_HOST
}
return self.send_body(200, json.dumps(body, ensure_ascii=False, indent=2), "application/json; charset=utf-8")
last_q = LAST_QUESTION.read_text(encoding="utf-8", errors="ignore") if LAST_QUESTION.exists() else ""
last_a = LAST_ANSWER.read_text(encoding="utf-8", errors="ignore") if LAST_ANSWER.exists() else ""
last_tts = LAST_TXT.read_text(encoding="utf-8", errors="ignore") if LAST_TXT.exists() else ""
llm_ok, _ = llm_health()
...
curl
add motion
root on fix bug
Success to speak
add japanese discription on code
β0.3.0 add comment
set UI only which YES or NO on select set origin mode
β0.2.8 ui with only yes or no to select calibration set origin mode