Files
Qwen3-tts/docker/voice_clone_ui.py

277 lines
11 KiB
Python
Raw Permalink Normal View History

2026-06-18 17:27:42 +02:00
#!/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()