Skip to content

StackChan × Android Termux Local LLM Gateway

Choose a tag to compare

@Xenoah Xenoah released this 28 Jun 01:52
· 2 commits to main since this release

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           : 8080

Android 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.gguf

Gateway が標準で探すモデルリンク:

~/models/model.gguf

4. 仕様

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.wav

GET /current.wav

最後に生成した WAV を返します。

curl http://192.168.0.106:50021/current.wav --output current.wav

4.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
__CURRENT

5.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 cmake

TTS 用に 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.0

pykakasi は、漢字混じり日本語を読み上げ用のかな寄りテキストへ変換するために使います。

例:

今日は良い天気です
↓
きょうはよいてんきです

6.3 モデル配置

推奨モデル例:

qwen2.5-1.5b-instruct-q4_k_m.gguf

配置例:

~/stackchan-qwen/models/qwen2.5-1.5b-instruct-q4_k_m.gguf

Gateway/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.gguf

ggml-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/models

llama-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.sh

StackChan IP が違う場合:

STACKCHAN_HOST=192.168.0.190 ~/stackchan_android_llm_start.sh

8. Windows からの使い方

8.1 文字コード

Windows CMD では最初に UTF-8 にします。

chcp 65001

PowerShell でも CMD でも、基本は --data-urlencode を使います。


8.2 Android Gateway 状態確認

curl http://192.168.0.106:50021/status

jq は不要です。
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.wav

tts_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/status

9.2 LLM 状態

curl http://127.0.0.1:8080/v1/models

9.3 Gateway の待受確認

ss -ltnp | grep 50021

OK:

0.0.0.0:50021

NG:

127.0.0.1:50021

127.0.0.1 だけで待っている場合、Windows や StackChan からアクセスできません。


9.4 tmux セッション確認

tmux ls

期待例:

stackchan_gateway
stackchan_llama

Gateway ログを見る:

tmux attach -t stackchan_gateway

抜ける:

Ctrl+b → d

LLM ログを見る:

tmux attach -t stackchan_llama

9.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.106

Windows や 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/status

OK:

curl http://192.168.0.106:50021/status

11.2 AndroidのIP とそのまま打ってしまった

これは説明用の仮名です。実際の IP に置き換えます。

NG:

curl http://AndroidのIP:50021/status

OK:

curl http://192.168.0.106:50021/status

11.3 jq が無い

Windows では jq が無いことがあります。

NGになる例:

curl -s http://192.168.0.106:50021/status | jq .

jq なしで見れば OK です。

curl http://192.168.0.106:50021/status

11.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.txt

ORIG: に current しか出ていない場合、まだ古い Gateway が動いている可能性があります。


11.6 文字が1文字ずつ空白になる

症状:

"question": "自 己 紹 介 し て",
"answer": "こ ん に ち は ! 私 は ..."

対策:

Gateway 側で日本語文字間スペースを除去します。

実装上の処理:

こ ん に ち は
↓
こんにちは

確認:

cat ~/stackchan_gateway/last_answer.txt
cat ~/stackchan_gateway/tts_last.txt

11.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/espeak

11.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.gguf

ggml-vocab-*.gguf は使わないでください。


11.11 ping は通るがブラウザで開けない

Gateway が 127.0.0.1 のみで待ち受けている可能性があります。

確認:

ss -ltnp | grep 50021

OK:

0.0.0.0:50021

NG:

127.0.0.1:50021

起動し直し:

tmux kill-session -t stackchan_gateway 2>/dev/null || true
~/stackchan_android_llm_start.sh

11.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 || true

50021 番を掴むプロセス確認:

lsof -i TCP:50021

12. 音声品質について

現在の標準 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.sh

Windows 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.sh
curl http://127.0.0.1:50021/status
curl -X POST http://127.0.0.1:50021/ask --data-urlencode "text=自己紹介して"

Windows CMD

chcp 65001
curl http://192.168.0.106:50021/status
curl -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_
CURRENT

17. 現在の到達点

この構成で確認済みのこと:

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}"
}