scripts/qwen3-tts-server.py
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env python3
"""Opt-in, loopback-only Qwen3-TTS sidecar for PalmTTY.
Install Qwen3-TTS in a separate environment. Download weights explicitly;
this service never accepts model paths, URLs or credentials from the browser.
"""
import argparse
from collections import OrderedDict
import hashlib
from http.server import BaseHTTPRequestHandler, HTTPServer
import hmac
import io
import json
import os
SPEAKERS = frozenset(("Vivian", "Serena", "Uncle_Fu", "Dylan", "Eric",
"Ryan", "Aiden", "Ono_Anna", "Sohee"))
MAX_INPUT_BYTES = 2048
MAX_AUDIO_BYTES = 8 * 1024 * 1024
def main():
parser = argparse.ArgumentParser(description="PalmTTY private Qwen3-TTS sidecar")
parser.add_argument("--model-dir", required=True, help="Local, already downloaded model directory")
parser.add_argument("--model", choices=("0.6b", "1.7b"), required=True)
parser.add_argument("--port", type=int, default=18765)
parser.add_argument("--device", choices=("cpu", "cuda"), default="cuda")
args = parser.parse_args()
if not 1024 <= args.port <= 65535:
parser.error("port must be 1024..65535")
if not os.path.isdir(args.model_dir):
parser.error("model-dir must be an existing local directory")
token = os.environ.get("PALMTTY_QWEN_TOKEN", "")
if not 32 <= len(token) <= 512:
parser.error("PALMTTY_QWEN_TOKEN must be set to a 32..512 character random secret")
# Model files must be explicitly prepared. Do not download weights at runtime.
os.environ["HF_HUB_OFFLINE"] = "1"
os.environ["TRANSFORMERS_OFFLINE"] = "1"
import torch
import soundfile as sf
from qwen_tts import Qwen3TTSModel
dtype = torch.bfloat16 if args.device == "cuda" else torch.float32
engine = Qwen3TTSModel.from_pretrained(
args.model_dir, device_map="cuda:0" if args.device == "cuda" else "cpu",
dtype=dtype
)
cache = OrderedDict()
cache_bytes = 0
class Handler(BaseHTTPRequestHandler):
def log_message(self, _format, *unused):
# No source text, auth headers, or synthesized content in ordinary logs.
pass
def respond(self, code, body, content_type="application/json"):
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "private, no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.end_headers()
self.wfile.write(body)
def authorized(self):
header = self.headers.get("Authorization", "")
if not hmac.compare_digest(header.encode("utf-8"),
("Bearer " + token).encode("utf-8")):
self.respond(401, b'{"error":"unauthorized"}')
return False
return True
def do_GET(self):
if not self.authorized():
return
if self.path != "/status":
self.respond(404, b'{"error":"not_found"}')
return
self.respond(200, json.dumps({"model": args.model}).encode("utf-8"))
def do_POST(self):
if not self.authorized():
return
if self.path != "/synthesize":
self.respond(404, b'{"error":"not_found"}')
return
try:
size = int(self.headers.get("Content-Length", "-1"))
except ValueError:
size = -1
if not 1 <= size <= MAX_INPUT_BYTES or self.headers.get("Transfer-Encoding"):
self.respond(413, b'{"error":"input_too_large"}')
return
try:
data = json.loads(self.rfile.read(size))
if not isinstance(data, dict) or set(data) != {"text", "speaker", "style"}:
raise ValueError()
text, speaker, style = data["text"], data["speaker"], data["style"]
if (not isinstance(text, str) or not 1 <= len(text) <= 260
or speaker not in SPEAKERS or style not in ("neutral", "story")
or (style == "story" and args.model != "1.7b")):
raise ValueError()
except (ValueError, TypeError, UnicodeError):
self.respond(400, b'{"error":"invalid_request"}')
return
nonlocal cache_bytes
key = hashlib.sha256(json.dumps(
[args.model, text, speaker, style], ensure_ascii=False
).encode("utf-8")).digest()
if key in cache:
cache.move_to_end(key)
self.respond(200, cache[key], "audio/wav")
return
try:
kwargs = {"text": text, "language": "Chinese", "speaker": speaker}
if args.model == "1.7b" and style == "story":
kwargs["instruct"] = "自然、清晰、克制地讲述中文小说,保留原文,不增删内容。"
with torch.inference_mode():
waveforms, sample_rate = engine.generate_custom_voice(**kwargs)
buffer = io.BytesIO()
sf.write(buffer, waveforms[0], sample_rate, format="WAV", subtype="PCM_16")
audio = buffer.getvalue()
if not 44 <= len(audio) <= MAX_AUDIO_BYTES:
raise ValueError("Audio outside bounds")
while cache_bytes + len(audio) > 32 * 1024 * 1024 and cache:
_, removed = cache.popitem(last=False)
cache_bytes -= len(removed)
cache[key] = audio
cache_bytes += len(audio)
self.respond(200, audio, "audio/wav")
except Exception:
# Inference failures are bounded and opaque: never echo the source.
self.respond(503, b'{"error":"synthesis_failed"}')
print("PalmTTY Qwen3-TTS sidecar listening on 127.0.0.1:" + str(args.port), flush=True)
HTTPServer(("127.0.0.1", args.port), Handler).serve_forever()
if __name__ == "__main__":
main()