Kleineres Modell
This commit is contained in:
276
docker/voice_clone_ui.py
Normal file
276
docker/voice_clone_ui.py
Normal file
@@ -0,0 +1,276 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user