Files
Codex Local 0c89e0c0f1 fix: MusicGen compose working (proper WAV write, VRAM management)
ACE-Step not importable as module, MusicGen fallback fixed
Auto unload/reload Ollama models for VRAM during generation
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-20 23:26:12 +01:00

357 lines
14 KiB
Python

#!/usr/bin/env python3
"""
KXKM TTS HTTP Server — Sidecar for Docker container.
Runs on host, provides HTTP API for text-to-speech synthesis.
Backends:
- Chatterbox (GPU, zero-shot voice cloning, high quality)
- Chatterbox-remote (proxy to Chatterbox Docker on :9200)
- Qwen3 (proxy to Qwen3-TTS server on :9300, voice design + cloning)
- Piper (CPU, fast, predefined voices, fallback)
Usage:
python3 scripts/tts-server.py [--port 9100] [--backend chatterbox|chatterbox-remote|qwen3|piper]
Endpoints:
POST /synthesize { text, voice, persona } → audio/wav
POST /compose { prompt, duration } → audio/wav
GET /health → { ok: true, backend: "..." }
"""
import argparse
import io
import json
import os
import sys
import urllib.request
import urllib.error
import wave
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
# Lazy-loaded backends
PiperVoice = None
ChatterboxModel = None
TTS_BACKEND = os.environ.get("TTS_BACKEND", "piper") # "chatterbox", "chatterbox-remote", "qwen3", or "piper"
CHATTERBOX_URL = os.environ.get("CHATTERBOX_URL", "http://127.0.0.1:9200")
QWEN3_TTS_URL = os.environ.get("QWEN3_TTS_URL", "http://127.0.0.1:9300")
VOICE_DIR = Path(os.environ.get("PIPER_VOICE_DIR", "data/piper-voices"))
SAMPLES_DIR = Path(os.environ.get("KXKM_VOICE_SAMPLES_DIR", "data/voice-samples"))
VOICE_MAP = {
"default": "fr_FR-siwis-medium",
"schaeffer": "fr_FR-siwis-medium",
"batty": "fr_FR-upmc-medium",
"radigue": "fr_FR-siwis-low",
"pharmacius": "fr_FR-gilles-low",
"moorcock": "en_GB-alan-medium",
}
def load_piper():
global PiperVoice
if PiperVoice is None:
from piper import PiperVoice as PV
PiperVoice = PV
return PiperVoice
def load_chatterbox():
global ChatterboxModel
if ChatterboxModel is None:
try:
from chatterbox.mtl_tts import ChatterboxMultilingualTTS
ChatterboxModel = ChatterboxMultilingualTTS.from_pretrained(device="cuda")
print("[tts-server] Chatterbox Multilingual loaded (GPU)", file=sys.stderr)
except Exception as e:
print(f"[tts-server] Chatterbox load failed: {e}, falling back to piper", file=sys.stderr)
return None
return ChatterboxModel
def synthesize_chatterbox(text: str, persona: str) -> bytes:
model = load_chatterbox()
if model is None:
raise RuntimeError("Chatterbox not available")
import torchaudio
# Use voice sample as reference if available
ref_path = SAMPLES_DIR / f"{persona.lower()}.wav"
if not ref_path.exists():
ref_path = SAMPLES_DIR / "pharmacius.wav" # fallback
wav = model.generate(text, audio_prompt_path=str(ref_path), language_id="fr")
buf = io.BytesIO()
torchaudio.save(buf, wav, model.sr, format="wav")
return buf.getvalue()
def synthesize_chatterbox_remote(text: str, persona: str) -> bytes:
"""Proxy TTS request to Chatterbox Docker server."""
payload = json.dumps({
"text": text,
"voice_mode": "predefined",
"predefined_voice_id": f"{persona.lower()}.wav",
"output_format": "wav",
}).encode("utf-8")
url = f"{CHATTERBOX_URL.rstrip('/')}/tts"
req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.read()
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError) as e:
raise RuntimeError(f"Chatterbox remote ({url}) failed: {e}")
def synthesize_qwen3(text: str, persona: str) -> bytes:
"""Proxy TTS request to Qwen3-TTS server (voice design + custom voice)."""
payload = json.dumps({
"text": text,
"persona": persona,
}).encode("utf-8")
url = f"{QWEN3_TTS_URL.rstrip('/')}/synthesize"
req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return resp.read()
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError) as e:
raise RuntimeError(f"Qwen3-TTS remote ({url}) failed: {e}")
def resolve_voice(persona: str) -> str:
return VOICE_MAP.get(persona.lower(), VOICE_MAP["default"])
def synthesize(text: str, voice_name: str, persona: str = "default") -> bytes:
if TTS_BACKEND == "qwen3":
try:
return synthesize_qwen3(text, persona)
except Exception as e:
print(f"[tts] Qwen3-TTS failed, falling back to piper: {e}", file=sys.stderr)
elif TTS_BACKEND == "chatterbox-remote":
try:
return synthesize_chatterbox_remote(text, persona)
except Exception as e:
print(f"[tts] Chatterbox remote failed, falling back to piper: {e}", file=sys.stderr)
elif TTS_BACKEND == "chatterbox":
try:
return synthesize_chatterbox(text, persona)
except Exception as e:
print(f"[tts] Chatterbox failed, falling back to piper: {e}", file=sys.stderr)
return synthesize_piper(text, voice_name)
def synthesize_piper(text: str, voice_name: str) -> bytes:
PV = load_piper()
from piper.download_voices import download_voice
VOICE_DIR.mkdir(parents=True, exist_ok=True)
model_path = VOICE_DIR / f"{voice_name}.onnx"
config_path = VOICE_DIR / f"{voice_name}.onnx.json"
if not model_path.exists() or not config_path.exists():
download_voice(voice_name, VOICE_DIR)
voice = PV.load(model_path, config_path=config_path, download_dir=VOICE_DIR)
chunks = list(voice.synthesize(text))
if not chunks:
raise RuntimeError("No audio output")
buf = io.BytesIO()
with wave.open(buf, "wb") as wf:
wf.setframerate(chunks[0].sample_rate)
wf.setsampwidth(chunks[0].sample_width)
wf.setnchannels(chunks[0].sample_channels)
for chunk in chunks:
wf.writeframes(chunk.audio_int16_bytes)
return buf.getvalue()
class TTSHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/health":
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"ok": True, "backend": TTS_BACKEND}).encode())
else:
self.send_error(404)
def do_POST(self):
if self.path not in ("/synthesize", "/compose"):
self.send_error(404)
return
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length)) if length else {}
if self.path == "/compose":
self._handle_compose(body)
return
text = body.get("text", "")
persona = body.get("persona", "default")
voice_name = body.get("voice") or resolve_voice(persona)
if not text:
self.send_error(400, "Missing text")
return
try:
audio = synthesize(text, voice_name, persona)
self.send_response(200)
self.send_header("Content-Type", "audio/wav")
self.send_header("Content-Length", str(len(audio)))
self.end_headers()
self.wfile.write(audio)
print(f"[tts] {persona}/{voice_name}: {len(text)} chars → {len(audio)} bytes", file=sys.stderr)
except Exception as e:
self.send_response(500)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"error": str(e)}).encode())
print(f"[tts] ERROR: {e}", file=sys.stderr)
def _free_vram_for_generation(self):
"""Unload Ollama models to free VRAM for ACE-Step/ComfyUI."""
import urllib.request, json
try:
for model in ["qwen3.5:9b", "mistral:7b", "nomic-embed-text"]:
req = urllib.request.Request(
"http://localhost:11434/api/generate",
data=json.dumps({"model": model, "keep_alive": 0}).encode(),
headers={"Content-Type": "application/json"},
)
urllib.request.urlopen(req, timeout=5)
print("[compose] VRAM freed: Ollama models unloaded", file=sys.stderr)
import time; time.sleep(2)
except Exception as e:
print(f"[compose] VRAM free warning: {e}", file=sys.stderr)
def _reload_ollama_models(self):
"""Reload Ollama models after generation."""
import urllib.request, json
try:
for model in ["qwen3.5:9b", "nomic-embed-text"]:
req = urllib.request.Request(
"http://localhost:11434/api/chat",
data=json.dumps({"model": model, "messages": [{"role": "user", "content": "."}], "stream": False, "options": {"num_predict": 1}, "keep_alive": "30m", "think": False}).encode(),
headers={"Content-Type": "application/json"},
)
urllib.request.urlopen(req, timeout=60)
print("[compose] Ollama models reloaded", file=sys.stderr)
except Exception as e:
print(f"[compose] Reload warning: {e}", file=sys.stderr)
def _handle_compose(self, body):
"""Run compose_music.py on host with GPU access."""
import subprocess, tempfile
prompt = body.get("prompt", "")
duration = body.get("duration", 30)
if not prompt:
self.send_error(400, "Missing prompt")
return
output_path = tempfile.mktemp(suffix=".wav", prefix="kxkm-compose-")
script_path = os.path.join(os.path.dirname(__file__), "compose_music.py")
try:
self._free_vram_for_generation()
result = subprocess.run(
[sys.executable, script_path, "--prompt", prompt, "--duration", str(duration), "--output", output_path],
capture_output=True, text=True, timeout=300,
env={**os.environ, "COQUI_TOS_AGREED": "1"},
)
# Parse JSON output from last line
last_line = (result.stdout.strip().split("\n") or ["{}"])[-1]
data = json.loads(last_line)
if data.get("status") == "completed" and os.path.exists(output_path):
with open(output_path, "rb") as f:
audio = f.read()
self.send_response(200)
self.send_header("Content-Type", "audio/wav")
self.send_header("Content-Length", str(len(audio)))
self.end_headers()
self.wfile.write(audio)
print(f"[compose] {prompt[:50]}: {len(audio)} bytes, {duration}s", file=sys.stderr)
else:
error = data.get("error", result.stderr[-200:] if result.stderr else "unknown")
self.send_response(500)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"error": error}).encode())
print(f"[compose] FAIL: {error}", file=sys.stderr)
except subprocess.TimeoutExpired:
self.send_response(504)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"error": "Timeout (5min)"}).encode())
print(f"[compose] TIMEOUT", file=sys.stderr)
except Exception as e:
self.send_response(500)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"error": str(e)}).encode())
print(f"[compose] ERROR: {e}", file=sys.stderr)
finally:
try: os.unlink(output_path)
except: pass
def log_message(self, format, *args):
pass # Suppress default access logs
def main():
global TTS_BACKEND
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=9100)
parser.add_argument("--backend", choices=["piper", "chatterbox", "chatterbox-remote", "qwen3"], default=TTS_BACKEND)
args = parser.parse_args()
TTS_BACKEND = args.backend
# Pre-load TTS backend
if TTS_BACKEND == "qwen3":
# Quick connectivity check to Qwen3-TTS sidecar (non-fatal)
try:
req = urllib.request.Request(f"{QWEN3_TTS_URL.rstrip('/')}/health")
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read())
print(f"[tts-server] Qwen3-TTS OK at {QWEN3_TTS_URL}: {data}", file=sys.stderr)
except Exception as e:
print(f"[tts-server] WARNING: Qwen3-TTS not reachable ({QWEN3_TTS_URL}): {e}", file=sys.stderr)
print(f"[tts-server] Will try at request time, fallback to piper", file=sys.stderr)
elif TTS_BACKEND == "chatterbox-remote":
# Quick connectivity check (non-fatal)
try:
req = urllib.request.Request(f"{CHATTERBOX_URL.rstrip('/')}/get_predefined_voices")
with urllib.request.urlopen(req, timeout=5) as resp:
voices = json.loads(resp.read())
print(f"[tts-server] Chatterbox remote OK at {CHATTERBOX_URL}, {len(voices)} voices", file=sys.stderr)
except Exception as e:
print(f"[tts-server] WARNING: Chatterbox remote not reachable ({CHATTERBOX_URL}): {e}", file=sys.stderr)
print(f"[tts-server] Will try at request time, fallback to piper", file=sys.stderr)
elif TTS_BACKEND == "chatterbox":
try:
load_chatterbox()
except Exception as e:
print(f"[tts-server] WARNING: Chatterbox failed: {e}, falling back to piper", file=sys.stderr)
TTS_BACKEND = "piper"
if TTS_BACKEND in ("piper", "chatterbox-remote", "qwen3"):
try:
load_piper()
print(f"[tts-server] Piper loaded, voices: {VOICE_DIR}", file=sys.stderr)
except Exception as e:
print(f"[tts-server] WARNING: Piper not available: {e}", file=sys.stderr)
print(f"[tts-server] Backend: {TTS_BACKEND}", file=sys.stderr)
server = HTTPServer(("127.0.0.1", args.port), TTSHandler)
print(f"[tts-server] Listening on http://127.0.0.1:{args.port}", file=sys.stderr)
server.serve_forever()
if __name__ == "__main__":
main()