Added chatterbox connections

This commit is contained in:
Jeff Luke committed 2026-10-18 10:54:38 -07:00
1 parent 41ef12f954
commit e58ceeb192
58 files changed
+8931 -8

No files matched your search

+235
View File
@@ -0,0 +1,235 @@
"""Local-only Glitch lab orchestration; never contacts a Pi or Odin."""
import argparse
import json
import os
from pathlib import Path
import shutil
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.request
import uuid
ROOT = Path(__file__).resolve().parent
BASE = "http://127.0.0.1:18766"
TOOLS = ("cargo", "flutter", "nats-server")
PORTS = (14222, 18766, 18767)
def preflight():
missing = []
for name in TOOLS:
path = shutil.which(name)
print(f"{name}: {path or 'MISSING - install and add to PATH'}")
if not path:
missing.append(name)
if missing:
print("See README.md prerequisites. Nothing was started.")
return False
return True
def run_checked(args, cwd=ROOT):
# Flutter ships as a batch file on Windows; cmd is used solely for its fixed CLI.
if os.name == "nt" and str(args[0]).lower().endswith((".bat", ".cmd")):
args = ["cmd.exe", "/d", "/c", *args]
subprocess.run(args, cwd=cwd, check=True)
def api(path, body=None, base=BASE):
data = None if body is None else json.dumps(body).encode()
request = urllib.request.Request(base + path, data=data,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(request, timeout=5) as response:
return json.load(response)
def command(action, session, command_id=None):
return {"id": command_id or str(uuid.uuid4()), "session": session,
"expires_ms": int(time.time() * 1000) + 10000, "action": action}
def smoke():
state = api("/state")["state"]
assert state["simulated"] is True, "Refusing to test a real cabinet"
session = state["session"]
def send(action):
return api("/command", command(action, session))
assert send("reset")["accepted"]
assert not send("start")["accepted"], "Start must require power and credit"
power = command("power_on", session)
assert api("/command", power)["state"]["phase"] == "booting"
assert api("/command", power)["duplicate"], "Duplicate command must be acknowledged"
assert not send("scan")["accepted"], "Boot-period scans must not grant credit"
deadline = time.monotonic() + 6
while time.monotonic() < deadline:
state = api("/state")["state"]
if state["phase"] == "ready":
break
time.sleep(0.1)
assert state["phase"] == "ready" and state["credits"] == 0
assert not send("start")["accepted"]
scan = command("scan", session)
assert api("/command", scan)["state"]["credits"] == 1
replay = api("/command", scan)
assert replay["duplicate"] and replay["state"]["scans"] == 1
assert send("start")["state"]["phase"] == "playing"
assert send("win")["state"]["phase"] == "won"
assert not api("/command", command("reset", "stale-session"))["accepted"]
expired = command("reset", session)
expired["expires_ms"] = 1
assert not api("/command", expired)["accepted"]
assert send("reset")["state"]["phase"] == "powered_off"
print("PASS: real HTTP -> Rust -> NATS -> MessagePack -> simulated cabinet flow")
def build():
run_checked([shutil.which("cargo"), "test"])
run_checked([shutil.which("cargo"), "build"])
flutter = shutil.which("flutter")
run_checked([flutter, "pub", "get"], ROOT / "gm")
run_checked([flutter, "analyze"], ROOT / "gm")
run_checked([flutter, "test"], ROOT / "gm")
run_checked([flutter, "build", "web", "--release"], ROOT / "gm")
def start(godot=False, room=False, speech=False, lan=False, speech_output="browser", speech_backend="chatterbox"):
if speech:
import teddy_speech
if not room:
raise RuntimeError("--speech requires start-room")
if speech_backend == "chatterbox":
from chatterbox_client import REFERENCE
if not REFERENCE.exists():
raise RuntimeError("Missing Teddy Chatterbox reference; see TEDDY_TRAINING.md")
if speech_backend == "piper" and (not teddy_speech.python_path().exists() or not teddy_speech.MODEL.exists()):
raise RuntimeError("Install the lab voice first: py -3 lab.py setup-speech")
# Fail before launching anything if another process owns a lab port.
ports = (14232, 18776, 18777) if room else PORTS
check_ports = (*ports, 18768) if godot else ports
if speech:
check_ports = (*check_ports, 18778)
for port in check_ports:
with socket.socket() as sock:
sock.bind(("127.0.0.1", port))
binary = ROOT / "target" / "debug" / ("glitch-odin-lab.exe" if os.name == "nt" else "glitch-odin-lab")
if not binary.exists() or not (ROOT / "gm/build/web/index.html").exists():
raise RuntimeError("Build the lab first: python lab.py build")
processes = []
def spawn(args):
flags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
process = subprocess.Popen(args, cwd=ROOT, creationflags=flags)
processes.append(process)
return process
try:
broker = spawn([shutil.which("nats-server"), "-c", str(ROOT / ("room-nats.conf" if room else "nats.conf"))])
deadline = time.monotonic() + 5
while True:
if broker.poll() is not None:
raise RuntimeError("NATS exited; see its output above")
try:
with socket.create_connection(("127.0.0.1", ports[0]), timeout=0.2):
break
except OSError:
if time.monotonic() >= deadline:
raise RuntimeError("NATS did not become ready")
time.sleep(0.1)
if room:
spawn([str(binary), "room"])
elif godot:
executable = ROOT.parents[1] / "Godot_v4.7.2-stable_win64_console.exe"
godot_path = os.environ.get("GLITCH_LAB_GODOT") or str(executable)
if not Path(godot_path).is_file():
raise RuntimeError("Set GLITCH_LAB_GODOT to the Godot executable path")
spawn([godot_path, "--path", str(ROOT.parents[1]), "--windowed",
"res://tests/odin_lab/cabinet.tscn", "--", "--glitch-lab", "--lab-inject-rfid"])
spawn([str(binary), "godot"])
else:
spawn([str(binary), "cabinet"])
if not room:
spawn([str(binary), "controller"])
if room:
spawn([sys.executable, str(ROOT / "room_web.py"), "--bind", "0.0.0.0" if lan else "127.0.0.1"])
else:
spawn([sys.executable, "-m", "http.server", str(ports[2]), "--bind", "127.0.0.1",
"--directory", str(ROOT / "gm/build/web")])
deadline = time.monotonic() + 10
while True:
if any(p.poll() is not None for p in processes):
raise RuntimeError("A lab process exited during startup")
try:
api("/room/state" if room else "/state", base=f"http://127.0.0.1:{ports[1]}")
break
except (OSError, ValueError):
if time.monotonic() >= deadline:
raise RuntimeError("Controller/cabinet did not become ready")
time.sleep(0.2)
if speech:
spawn([sys.executable, str(ROOT / "teddy_speech.py"), "follow", "--backend", speech_backend, "--output-device", speech_output])
deadline = time.monotonic() + 180
while True:
if any(p.poll() is not None for p in processes):
raise RuntimeError("Teddy speech failed to start")
try:
health = api("/health", base="http://127.0.0.1:18778")
if health.get("status") == "error":
raise RuntimeError(health.get("message", "Teddy failed to load"))
if health.get("status") != "starting":
break
if time.monotonic() >= deadline:
raise RuntimeError("Teddy model loading timed out; see .speech/chatterbox.log")
time.sleep(.2)
except (OSError, ValueError):
if time.monotonic() >= deadline:
raise RuntimeError("Teddy speech did not become ready")
time.sleep(0.2)
url = f"http://127.0.0.1:{ports[2]}/" + ("?room=1" if room else "")
print(f"READY: {url} - press Ctrl+C to stop all lab processes", flush=True)
if lan:
print("LAN DEMO: open http://<this-PC-Wi-Fi-IPv4>:18777/?room=1 on your phone. Shared simulation controls; enable browser audio on each listening device.", flush=True)
while True:
if any(p.poll() is not None for p in processes):
raise RuntimeError("A lab process exited; stopping this lab")
time.sleep(0.5)
finally:
for process in reversed(processes):
if process.poll() is None:
process.terminate()
for process in processes:
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("mode", choices=("preflight", "build", "start", "start-godot", "start-room", "setup-speech", "smoke"))
parser.add_argument("--speech", action="store_true", help="Play Teddy cues using Chatterbox (default) or Piper")
parser.add_argument("--lan", action="store_true", help="Expose only the room demo webpage/gateway to the LAN")
parser.add_argument("--speech-backend", choices=("chatterbox", "piper"), default="chatterbox")
parser.add_argument("--speech-output", choices=("browser","local"), default="browser")
args = parser.parse_args()
mode = args.mode
if args.lan and mode != "start-room":
parser.error("--lan is only supported with start-room")
if args.speech and mode != "start-room":
parser.error("--speech is only supported with start-room")
if mode == "setup-speech":
import teddy_speech
teddy_speech.setup()
return 0
if mode == "smoke":
smoke()
return 0
if not preflight():
return 1
if mode == "build":
build()
elif mode in ("start", "start-godot", "start-room"):
start(godot=mode == "start-godot", room=mode == "start-room", speech=args.speech, lan=args.lan, speech_output=args.speech_output, speech_backend=args.speech_backend)
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\nLab stopped.")
except Exception as error:
print(f"ERROR: {error}", file=sys.stderr)
sys.exit(1)