255 lines
10 KiB
Python
255 lines
10 KiB
Python
"""Local Teddy audio follower (Chatterbox or Piper). Reads simulated state; never changes game state."""
|
|
import argparse
|
|
import hashlib
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import urllib.request
|
|
import wave
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
DATA = ROOT / ".speech"
|
|
VOICE = "en_US-ljspeech-medium"
|
|
MODEL = DATA / "voices" / (VOICE + ".onnx")
|
|
AUDIO = {}
|
|
OUTPUT = "local"
|
|
STATUS = {"status": "starting", "voice": VOICE, "message": "Loading speech follower"}
|
|
|
|
def python_path():
|
|
return DATA / "venv" / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
|
|
|
|
def setup():
|
|
import venv
|
|
DATA.mkdir(exist_ok=True)
|
|
if not python_path().exists():
|
|
venv.EnvBuilder(with_pip=True).create(DATA / "venv")
|
|
subprocess.run([str(python_path()), "-m", "pip", "install", "piper-tts==1.8.0"], check=True)
|
|
MODEL.parent.mkdir(exist_ok=True)
|
|
base = "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/ljspeech/medium/"
|
|
for name in (MODEL.name, MODEL.name + ".json", "MODEL_CARD"):
|
|
target = MODEL.parent / name
|
|
if not target.exists():
|
|
temp = target.with_suffix(target.suffix + ".download")
|
|
print("Downloading " + name, flush=True)
|
|
urllib.request.urlretrieve(base + name, temp)
|
|
temp.replace(target)
|
|
print("Piper ready. Run: py -3 lab.py start-room --speech")
|
|
|
|
def synth(text_file, output):
|
|
from piper import PiperVoice
|
|
voice = PiperVoice.load(str(MODEL))
|
|
temp = Path(output).with_suffix(".partial")
|
|
with wave.open(str(temp), "wb") as stream:
|
|
voice.synthesize_wav(Path(text_file).read_text(encoding="utf-8"), stream)
|
|
temp.replace(output)
|
|
|
|
class BrowserPlayer:
|
|
def stop(self): pass
|
|
def play(self, path): pass
|
|
|
|
class Player:
|
|
def __init__(self):
|
|
self.process = None
|
|
if os.name != "nt":
|
|
self.command = shutil.which("afplay" if sys.platform == "darwin" else "aplay")
|
|
if not self.command:
|
|
raise RuntimeError("Install aplay (alsa-utils) for Linux audio playback")
|
|
def stop(self):
|
|
if os.name == "nt":
|
|
import winsound
|
|
winsound.PlaySound(None, 0)
|
|
elif self.process and self.process.poll() is None:
|
|
self.process.terminate()
|
|
self.process.wait(timeout=3)
|
|
self.process = None
|
|
def play(self, path):
|
|
self.stop()
|
|
if os.name == "nt":
|
|
import winsound
|
|
winsound.PlaySound(str(path), winsound.SND_FILENAME | winsound.SND_ASYNC | winsound.SND_NODEFAULT)
|
|
else:
|
|
self.process = subprocess.Popen([self.command, str(path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
|
|
def cue_for(state):
|
|
teddy = state.get("teddy", {})
|
|
cue = teddy.get("current")
|
|
if not cue or teddy.get("muted") or (teddy.get("takeover") and not cue.get("key", "").startswith("gm-")) or not teddy.get("online"):
|
|
return None
|
|
return (state["run_id"], cue["id"], cue.get("started"), cue["transcript"])
|
|
|
|
class Follower:
|
|
def __init__(self, player, engine=None):
|
|
self.engine = engine
|
|
self.player = player
|
|
self.current = None
|
|
self.job = None
|
|
self.output = None
|
|
self.end = None
|
|
self.failed = False
|
|
(DATA / "cache").mkdir(parents=True, exist_ok=True)
|
|
def status(self, value, message):
|
|
global STATUS
|
|
STATUS = {"status": value, "voice": VOICE, "message": message}
|
|
def cancel(self):
|
|
global AUDIO
|
|
AUDIO = {}
|
|
if self.job and self.job.poll() is None:
|
|
self.job.terminate()
|
|
self.job.wait(timeout=5)
|
|
self.job = None
|
|
self.end = None
|
|
self.player.stop()
|
|
def update(self, state):
|
|
cue = cue_for(state) if state else None
|
|
if cue != self.current:
|
|
self.cancel()
|
|
self.current = cue
|
|
self.failed = False
|
|
if cue:
|
|
digest = hashlib.sha256((VOICE + "\n" + cue[3]).encode()).hexdigest()
|
|
self.output = DATA / "cache" / (digest + ".wav")
|
|
if self.output.exists():
|
|
self.play()
|
|
else:
|
|
text_file = self.output.with_suffix(".txt")
|
|
text_file.write_text(cue[3], encoding="utf-8")
|
|
self.job = self.engine.submit(cue[3], self.output) if self.engine else subprocess.Popen([str(python_path()), str(Path(__file__).resolve()),
|
|
"synth", "--text-file", str(text_file), "--output", str(self.output)],
|
|
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
|
|
self.status("generating", "Generating Teddy's current cue")
|
|
if self.job and self.job.poll() is not None:
|
|
result = self.job.returncode
|
|
self.job = None
|
|
if result == 0:
|
|
self.play()
|
|
else:
|
|
self.failed = True
|
|
self.status("error", "Speech synthesis failed; captions remain available. Replay to retry.")
|
|
if self.end is not None and time.monotonic() >= self.end:
|
|
self.end = None
|
|
self.status("finished", "Cue finished. Advance virtual time to release the next queued cue.")
|
|
if not cue:
|
|
self.status("idle" if state else "disconnected",
|
|
"Waiting for a cue" if state else "Room connection lost; audio stopped")
|
|
def play(self):
|
|
try:
|
|
with wave.open(str(self.output)) as audio:
|
|
duration = audio.getnframes() / audio.getframerate()
|
|
self.player.play(self.output)
|
|
global AUDIO
|
|
identity = hashlib.sha256(repr(self.current[:3]).encode()).hexdigest()
|
|
AUDIO = {"path":str(self.output), "audio_url":"/audio/" + identity + ".wav",
|
|
"cue_id":self.current[1], "run_id":self.current[0]}
|
|
self.end = None if OUTPUT == "browser" else time.monotonic() + duration
|
|
self.status("ready" if OUTPUT == "browser" else "playing",
|
|
"Audio ready for enabled browsers" if OUTPUT == "browser" else "Playing on the lab computer's default audio output")
|
|
except Exception as error:
|
|
self.failed = True
|
|
self.status("error", "Audio playback failed: " + str(error))
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
origin = self.headers.get("Origin", "")
|
|
allowed = ("http://127.0.0.1:18777", "http://localhost:18777")
|
|
if origin and origin not in allowed:
|
|
self.send_error(403)
|
|
return
|
|
audio = AUDIO.copy()
|
|
status = 200
|
|
content_range = None
|
|
if self.path == "/health":
|
|
data = json.dumps({**STATUS, "output":OUTPUT,
|
|
**{k:v for k,v in audio.items() if k != "path"}}).encode()
|
|
content_type = "application/json"
|
|
elif audio and self.path == audio.get("audio_url"):
|
|
data = Path(audio["path"]).read_bytes()
|
|
content_type = "audio/wav"
|
|
byte_range = self.headers.get("Range")
|
|
if byte_range:
|
|
import re
|
|
match = re.fullmatch(r"bytes=(\d+)-(\d*)", byte_range)
|
|
start = int(match[1]) if match else len(data)
|
|
end = min(int(match[2]) if match and match[2] else len(data)-1,len(data)-1)
|
|
if start > end:
|
|
self.send_error(416)
|
|
return
|
|
content_range = f"bytes {start}-{end}/{len(data)}"
|
|
data = data[start:end+1]
|
|
status = 206
|
|
else:
|
|
self.send_error(404)
|
|
return
|
|
self.send_response(status)
|
|
if origin:
|
|
self.send_header("Access-Control-Allow-Origin", origin)
|
|
self.send_header("Content-Type", content_type)
|
|
self.send_header("Accept-Ranges", "bytes")
|
|
if content_range:
|
|
self.send_header("Content-Range", content_range)
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.send_header("Content-Length", str(len(data)))
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
def log_message(self, *_):
|
|
pass
|
|
|
|
def follow(room_port=18776, status_port=18778, output="local", backend="chatterbox"):
|
|
global OUTPUT, VOICE
|
|
OUTPUT = output
|
|
engine = None
|
|
if backend == "chatterbox":
|
|
from chatterbox_client import Engine, voice_id
|
|
VOICE = voice_id()
|
|
engine = Engine()
|
|
follower = Follower(BrowserPlayer() if output == "browser" else Player(), engine)
|
|
server = ThreadingHTTPServer(("127.0.0.1", status_port), Handler)
|
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
follower.status("starting" if engine else "idle", "Loading Teddy voice" if engine else "Waiting for a cue")
|
|
print(f"TEDDY AUDIO: {backend}, {output} output, status port {status_port}", flush=True)
|
|
try:
|
|
while True:
|
|
if engine and engine.error:
|
|
follower.cancel()
|
|
follower.status("error", "Chatterbox unavailable; see .speech/chatterbox.log. Restart speech to retry.")
|
|
time.sleep(1)
|
|
continue
|
|
if engine and not engine.ready.is_set():
|
|
time.sleep(.2)
|
|
continue
|
|
try:
|
|
with urllib.request.urlopen(f"http://127.0.0.1:{room_port}/room/state", timeout=1) as response:
|
|
state = json.load(response)["state"]
|
|
except Exception:
|
|
state = None
|
|
follower.update(state)
|
|
time.sleep(0.2)
|
|
finally:
|
|
follower.cancel()
|
|
if engine:
|
|
engine.close()
|
|
server.shutdown()
|
|
server.server_close()
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("mode", choices=("setup", "follow", "synth"))
|
|
parser.add_argument("--backend", choices=("chatterbox", "piper"), default="chatterbox")
|
|
parser.add_argument("--text-file")
|
|
parser.add_argument("--output")
|
|
parser.add_argument("--room-port", type=int, default=18776)
|
|
parser.add_argument("--status-port", type=int, default=18778)
|
|
parser.add_argument("--output-device", choices=("local","browser"), default="local")
|
|
args = parser.parse_args()
|
|
if args.mode == "setup":
|
|
setup()
|
|
elif args.mode == "synth":
|
|
synth(args.text_file, args.output)
|
|
else:
|
|
follow(args.room_port, args.status_port, args.output_device, args.backend)
|