#!/usr/bin/env python3 """Serve the voice-cloning UI and proxy requests to the local Qwen3-TTS API.""" from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib import error, parse, request import argparse import json import math import os import re ROOT = Path(__file__).resolve().parent UI_FILE = ROOT / "ui" / "voice-cloning.html" VECTOR_DIR = Path("/root/.cache/qwen3-tts-ui/vectors") NAME_RE = re.compile(r"^[A-Za-z0-9_.-]{1,80}$") class VoiceCloneHandler(BaseHTTPRequestHandler): api_base = "http://localhost:8091" clone_api_base = "http://localhost:8093" model = os.environ.get("QWEN3_TTS_MODEL", "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice") def do_GET(self): if self.path in ("/", "/voice-cloning.html"): self._send_file(UI_FILE, "text/html; charset=utf-8") return if self.path == "/health": self._proxy_get("/health") return if self.path == "/api/config": self._json_response(200, {"model": self.model}) return if self.path == "/api/v1/audio/voices": self._proxy_get("/v1/audio/voices") return if self.path == "/api/clone/health": self._proxy_get_base(self.clone_api_base, "/health", timeout=3) return if self.path == "/api/voice-vectors": self._list_vectors() return prefix = "/api/voice-vectors/" if self.path.startswith(prefix): self._get_vector(self.path[len(prefix):]) return self.send_error(404) def do_POST(self): if self.path == "/api/v1/audio/speech": self._proxy_post("/v1/audio/speech") return if self.path == "/api/v1/audio/voices": self._proxy_post("/v1/audio/voices") return if self.path == "/api/clone/speech": self._proxy_post_base(self.clone_api_base, "/v1/audio/speech", timeout=180) return if self.path == "/api/voice-vectors": self._save_vector() return prefix = "/api/voice-vectors/" if self.path.startswith(prefix) and self.path.endswith("/speech"): self._speech_with_vector(self.path[len(prefix):-len("/speech")]) return self.send_error(404) def do_DELETE(self): prefix = "/api/v1/audio/voices/" if self.path.startswith(prefix): name = parse.quote(parse.unquote(self.path[len(prefix):]), safe="") self._proxy_delete(f"/v1/audio/voices/{name}") return vector_prefix = "/api/voice-vectors/" if self.path.startswith(vector_prefix): self._delete_vector(self.path[len(vector_prefix):]) return self.send_error(404) def log_message(self, fmt, *args): print("%s - %s" % (self.address_string(), fmt % args)) def _json_response(self, status, payload): body = json.dumps(payload).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _read_json_body(self): length = int(self.headers.get("Content-Length", "0")) raw = self.rfile.read(length) return json.loads(raw.decode("utf-8") or "{}") def _clean_vector_name(self, raw_name): name = parse.unquote(str(raw_name)).strip() if not NAME_RE.match(name): raise ValueError("Vector name must be 1-80 chars: letters, digits, dot, underscore or dash") return name def _vector_path(self, raw_name): name = self._clean_vector_name(raw_name) return VECTOR_DIR / f"{name}.json" def _validate_embedding(self, value): if isinstance(value, str): value = json.loads(value) if not isinstance(value, list) or not value: raise ValueError("embedding must be a non-empty list") if len(value) > 4096: raise ValueError("embedding exceeds 4096 values") emb = [] for item in value: if not isinstance(item, (int, float)) or not math.isfinite(float(item)): raise ValueError("embedding must contain only finite numbers") emb.append(float(item)) return emb def _list_vectors(self): VECTOR_DIR.mkdir(parents=True, exist_ok=True) vectors = [] for path in sorted(VECTOR_DIR.glob("*.json")): try: data = json.loads(path.read_text()) vectors.append({ "name": data.get("name", path.stem), "dim": len(data.get("embedding", [])), "description": data.get("description", ""), "created_at": data.get("created_at", 0), }) except Exception: continue self._json_response(200, {"vectors": vectors}) def _get_vector(self, raw_name): try: path = self._vector_path(raw_name) if not path.exists(): self._json_response(404, {"error": "vector not found"}) return self._json_response(200, json.loads(path.read_text())) except Exception as exc: self._json_response(400, {"error": str(exc)}) def _save_vector(self): try: data = self._read_json_body() name = self._clean_vector_name(data.get("name", "")) embedding = self._validate_embedding(data.get("embedding")) VECTOR_DIR.mkdir(parents=True, exist_ok=True) payload = { "name": name, "embedding": embedding, "description": str(data.get("description", "")), "created_at": data.get("created_at") or __import__("time").time(), } (VECTOR_DIR / f"{name}.json").write_text(json.dumps(payload)) self._json_response(200, {"success": True, "vector": {"name": name, "dim": len(embedding)}}) except Exception as exc: self._json_response(400, {"error": str(exc)}) def _delete_vector(self, raw_name): try: path = self._vector_path(raw_name) path.unlink(missing_ok=True) self._json_response(200, {"success": True}) except Exception as exc: self._json_response(400, {"error": str(exc)}) def _speech_with_vector(self, raw_name): try: path = self._vector_path(raw_name) if not path.exists(): self._json_response(404, {"error": "vector not found"}) return vector = json.loads(path.read_text()).get("embedding") payload = self._read_json_body() payload["speaker_embedding"] = self._validate_embedding(vector) payload["task_type"] = "Base" payload["x_vector_only_mode"] = True body = json.dumps(payload).encode("utf-8") upstream = request.Request( f"{self.api_base}/v1/audio/speech", data=body, method="POST", headers={"Content-Type": "application/json"}, ) self._send_upstream(upstream) except Exception as exc: self._json_response(400, {"error": str(exc)}) def _send_file(self, path, content_type): if not path.exists(): self.send_error(404) return body = path.read_bytes() self.send_response(200) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _proxy_get(self, target_path): self._proxy_get_base(self.api_base, target_path) def _proxy_get_base(self, base_url, target_path, timeout=180): upstream = request.Request(f"{base_url}{target_path}", method="GET") self._send_upstream(upstream, timeout=timeout) def _proxy_post(self, target_path): self._proxy_post_base(self.api_base, target_path) def _proxy_post_base(self, base_url, target_path, timeout=180): length = int(self.headers.get("Content-Length", "0")) body = self.rfile.read(length) headers = {"Content-Type": self.headers.get("Content-Type", "application/json")} upstream = request.Request( f"{base_url}{target_path}", data=body, method="POST", headers=headers, ) self._send_upstream(upstream, timeout=timeout) def _proxy_delete(self, target_path): upstream = request.Request(f"{self.api_base}{target_path}", method="DELETE") self._send_upstream(upstream) def _send_upstream(self, upstream, timeout=180): try: with request.urlopen(upstream, timeout=timeout) as response: body = response.read() self.send_response(response.status) self.send_header("Content-Type", response.headers.get("Content-Type", "application/octet-stream")) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) except error.HTTPError as exc: body = exc.read() self.send_response(exc.code) self.send_header("Content-Type", exc.headers.get("Content-Type", "text/plain; charset=utf-8")) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) except Exception as exc: body = json.dumps({"error": str(exc)}).encode("utf-8") self.send_response(502) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def main(): parser = argparse.ArgumentParser() parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=8092) parser.add_argument("--api-base", default="http://localhost:8091") parser.add_argument("--clone-api-base", default="http://localhost:8093") parser.add_argument("--model", default=os.environ.get("QWEN3_TTS_MODEL", "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice")) args = parser.parse_args() VoiceCloneHandler.api_base = args.api_base.rstrip("/") VoiceCloneHandler.clone_api_base = args.clone_api_base.rstrip("/") VoiceCloneHandler.model = args.model server = ThreadingHTTPServer((args.host, args.port), VoiceCloneHandler) print(f"UI: http://{args.host}:{args.port}/") print(f"Upstream: {VoiceCloneHandler.api_base}") print(f"Clone upstream: {VoiceCloneHandler.clone_api_base}") server.serve_forever() if __name__ == "__main__": main()