#!/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()