Repository navigation
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()
page = f"""<!doctype html>
StackChan LLM Gateway fixed
LLM: {'OK' if llm_ok else 'NG'}
<textarea name="text" rows="5" style="width:100%;">自己紹介して</textarea>LLMで回答して喋る
最後の質問
{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 をアルファベット読みする事故を回避します。