Added chatterbox connections
This commit is contained in:
1 parent
41ef12f954
commit
e58ceeb192
58 files changed
+8931
-8
No files matched your search
@@ -0,0 +1,254 @@
|
||||
"""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)
|
||||
Reference in new issue
Block a user