Skip to content

success

Choose a tag to compare

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

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()

    page = f"""<!doctype html>
<title>StackChan LLM Gateway</title>

StackChan LLM Gateway fixed

LLM: {'OK' if llm_ok else 'NG'}

<textarea name="text" rows="5" style="width:100%;">自己紹介して</textarea>
LLMで回答して喋る

status / current.wav

最後の質問

{html.escape(last_q)}

最後の回答

{html.escape(last_a)}

TTSログ

{html.escape(last_tts)}
""" 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)
            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}")
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()

if name == "main":
main()
PY

chmod +x "$SERVER"

echo
echo "=== 5. Gateway起動 ==="
tmux new-session -d -s stackchan_gateway "python '$SERVER'"

sleep 2

echo
echo "=== 6. status確認 ==="
curl -s "http://127.0.0.1:$PORT/status"

echo
echo
echo "=== 7. /ask テスト ==="
curl -sS --max-time 120 -X POST "http://127.0.0.1:$PORT/ask"
-d "text=自己紹介して"

echo
echo
echo "=== 8. 最後のTTSログ ==="
cat "$DATA/tts_last.txt" 2>/dev/null || true

echo
echo "=== 完了 ==="
echo "Windowsから:"
echo " curl -X POST http://192.168.0.106:50021/ask --data-urlencode "text=自己紹介して""
EOF

chmod +x ~/stackchan_fix_spacing_and_current.sh
~/stackchan_fix_spacing_and_current.sh

成功後、Windowsからもう一度これです。

chcp 65001
curl -X POST http://192.168.0.106:50021/ask --data-urlencode "text=自己紹介して"

期待するJSONはこうです。

{
"question": "自己紹介して",
"answer": "こんにちは!私はスタックチャン、手のひらサイズのコミュニケーションロボットです。"
}

ポイントは、もう CURRENT をStackChanに送らず、LLM回答本文そのものを送って喋らせる ところです。
current をアルファベット読みする事故を回避します。