Repository navigation
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 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
import shutil
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 = shutil.which("espeak-ng") or shutil.which("espeak") or "$ESPEAK_BIN"
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):
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()
page = f"""<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>StackChan LLM Gateway</title>
<style>
body {{ font-family: sans-serif; max-width: 760px; margin: 24px auto; background:#111; color:#eee; }}
textarea {{ width:100%; background:#222; color:#fff; }}
button {{ padding:10px 16px; margin-top:8px; }}
a {{ color:#8fd3ff; }}
pre {{ background:#222; padding:12px; white-space:pre-wrap; }}
</style>
</head>
<body>
<h1>StackChan LLM Gateway fixed</h1>
<p>LLM: {"OK" if llm_ok else "NG"}</p>
<form method="post" action="/ask">
<textarea name="text" rows="5">自己紹介して</textarea><br>
<button type="submit">LLMで回答して喋る</button>
</form>
<p><a href="/status">status</a> / <a href="/current.wav">current.wav</a></p>
<h2>最後の質問</h2>
<pre>{html.escape(last_q)}</pre>
<h2>最後の回答</h2>
<pre>{html.escape(last_a)}</pre>
<h2>TTSログ</h2>
<pre>{html.escape(last_tts)}</pre>
</body>
</html>"""
return self.send_body(200, page, "text/html; charset=utf-8")
def do_POST(self):
path = self.path.split("?", 1)[0]
if path == "/synthesis":
try:
qs, raw = self.read_form()
text = qs.get("text", [raw])[0]
data = synth_wav(text)
return self.send_body(200, data, "audio/wav")
except Exception as e:
log("TTS ERROR: " + repr(e))
return self.send_body(500, "TTS ERROR\\n" + repr(e) + "\\n")
if path in ("/ask", "/chat"):
try:
qs, raw = self.read_form()
q = qs.get("text", qs.get("q", qs.get("question", [raw])))[0]
q = clean_text(q)
ans = ask_llm(q)
synth_wav(ans)
# __CURRENT__ ではなく回答本文を送る。
# StackChan が current をアルファベット読みする事故を避けるため。
stackchan_res = call_stackchan_text(ans)
body = {
"ok": True,
"question": q,
"answer": ans,
"stackchan": stackchan_res
}
return self.send_body(200, json.dumps(body, ensure_ascii=False, indent=2), "application/json; charset=utf-8")
except Exception as e:
log("ASK ERROR: " + repr(e))
return self.send_body(500, "ASK ERROR\\n" + repr(e) + "\\n")
return self.send_body(404, "not found")
def main():
log(f"LLM Gateway fixed listening on 0.0.0.0:{PORT}")
log(f"espeak={ESPEAK}")
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
if __name__ == "__main__":
main()
PY
chmod +x "$SERVER"
log "8. start gateway"
tmux new-session -d -s stackchan_gateway "python '$SERVER'"
sleep 2
log "9. status"
curl -s --max-time 5 "http://127.0.0.1:$PORT/status" || true
echo
log "10. local ask test"
curl -sS --max-time 120 -X POST "http://127.0.0.1:$PORT/ask" \
-d "text=自己紹介して" || true
echo
log "11. android ip"
ANDROID_IP="$(
python - <<'PY'
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("192.168.0.1", 80))
print(s.getsockname()[0])
finally:
s.close()
PY
)"
echo "Android IP: $ANDROID_IP"
echo "Gateway URL: http://$ANDROID_IP:$PORT/"
log "done"
echo
echo "Windows CMD:"
echo " chcp 65001"
echo " curl http://$ANDROID_IP:$PORT/status"
echo " curl -X POST http://$ANDROID_IP:$PORT/ask --data-urlencode \"text=自己紹介して\""
echo
echo "StackChan Setup:"
echo " TTS Engine Type : simple_wav"
echo " TTS Host : $ANDROID_IP"
echo " TTS Port : $PORT"
echo " Text to speak : __REASK_LAST__"
EOF
chmod +x ~/stackchan_android_llm_start.sh実行:
~/stackchan_android_llm_start.shStackChan IP が違う場合:
STACKCHAN_HOST=192.168.0.190 ~/stackchan_android_llm_start.sh8. Windows からの使い方
8.1 文字コード
Windows CMD では最初に UTF-8 にします。
chcp 65001PowerShell でも CMD でも、基本は --data-urlencode を使います。
8.2 Android Gateway 状態確認
curl http://192.168.0.106:50021/statusjq は不要です。
Windows に jq が入っていない場合、以下のようなエラーになります。
'jq' is not recognized as an internal or external commandこれは無視して構いません。
8.3 LLM に質問して StackChan に喋らせる
これが本命です。
curl -X POST http://192.168.0.106:50021/ask --data-urlencode "text=自己紹介して"質問例:
curl -X POST http://192.168.0.106:50021/ask --data-urlencode "text=なぜ空は青いの?"curl -X POST http://192.168.0.106:50021/ask --data-urlencode "text=今日の気分を一言で教えて"8.4 TTS だけ試す
LLM を通さず、Android Gateway の TTS だけ確認します。
curl -X POST http://192.168.0.106:50021/synthesis --data-urlencode "text=今日は良い天気です" --output tts_test.wavtts_test.wav が生成されれば、Android 側 TTS は動いています。
8.5 StackChan に直接喋らせる
これは LLM を通りません。
curl -X POST http://192.168.0.190/api/speak --data-urlencode "text=今日は良い天気です"このコマンドは以下の意味です。
StackChan に直接「この文字列を喋れ」と命令するLLM に考えさせたい場合は、必ず Android Gateway の /ask に送ります。
curl -X POST http://192.168.0.106:50021/ask --data-urlencode "text=自己紹介して"9. Android 側でよく使う確認コマンド
9.1 Gateway 状態
curl http://127.0.0.1:50021/status9.2 LLM 状態
curl http://127.0.0.1:8080/v1/models9.3 Gateway の待受確認
ss -ltnp | grep 50021OK:
0.0.0.0:50021NG:
127.0.0.1:50021127.0.0.1 だけで待っている場合、Windows や StackChan からアクセスできません。
9.4 tmux セッション確認
tmux ls期待例:
stackchan_gateway
stackchan_llamaGateway ログを見る:
tmux attach -t stackchan_gateway抜ける:
Ctrl+b → dLLM ログを見る:
tmux attach -t stackchan_llama9.5 Android IP 確認
python - <<'PY'
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("192.168.0.1", 80))
print(s.getsockname()[0])
finally:
s.close()
PY例:
192.168.0.106Windows や StackChan からは、この IP を使います。
10. 保存先
Android 側の保存先:
~/stackchan_gateway/中身:
current.wav
current.json
last_question.txt
last_answer.txt
tts_last.txt
history/
logs/current.wav
最後に生成した音声です。
tts_last.txt
最後に TTS へ渡した内容を確認できます。
例:
ORIG:
こんにちは!私はスタックチャン、手のひらサイズのコミュニケーションロボットです。
KANA:
こんにちは!わたしはすたっくちゃん、てのひらさいずのこみゅにけーしょんろぼっとです。文字間スペース問題や漢字変換の確認に使います。
11. トラブルシューティング
11.1 Windows から 127.0.0.1:50021 に繋がらない
正常です。
Windows で 127.0.0.1 と書くと、Windows 自身を見に行きます。
Android ではありません。
NG:
curl http://127.0.0.1:50021/statusOK:
curl http://192.168.0.106:50021/status11.2 AndroidのIP とそのまま打ってしまった
これは説明用の仮名です。実際の IP に置き換えます。
NG:
curl http://AndroidのIP:50021/statusOK:
curl http://192.168.0.106:50021/status11.3 jq が無い
Windows では jq が無いことがあります。
NGになる例:
curl -s http://192.168.0.106:50021/status | jq .jq なしで見れば OK です。
curl http://192.168.0.106:50021/status11.4 /api/speak は OK なのに LLM を通っていない
以下は LLM を通りません。
curl -X POST http://192.168.0.190/api/speak --data-urlencode "text=自己紹介して"これは StackChan に直接喋らせているだけです。
LLM を通すには Android Gateway に送ります。
curl -X POST http://192.168.0.106:50021/ask --data-urlencode "text=自己紹介して"11.5 current とアルファベット読みしてしまう
原因:
StackChan に __CURRENT__ や CURRENT が文字列として渡っている対策:
/ask の後は __CURRENT__ ではなく回答本文を StackChan に送る
Gateway は fixed 版を使う
StackChan の Text to speak は __REASK_LAST__ にする確認:
cat ~/stackchan_gateway/tts_last.txtORIG: に current しか出ていない場合、まだ古い Gateway が動いている可能性があります。
11.6 文字が1文字ずつ空白になる
症状:
"question": "自 己 紹 介 し て",
"answer": "こ ん に ち は ! 私 は ..."対策:
Gateway 側で日本語文字間スペースを除去します。
実装上の処理:
こ ん に ち は
↓
こんにちは確認:
cat ~/stackchan_gateway/last_answer.txt
cat ~/stackchan_gateway/tts_last.txt11.7 漢字が読めない
espeak は日本語漢字を直接読むのが苦手です。
Gateway 側で pykakasi により、漢字混じり日本語をかな寄りにしてから espeak に渡します。
確認:
cat ~/stackchan_gateway/tts_last.txt期待:
ORIG:
今日は良い天気です。
KANA:
きょうはよいてんきです。11.8 TTSエラーになる
まず Android 側で確認します。
curl -X POST http://127.0.0.1:50021/synthesis \
--data-urlencode "text=今日は良い天気です" \
--output tts_test.wav
ls -lh tts_test.wav
head -c 12 tts_test.wav | od -An -t x1正常なら先頭が以下になります。
52 49 46 46 ... 57 41 56 45これは RIFF ... WAVE です。
espeak 確認:
command -v espeak
command -v espeak-ng
ls -l $PREFIX/bin/espeak*11.9 FileNotFoundError: espeak
espeak 実体が見つかっていません。
確認:
ls -l $PREFIX/bin/espeak*対処:
pkg install -y espeakもし開発中に espeak-ng.real.bak が残っている場合:
cp $PREFIX/bin/espeak-ng.real.bak $PREFIX/bin/espeak-ng
chmod +x $PREFIX/bin/espeak-ng
ln -sf $PREFIX/bin/espeak-ng $PREFIX/bin/espeak11.10 failed to load model
Gateway/llama-server がモデルを見つけていません。
確認:
ls -lh ~/models/model.gguf修正例:
mkdir -p ~/models
ln -sf ~/stackchan-qwen/models/qwen2.5-1.5b-instruct-q4_k_m.gguf \
~/models/model.ggufggml-vocab-*.gguf は使わないでください。
11.11 ping は通るがブラウザで開けない
Gateway が 127.0.0.1 のみで待ち受けている可能性があります。
確認:
ss -ltnp | grep 50021OK:
0.0.0.0:50021NG:
127.0.0.1:50021起動し直し:
tmux kill-session -t stackchan_gateway 2>/dev/null || true
~/stackchan_android_llm_start.sh11.12 旧Gatewayが残っている
旧Gateway の例:
{
"name": "stackchan-qwen-nopip-gateway"
}停止:
tmux kill-session -t stackchan_gateway 2>/dev/null || true
tmux kill-session -t stackchan_llama 2>/dev/null || true
pkill -f "uvicorn.*50021" 2>/dev/null || true
pkill -f "stackchan_qwen" 2>/dev/null || true
pkill -f "qwen-nopip" 2>/dev/null || true50021 番を掴むプロセス確認:
lsof -i TCP:5002112. 音声品質について
現在の標準 TTS は espeak / espeak-ng です。
メリット:
軽い
Termuxで動かしやすい
WAV出力が簡単
オフラインで動作デメリット:
日本語の自然さは低い
漢字読みは pykakasi に依存
声質はかなり機械的
ずんだもん声ではない自然な日本語音声やずんだもん声にしたい場合は、以下の構成が推奨です。
PC / Mac / Linux で VOICEVOX Engine を起動
↓
Android Gateway が VOICEVOX API を呼ぶ
↓
StackChan は simple_wav のままTermux 単体で VOICEVOX/ずんだもんを安定運用するのは、依存関係が重くなりやすいため非推奨です。
13. 推奨運用
通常起動
Android Termux:
~/stackchan_android_llm_start.shWindows CMD:
chcp 65001
curl -X POST http://192.168.0.106:50021/ask --data-urlencode "text=自己紹介して"StackChan の Aボタン
StackChan 側:
Text to speak: __REASK_LAST__これにより、最後の質問をもう一度 LLM へ投げて喋ります。
14. リリース前チェックリスト
StackChan
[ ] Wi-Fi 接続済み
[ ] IP を確認済み
[ ] TTS Engine Type が simple_wav
[ ] TTS Host が Android IP
[ ] TTS Port が 50021
[ ] Text to speak が __REASK_LAST__
[ ] /api/status が取得できる
[ ] /api/speak が {"ok":true} を返すAndroid Termux
[ ] python が入っている
[ ] tmux が入っている
[ ] espeak または espeak-ng がある
[ ] pykakasi が入っている
[ ] GGUFモデルがある
[ ] ~/models/model.gguf が実モデルを指している
[ ] llama-server がある
[ ] Gateway が 0.0.0.0:50021 で待ち受けている
[ ] /status が返る
[ ] /ask が返る
[ ] current.wav が生成されるWindows / PC
[ ] Android IP に ping が通る
[ ] http://Android_IP:50021/status が開ける
[ ] curl で /ask に質問できる
[ ] StackChan が喋る15. 最短動作確認コマンド集
Android Termux
~/stackchan_android_llm_start.shcurl http://127.0.0.1:50021/statuscurl -X POST http://127.0.0.1:50021/ask --data-urlencode "text=自己紹介して"Windows CMD
chcp 65001curl http://192.168.0.106:50021/statuscurl -X POST http://192.168.0.106:50021/ask --data-urlencode "text=自己紹介して"StackChan 直接発話
curl -X POST http://192.168.0.190/api/speak --data-urlencode "text=今日は良い天気です"16. 重要な注意
/api/speak は LLM を通らない
StackChan /api/speak
=
喋れ命令LLM を使う場合:
Android Gateway /ask
=
考えてから喋れ命令Windows の 127.0.0.1 は Android ではない
Windows から Android Gateway を叩く場合は、必ず Android の LAN IP を使います。
http://192.168.0.106:50021/__REASK_LAST__ はアンダースコア2本ずつ
正:
__REASK_LAST__誤:
REASK_LAST
_REASK_LAST_
CURRENT17. 現在の到達点
この構成で確認済みのこと:
Windows から Android Gateway /ask に質問できる
Android Gateway が LLM を呼べる
LLM回答の文字間スペースを除去できる
漢字混じり日本語を pykakasi で読み上げ用に変換できる
espeak / espeak-ng で WAV を生成できる
StackChan /api/speak が {"ok":true} を返す
StackChan が simple_wav で Gateway から WAV を取得して喋る動作確認済み curl 例:
chcp 65001
curl -X POST http://192.168.0.106:50021/ask --data-urlencode "text=自己紹介して"期待レスポンス例:
{
"ok": true,
"question": "自己紹介して",
"answer": "こんにちは!私はスタックチャン、手のひらサイズのコミュニケーションロボットです。何かお手伝いできますか?",
"stackchan": "{\"ok\":true}"
}