277 lines
11 KiB
Python
277 lines
11 KiB
Python
#!/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()
|