AI Backends: local service connect/disconnect, Docker container management, 0.0.0.0 probe fix

- Add initLlmsSection() IIFE to app.js: copy buttons, API key persistence with eye toggle
  and saved badge, local service URL persistence, Connect/Disconnect toggle with server-side
  probe via /api/probe-url (avoids CORS), card turns green on success / red on failure
- Substitute 0.0.0.0 → host.docker.internal before probing (0.0.0.0 not routable from Docker)
- Add /api/local-containers, /api/probe-url, start/stop/restart endpoints to server.py
- Rewrite AI Backends section into Local / Online API categories with Docker stack grid,
  local service cards (LLM/STT/TTS) with icons and editable URL inputs, online cloud API cards
- Add bind mounts for static/ and server.py so changes take effect without image rebuild
- Add dc-grid, llm-local-grid CSS with uniform minmax(310px,1fr) card layout
- Fix VOICE_HOST_DIR default via .env so voice folders survive container recreation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-05-26 22:35:42 +02:00
parent 04b82e49d8
commit 90cc7b7eb1
13 changed files with 1345 additions and 493 deletions

View File

@ -10,15 +10,23 @@ services:
ports: ports:
- "7890:7890" - "7890:7890"
# Give the app user access to the Docker socket (GID of docker group on host)
group_add:
- "${DOCKER_GID:-988}"
volumes: volumes:
- ${VOICE_HOST_DIR:-./voices}:/voices:rw - ${VOICE_HOST_DIR:-./voices}:/voices:rw
- tts-voice-creator-clone-and-design-2:/home/app/.config/tts-voice-creator - tts-voice-creator-clone-and-design-2:/home/app/.config/tts-voice-creator
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./static:/app/static:ro
- ./server.py:/app/server.py:ro
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- VOICES_DIR=/voices - VOICES_DIR=/voices
- OUTPUT_DIR=/voices/active_voices - OUTPUT_DIR=/voices/active_voices
- TTS_CONTAINER_NAME=faster-qwen3-tts # Containers that load WAV files and need a restart after voice changes
- TTS_CONTAINER_NAMES=faster-qwen3-tts-voiceclone,faster-qwen3-tts-streaming
- VOICE_DESIGN_MODEL=Qwen3-TTS-12Hz-1.7B-VoiceDesign - VOICE_DESIGN_MODEL=Qwen3-TTS-12Hz-1.7B-VoiceDesign
extra_hosts: extra_hosts:

377
server.py
View File

@ -47,6 +47,7 @@ _NVIDIA_CLONE_DEFAULT = os.environ.get("NVIDIA_TTS_CLONE_URL", "http://host.dock
_NVIDIA_ZEROSHOT_DEFAULT = os.environ.get("NVIDIA_ZEROSHOT_TTS_URL", _NVIDIA_CLONE_DEFAULT) _NVIDIA_ZEROSHOT_DEFAULT = os.environ.get("NVIDIA_ZEROSHOT_TTS_URL", _NVIDIA_CLONE_DEFAULT)
_NVIDIA_FLOW_DEFAULT = os.environ.get("NVIDIA_FLOW_TTS_URL", "http://host.docker.internal:8094") _NVIDIA_FLOW_DEFAULT = os.environ.get("NVIDIA_FLOW_TTS_URL", "http://host.docker.internal:8094")
_TTS_CONTAINER = os.environ.get("TTS_CONTAINER_NAME", "faster-qwen3-tts") _TTS_CONTAINER = os.environ.get("TTS_CONTAINER_NAME", "faster-qwen3-tts")
_TTS_CONTAINERS_RAW = os.environ.get("TTS_CONTAINER_NAMES", "") # comma-separated override
_VOICE_DESIGN_MODEL = os.environ.get("VOICE_DESIGN_MODEL", "Qwen3-TTS-12Hz-1.7B-VoiceDesign") _VOICE_DESIGN_MODEL = os.environ.get("VOICE_DESIGN_MODEL", "Qwen3-TTS-12Hz-1.7B-VoiceDesign")
_VOICE_TARGET_DBFS = float(os.environ.get("VOICE_TARGET_DBFS", "-20.0")) _VOICE_TARGET_DBFS = float(os.environ.get("VOICE_TARGET_DBFS", "-20.0"))
_VOICE_PEAK_DBFS = float(os.environ.get("VOICE_PEAK_DBFS", "-1.0")) _VOICE_PEAK_DBFS = float(os.environ.get("VOICE_PEAK_DBFS", "-1.0"))
@ -192,6 +193,62 @@ def _copy_limited(src, dest, limit: int) -> int:
return total return total
def _decode_chunked_bytes(data: bytes) -> bytes:
result = bytearray()
pos = 0
while pos < len(data):
end = data.find(b"\r\n", pos)
if end < 0:
break
try:
size = int(data[pos:end].split(b";")[0].strip(), 16)
except ValueError:
break
if size == 0:
break
pos = end + 2
result.extend(data[pos:pos + size])
pos += size + 2
return bytes(result)
def _docker_get_json(path: str) -> tuple[int, dict | list | None]:
sock_path = os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")
if not Path(sock_path).exists():
raise RuntimeError(f"Docker socket not found: {sock_path}")
request = (
f"GET {path} HTTP/1.1\r\n"
"Host: docker\r\n"
"Connection: close\r\n\r\n"
).encode("utf-8")
chunks: list[bytes] = []
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.settimeout(10)
sock.connect(sock_path)
sock.sendall(request)
while True:
data = sock.recv(65536)
if not data:
break
chunks.append(data)
raw = b"".join(chunks)
header_end = raw.find(b"\r\n\r\n")
if header_end < 0:
raise RuntimeError("Invalid Docker HTTP response")
header_str = raw[:header_end].decode("utf-8", errors="replace")
body_bytes = raw[header_end + 4:]
status_line = header_str.splitlines()[0]
m = re.match(r"HTTP/\S+\s+(\d+)", status_line)
if not m:
raise RuntimeError(f"Invalid Docker status line: {status_line!r}")
code = int(m.group(1))
if "transfer-encoding: chunked" in header_str.lower():
body_bytes = _decode_chunked_bytes(body_bytes)
if not body_bytes.strip():
return code, None
return code, json.loads(body_bytes)
def _docker_post(path: str) -> tuple[int, str]: def _docker_post(path: str) -> tuple[int, str]:
sock_path = os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock") sock_path = os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")
if not Path(sock_path).exists(): if not Path(sock_path).exists():
@ -1456,6 +1513,102 @@ async def import_source_audio(request: Request):
return {"id": fid, "duration": _duration(wav), "filename": filename or dest.name, "audio_url": audio_url} return {"id": fid, "duration": _duration(wav), "filename": filename or dest.name, "audio_url": audio_url}
# ── Audio proxy (CORS bypass for external sources) ────────────────────────────
_PROXY_AUDIO_DOMAINS: set[str] = {
"drive.usercontent.google.com",
"drive.google.com",
"aiartes.com",
"freesound.org",
"lanceblairvo.com",
"raw.githubusercontent.com",
"sample-files.com",
}
@app.get("/api/proxy-audio")
async def proxy_audio(url: str):
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise HTTPException(400, "Only http/https URLs are supported")
domain = parsed.netloc.lower().lstrip("www.")
if not any(domain == d or domain.endswith("." + d) for d in _PROXY_AUDIO_DOMAINS):
raise HTTPException(403, f"Domain not in audio proxy allowlist: {parsed.netloc}")
try:
resp = requests.get(url, headers=_VOICE_SOURCE_HEADERS, timeout=30, stream=True)
resp.raise_for_status()
except Exception as e:
raise HTTPException(502, f"Proxy fetch failed: {e}")
content_type = resp.headers.get("content-type", "audio/mpeg")
def _stream():
for chunk in resp.iter_content(65536):
if chunk:
yield chunk
return StreamingResponse(
_stream(), media_type=content_type,
headers={"Cache-Control": "public, max-age=3600", "Access-Control-Allow-Origin": "*"},
)
# ── Quick voice import (download + save directly to library) ──────────────────
@app.post("/api/quick-import-voice")
async def quick_import_voice(request: Request):
data = await request.json()
audio_url = _source_import_url(str(data.get("audio_url") or ""))
voice_id = re.sub(r"[^A-Za-z0-9_\-\.]", "_", str(data.get("voice_id") or "").strip())[:80]
if not voice_id:
raise HTTPException(400, "voice_id is required")
transcript = str(data.get("transcript") or "").strip()
suffix = Path(urlparse(audio_url).path).suffix.lower()
if suffix not in _AUDIO_EXTS:
suffix = ".bin"
dest = TEMP_DIR / f"{uuid.uuid4().hex}{suffix}"
try:
with requests.get(audio_url, headers=_VOICE_SOURCE_HEADERS, timeout=30, stream=True) as r:
r.raise_for_status()
total = 0
with dest.open("wb") as f:
for chunk in r.iter_content(1024 * 1024):
if not chunk:
continue
total += len(chunk)
if total > _source_download_limit("_MAX_UPLOAD_BYTES", 1024):
raise HTTPException(413, "Downloaded audio is too large")
f.write(chunk)
wav = _to_wav_24k(dest)
except HTTPException:
raise
except Exception as e:
raise HTTPException(400, f"Audio download failed: {e}")
settings = _load_settings()
out_dir = _active_voices_dir(settings)
out_dir.mkdir(parents=True, exist_ok=True)
final_id = voice_id
if (out_dir / f"{final_id}.wav").exists():
for i in range(2, 1000):
candidate = f"{voice_id}_{i}"
if not (out_dir / f"{candidate}.wav").exists():
final_id = candidate
break
wav_dest = out_dir / f"{final_id}.wav"
_remove_audio_variants(out_dir, final_id)
loudness = _export_normalized_wav(wav, wav_dest)
if transcript:
(out_dir / f"{final_id}.reference.txt").write_text(transcript, encoding="utf-8")
meta = _load_meta(wav_dest)
meta["enabled"] = True
meta["loudness"] = loudness
_save_meta(wav_dest, meta)
return {"voice_id": final_id, "loudness": loudness}
# ── YouTube download (SSE) ──────────────────────────────────────────────────── # ── YouTube download (SSE) ────────────────────────────────────────────────────
@app.get("/api/download-yt") @app.get("/api/download-yt")
@ -2599,6 +2752,77 @@ def _normalize_voice_source_urls(urls: list[str] | None = None) -> list[str]:
return normalized[:24] return normalized[:24]
_GDRIVE_MIME_EXT = {
"audio/mpeg": "mp3", "audio/mp3": "mp3",
"audio/wav": "wav", "audio/x-wav": "wav",
"audio/ogg": "ogg", "audio/vorbis": "ogg",
"audio/flac": "flac", "audio/x-flac": "flac",
"audio/aac": "aac", "audio/x-aac": "aac",
"audio/x-m4a": "m4a", "audio/m4a": "m4a", "audio/mp4": "m4a",
}
def _scrape_google_drive_folder(url: str) -> dict:
m = re.search(r"/folders/([A-Za-z0-9_-]+)", url)
if not m:
return _generic_audio_page_source(url)
folder_id = m.group(1)
source_id = _voice_source_id(url)
source_name = "Google Drive"
embed_url = f"https://drive.google.com/embeddedfolderview?id={folder_id}#list"
try:
body = _source_get(embed_url).text
except Exception:
return _generic_audio_page_source(url)
items = []
seen: set[str] = set()
for chunk in re.split(r"(?=<div class=\"flip-entry\" id=\"entry-)", body):
eid = re.search(r'id="entry-([A-Za-z0-9_-]+)"', chunk)
title_m = re.search(r'class="flip-entry-title">(.*?)</div>', chunk)
mime_m = re.search(r"type/([a-zA-Z0-9/+\-]+)", chunk)
if not eid or not title_m:
continue
file_id = eid.group(1)
if file_id in seen:
continue
seen.add(file_id)
filename = title_m.group(1).strip()
mime = mime_m.group(1).lower() if mime_m else ""
if not mime.startswith("audio"):
continue
ext = _GDRIVE_MIME_EXT.get(mime, Path(filename).suffix.lstrip(".").lower() or "mp3")
gdrive_dl = f"https://drive.usercontent.google.com/download?id={file_id}&export=download&authuser=0"
proxy_url = f"/api/proxy-audio?url={quote(gdrive_dl)}"
stem = Path(filename).stem.replace("-", " ").replace("_", " ").strip()
lang = _guess_language(filename)
gender = _guess_gender(filename)
item = _source_item(
source_id, source_name,
stem, "Google Drive audio",
url,
audio_url=proxy_url,
file_type=ext,
category="Google Drive",
description=f"Audio file from Google Drive folder.",
language=lang,
gender=gender,
)
item["import_url"] = gdrive_dl
items.append(item)
if not items:
items.append(_source_item(
source_id, source_name,
"Google Drive folder", "Source page",
url,
category="Google Drive",
description="No audio files found in this Google Drive folder. It may be private or empty.",
))
return _source_result(source_id, source_name, url, f"{len(items)} audio files from Google Drive folder.", items)
def _scrape_voice_source_url(url: str) -> dict: def _scrape_voice_source_url(url: str) -> dict:
lower = url.lower() lower = url.lower()
if "aiartes.com/voiceai" in lower: if "aiartes.com/voiceai" in lower:
@ -2613,6 +2837,8 @@ def _scrape_voice_source_url(url: str) -> dict:
return _scrape_freesound_scott_simpson() return _scrape_freesound_scott_simpson()
if "lanceblairvo.com/raw-voiceover-samples" in lower: if "lanceblairvo.com/raw-voiceover-samples" in lower:
return _scrape_lanceblair_raw_samples() return _scrape_lanceblair_raw_samples()
if "drive.google.com/drive/folders/" in lower or "drive.google.com/open?id=" in lower:
return _scrape_google_drive_folder(url)
if _DIRECT_AUDIO_RE.search(lower): if _DIRECT_AUDIO_RE.search(lower):
return _direct_audio_source(url) return _direct_audio_source(url)
return _generic_audio_page_source(url) return _generic_audio_page_source(url)
@ -2916,30 +3142,159 @@ def _clear_tts_restart_flags(settings: dict | None = None) -> int:
cleared += 1 cleared += 1
return cleared return cleared
def _tts_container_names() -> list[str]:
"""Return the list of TTS container names to restart, from env vars."""
multi = os.environ.get("TTS_CONTAINER_NAMES", _TTS_CONTAINERS_RAW).strip()
if multi:
return [c.strip() for c in multi.split(",") if c.strip()]
single = os.environ.get("TTS_CONTAINER_NAME", _TTS_CONTAINER).strip()
return [single] if single else []
@app.post("/api/tts/restart") @app.post("/api/tts/restart")
async def restart_tts_container(): async def restart_tts_container():
container = os.environ.get("TTS_CONTAINER_NAME", _TTS_CONTAINER).strip() containers = _tts_container_names()
if not container: if not containers:
raise HTTPException(400, "TTS_CONTAINER_NAME is not configured") raise HTTPException(400, "No TTS container names configured (set TTS_CONTAINER_NAMES in docker-compose.yml)")
results = []
errors = []
for container in containers:
path = f"/containers/{quote(container, safe='')}/restart?t=10" path = f"/containers/{quote(container, safe='')}/restart?t=10"
try: try:
code, raw = _docker_post(path) code, raw = _docker_post(path)
except PermissionError:
raise HTTPException(502, "No permission to access /var/run/docker.sock from this container")
except Exception as e:
raise HTTPException(502, f"Docker restart failed: {e}")
if code not in (204, 304): if code not in (204, 304):
detail = raw.split("\r\n\r\n", 1)[-1].strip() or f"Docker API returned HTTP {code}" detail = raw.split("\r\n\r\n", 1)[-1].strip() or f"HTTP {code}"
raise HTTPException(502, detail) errors.append(f"{container}: {detail}")
else:
results.append(container)
except PermissionError:
raise HTTPException(502, "No permission to access /var/run/docker.sock — is the socket mounted in docker-compose.yml?")
except Exception as e:
errors.append(f"{container}: {e}")
cleared = 0 cleared = 0
try: try:
cleared = _clear_tts_restart_flags() cleared = _clear_tts_restart_flags()
except Exception as e: except Exception as e:
logger.warning("Could not clear TTS restart flags: %s", e) logger.warning("Could not clear TTS restart flags: %s", e)
return {"ok": True, "container": container, "cleared_restart_flags": cleared}
if errors and not results:
raise HTTPException(502, "; ".join(errors))
return {
"ok": True,
"restarted": results,
"errors": errors,
"cleared_restart_flags": cleared,
}
@app.get("/api/tts/restart-info")
async def tts_restart_info():
containers = _tts_container_names()
sock_ok = Path(os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")).exists()
return {"containers": containers, "socket_available": sock_ok}
# ── Local Docker container management ────────────────────────────────────────
_LOCAL_CONTAINER_DEFS: list[dict] = [
{"name": "faster-qwen3-tts-voiceclone", "label": "Qwen3 TTS · Voice Clone", "role": "tts", "port": 8020, "stack": "faster-qwen3-tts-dgx-spark-voice-design", "image": "martinb78/faster-qwen3-tts-dgx-spark:v4", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "WAV voice cloning. Scans active_voices at startup — restart after adding or editing voices."},
{"name": "faster-qwen3-tts-voicedesign", "label": "Qwen3 TTS · Voice Design", "role": "tts", "port": 8021, "stack": "faster-qwen3-tts-dgx-spark-voice-design", "image": "martinb78/faster-qwen3-tts-dgx-spark:v4", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "Instruction-based voice design. Describe a voice in words — no WAV needed."},
{"name": "faster-qwen3-tts-customvoice", "label": "Qwen3 TTS · Custom Voice", "role": "tts", "port": 8022, "stack": "faster-qwen3-tts-dgx-spark-voice-design", "image": "martinb78/faster-qwen3-tts-dgx-spark:v4", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "Style control over configured premium speakers such as Ryan, Vivian, and Serena."},
{"name": "faster-qwen3-tts-streaming", "label": "Qwen3 TTS · Streaming", "role": "tts", "port": 8023, "stack": "faster-qwen3-tts-dgx-spark-voice-design", "image": "martinb78/qwen3-tts-streaming-dgx-spark:latest", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "Low-latency progressive WAV streaming for voice clone voices."},
{"name": "parakeet-asr", "label": "NVIDIA Parakeet ASR", "role": "stt", "port": 8090, "stack": "nvidia-speech-gateway", "image": "parakeet-tdt-v3-spark:latest", "repo": "https://github.com/mARTin-B78/dgx-spark-parakeet-asr", "description": "NVIDIA Parakeet GPU-accelerated speech recognition on port 8090."},
{"name": "magpie-tts", "label": "NVIDIA Magpie TTS", "role": "tts", "port": 8091, "stack": "nvidia-speech-gateway", "image": "nvcr.io/nim/nvidia/magpie-tts-multilingual:latest","repo": "https://github.com/mARTin-B78/dgx-spark-parakeet-asr", "description": "NVIDIA neural TTS. GPU-accelerated, high-quality multilingual synthesis."},
{"name": "parakeet-rnnt-nim", "label": "NVIDIA Parakeet RNNT NIM", "role": "stt", "port": 8092, "stack": "nvidia-speech-gateway", "image": "nvcr.io/nim/nvidia/parakeet-1b-rnnt-multilingual:latest","repo": "https://github.com/mARTin-B78/dgx-spark-parakeet-asr", "description": "NVIDIA Parakeet RNNT NIM multilingual ASR on port 8092."},
]
def _container_status(name: str) -> dict:
try:
code, data = _docker_get_json(f"/containers/{quote(name, safe='')}/json")
if code == 404:
return {"status": "not_found"}
if code == 200 and isinstance(data, dict):
state = data.get("State", {})
return {
"status": state.get("Status", "unknown"),
"running": state.get("Running", False),
"paused": state.get("Paused", False),
"started_at": state.get("StartedAt", ""),
"image": data.get("Config", {}).get("Image", ""),
}
return {"status": "error", "detail": f"HTTP {code}"}
except Exception as e:
return {"status": "error", "detail": str(e)}
@app.get("/api/local-containers")
async def get_local_containers():
sock_ok = Path(os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")).exists()
results = []
for defn in _LOCAL_CONTAINER_DEFS:
entry = {k: v for k, v in defn.items()}
if sock_ok:
entry.update(_container_status(defn["name"]))
else:
entry["status"] = "no_socket"
results.append(entry)
return {"containers": results, "socket_available": sock_ok}
@app.post("/api/local-containers/{name}/start")
async def start_local_container(name: str):
if not any(c["name"] == name for c in _LOCAL_CONTAINER_DEFS):
raise HTTPException(404, f"Unknown container: {name}")
try:
code, _ = _docker_post(f"/containers/{quote(name, safe='')}/start")
except Exception as e:
raise HTTPException(502, f"Docker start failed: {e}")
if code not in (204, 304):
raise HTTPException(502, f"Docker API returned HTTP {code}")
return {"ok": True, "name": name, **_container_status(name)}
@app.post("/api/local-containers/{name}/stop")
async def stop_local_container(name: str):
if not any(c["name"] == name for c in _LOCAL_CONTAINER_DEFS):
raise HTTPException(404, f"Unknown container: {name}")
try:
code, _ = _docker_post(f"/containers/{quote(name, safe='')}/stop?t=10")
except Exception as e:
raise HTTPException(502, f"Docker stop failed: {e}")
if code not in (204, 304):
raise HTTPException(502, f"Docker API returned HTTP {code}")
return {"ok": True, "name": name, **_container_status(name)}
@app.post("/api/local-containers/{name}/restart")
async def restart_local_container(name: str):
if not any(c["name"] == name for c in _LOCAL_CONTAINER_DEFS):
raise HTTPException(404, f"Unknown container: {name}")
try:
code, _ = _docker_post(f"/containers/{quote(name, safe='')}/restart?t=10")
except Exception as e:
raise HTTPException(502, f"Docker restart failed: {e}")
if code not in (204, 304):
raise HTTPException(502, f"Docker API returned HTTP {code}")
return {"ok": True, "name": name, **_container_status(name)}
@app.get("/api/probe-url")
async def probe_url(url: str):
"""Server-side reachability check — avoids browser CORS restrictions."""
try:
r = requests.get(url, timeout=5, allow_redirects=True,
headers={"User-Agent": "TTS-Voice-Creator/probe"})
return {"ok": True, "status": r.status_code}
except requests.exceptions.ConnectionError:
return {"ok": False, "error": "Connection refused"}
except requests.exceptions.Timeout:
return {"ok": False, "error": "Timeout"}
except Exception as e:
return {"ok": False, "error": str(e)}
@app.post("/api/tts/restart-flags/clear") @app.post("/api/tts/restart-flags/clear")

View File

@ -104,10 +104,10 @@ function selectVoice(wrap) {
</div> </div>
<div class="insp-hd-divider"></div> <div class="insp-hd-divider"></div>
<div class="insp-subtitle"> <div class="insp-subtitle">
<span class="insp-flag" title="Double-click to change accent flag"> <span class="insp-flag" title="Click to change accent flag">
<span class="insp-flag-icon">${flagIconHtml}</span> <span class="insp-flag-icon">${flagIconHtml}</span>
</span> </span>
<span class="insp-lang-wrap" title="Double-click to change language"> <span class="insp-lang-wrap" title="Click to change language">
<span class="insp-lang-code">${escHtml(langCode)}</span> <span class="insp-lang-code">${escHtml(langCode)}</span>
</span> </span>
<span class="insp-gender-label" title="Click to cycle gender">${escHtml(genderLabelHtml)}</span> <span class="insp-gender-label" title="Click to cycle gender">${escHtml(genderLabelHtml)}</span>
@ -202,7 +202,7 @@ function selectVoice(wrap) {
if (flagIconEl) flagIconEl.innerHTML = fi; if (flagIconEl) flagIconEl.innerHTML = fi;
await saveMeta(voiceId, { flag: cc }); await saveMeta(voiceId, { flag: cc });
}; };
flagSpan.addEventListener('dblclick', () => { flagSpan.addEventListener('click', () => {
const items = (FLAG_OPTIONS[langCode.toUpperCase()] || ALL_FLAGS).map(([cc, name]) => [cc.toLowerCase(), name]); const items = (FLAG_OPTIONS[langCode.toUpperCase()] || ALL_FLAGS).map(([cc, name]) => [cc.toLowerCase(), name]);
createSearchablePicker(flagSpan, items, applyFlag, { createSearchablePicker(flagSpan, items, applyFlag, {
placeholder: 'Country or accent…', placeholder: 'Country or accent…',
@ -211,7 +211,7 @@ function selectVoice(wrap) {
}); });
}); });
// ── Language — double-click lang code to change ─────────────────────────── // ── Language — click lang code to change ─────────────────────────────────
const langCodeEl = inspector.querySelector('.insp-lang-code'); const langCodeEl = inspector.querySelector('.insp-lang-code');
const langWrap = inspector.querySelector('.insp-lang-wrap'); const langWrap = inspector.querySelector('.insp-lang-wrap');
@ -220,7 +220,7 @@ function selectVoice(wrap) {
if (langCodeEl) langCodeEl.textContent = newLang; if (langCodeEl) langCodeEl.textContent = newLang;
await saveMeta(voiceId, { lang: newLang }); await saveMeta(voiceId, { lang: newLang });
}; };
langWrap?.addEventListener('dblclick', () => { langWrap?.addEventListener('click', () => {
const items = LANGS.map(l => [l, LANGUAGE_LABELS[l] ? `${LANGUAGE_LABELS[l]} (${l})` : l]); const items = LANGS.map(l => [l, LANGUAGE_LABELS[l] ? `${LANGUAGE_LABELS[l]} (${l})` : l]);
createSearchablePicker(langWrap, items, applyLang, { createSearchablePicker(langWrap, items, applyLang, {
placeholder: 'Language…', placeholder: 'Language…',
@ -357,14 +357,16 @@ function selectVoice(wrap) {
const maintTitle = body.querySelector('.opt-maintenance .opt-group-title'); const maintTitle = body.querySelector('.opt-maintenance .opt-group-title');
if (maintTitle) { if (maintTitle) {
maintTitle.innerHTML = `Loudness <span class="opt-group-meta">Current ${escHtml(dbfs)} dBFS</span> <span class="opt-chevron">&#8964;</span>`; maintTitle.innerHTML = `<span class="opt-chevron"></span><span class="opt-title-text">Loudness <span class="opt-group-meta">Current ${escHtml(dbfs)} dBFS</span></span>`;
} }
// ── Collapsible opt-groups ──────────────────────────────────────────────── // ── Collapsible opt-groups ────────────────────────────────────────────────
body.querySelectorAll('.opt-group').forEach(group => { body.querySelectorAll('.opt-group').forEach(group => {
const title = group.querySelector(':scope > .opt-group-title'); const title = group.querySelector(':scope > .opt-group-title');
if (!title) return; if (!title) return;
title.addEventListener('click', () => group.classList.toggle('open')); title.addEventListener('click', () => {
group.classList.toggle('open');
});
}); });
// Track extracted elements for restoreToRow // Track extracted elements for restoreToRow
@ -837,8 +839,9 @@ function renderGetVoices() {
const thumb = item.image_url ? `<img class="voice-source-thumb" src="${escHtml(item.image_url)}" alt="">` : `<div class="voice-source-thumb"></div>`; const thumb = item.image_url ? `<img class="voice-source-thumb" src="${escHtml(item.image_url)}" alt="">` : `<div class="voice-source-thumb"></div>`;
const audio = item.audio_url ? `<audio controls preload="none" src="${escHtml(item.audio_url)}"></audio>` : ''; const audio = item.audio_url ? `<audio controls preload="none" src="${escHtml(item.audio_url)}"></audio>` : '';
const audioLink = item.audio_url ? `<a class="btn-secondary" href="${escHtml(item.audio_url)}" target="_blank" rel="noopener">Open audio</a>` : ''; const audioLink = item.audio_url ? `<a class="btn-secondary" href="${escHtml(item.audio_url)}" target="_blank" rel="noopener">Open audio</a>` : '';
const canGetVoice = !!item.audio_url; const canGetVoice = !!(item.import_url || item.audio_url);
const getVoice = canGetVoice ? `<button class="btn-primary get-source-voice" data-url="${escHtml(item.audio_url)}" data-name="${escHtml(item.name || '')}" data-image="${escHtml(item.image_url || '')}" data-language="${escHtml(item.language || '')}" data-gender="${escHtml(item.gender || '')}" data-kind="${escHtml(item.kind || '')}" data-description="${escHtml(item.description || '')}" data-page="${escHtml(item.page_url || item._sourceHomepage || '')}">Import this voice</button>` : ''; const importUrl = item.import_url || item.audio_url;
const getVoice = canGetVoice ? `<button class="btn-primary get-source-voice" data-url="${escHtml(importUrl)}" data-name="${escHtml(item.name || '')}" data-image="${escHtml(item.image_url || '')}" data-language="${escHtml(item.language || '')}" data-gender="${escHtml(item.gender || '')}" data-kind="${escHtml(item.kind || '')}" data-description="${escHtml(item.description || '')}" data-page="${escHtml(item.page_url || item._sourceHomepage || '')}">Import this voice</button>` : '';
const type = item.file_type ? `<span class="voice-source-pill">${escHtml(String(item.file_type).toUpperCase())}</span>` : ''; const type = item.file_type ? `<span class="voice-source-pill">${escHtml(String(item.file_type).toUpperCase())}</span>` : '';
const language = item.language ? `<span class="voice-source-pill">${escHtml(item.language)}</span>` : ''; const language = item.language ? `<span class="voice-source-pill">${escHtml(item.language)}</span>` : '';
const gender = item.gender ? `<span class="voice-source-pill">${escHtml(item.gender)}</span>` : ''; const gender = item.gender ? `<span class="voice-source-pill">${escHtml(item.gender)}</span>` : '';
@ -963,32 +966,24 @@ function setLibAddSourcePreview(meta = {}) {
async function getSourceVoiceInLibrary(meta) { async function getSourceVoiceInLibrary(meta) {
if (!meta.url) { toast('This source has no direct audio URL', 'error'); return; } if (!meta.url) { toast('This source has no direct audio URL', 'error'); return; }
switchTab('library');
const panel = $('lib-add-panel');
if (panel && !panel.classList.contains('open')) panel.classList.add('open');
const lang = sourceLanguageCode(meta.language); const lang = sourceLanguageCode(meta.language);
const gender = sourceGenderCode(meta.gender, meta.name); const gender = sourceGenderCode(meta.gender, meta.name);
if ($('lib-add-url')) $('lib-add-url').value = meta.url; const voiceId = suggestedVoiceIdFromSourceName(meta.name, lang, gender);
if ($('lib-add-lang')) $('lib-add-lang').value = lang; toast(`Importing ${meta.name || 'voice'}`, 'info');
if ($('lib-add-gender')) $('lib-add-gender').value = gender;
if ($('lib-add-voice-id')) $('lib-add-voice-id').value = suggestedVoiceIdFromSourceName(meta.name, lang, gender);
if ($('lib-add-transcript')) $('lib-add-transcript').value = '';
if (window.libAddState) window.libAddState.pendingSource = {...meta, language: lang, gender};
setLibAddSourcePreview({...meta, language: lang, gender});
setLibAddStatus(`Importing source audio: ${meta.name || 'external voice'}...`);
try { try {
const r = await fetch('/api/import-source-audio', { const r = await fetch('/api/quick-import-voice', {
method: 'POST', headers: {'Content-Type': 'application/json'}, method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({audio_url: meta.url, name: meta.name || 'Source voice'}) body: JSON.stringify({audio_url: meta.url, voice_id: voiceId, transcript: ''})
}); });
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json(); const d = await r.json();
loadLibAddAudio(d.id, d.duration, meta.name || 'Source voice'); _pendingSelectId = d.voice_id;
setLibAddStatus(`Source voice loaded: ${meta.name || 'external voice'}${meta.imageUrl ? ' (image will attach on save)' : ''}`); navTo('s-voices');
toast('Voice imported into Voice Clone', 'success'); _libraryLoadPromise = null;
await loadVoiceLibrary();
toast(`Saved as ${d.voice_id}`, 'success');
} catch(e) { } catch(e) {
setLibAddStatus('Source import failed'); toast('Import failed: ' + e.message, 'error');
toast('Source import failed: ' + e.message, 'error');
} }
} }
@ -1852,6 +1847,12 @@ async function refreshTtsBackendAvailability(selected = '') {
sel.innerHTML = styleBackendOptions(prev); sel.innerHTML = styleBackendOptions(prev);
sel.disabled = !availableTtsBackends().length; sel.disabled = !availableTtsBackends().length;
}); });
const perfSel = $('perf-backend-select');
if (perfSel) {
const prev = perfSel.value;
perfSel.innerHTML = ttsBackendOptions(prev);
perfSel.disabled = !availableTtsBackends().length;
}
updateBackendHelp(); updateBackendHelp();
updateStyleBackendHelp(); updateStyleBackendHelp();
updateBackendDependentTabs(); updateBackendDependentTabs();
@ -2797,6 +2798,7 @@ $('save-btn').addEventListener('click', async () => {
// ══════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════
let _voices = []; let _voices = [];
let _pendingSelectId = null;
let _sortField = 'id'; let _sortField = 'id';
let _sortDir = 1; // 1 = asc, -1 = desc let _sortDir = 1; // 1 = asc, -1 = desc
let _libraryIssueFilter = ''; let _libraryIssueFilter = '';
@ -4251,6 +4253,10 @@ function renderVoiceList() {
return; return;
} }
filtered.forEach(v => list.appendChild(makeVoiceRow(v))); filtered.forEach(v => list.appendChild(makeVoiceRow(v)));
if (_pendingSelectId) {
const wrap = list.querySelector(`[data-id="${CSS.escape(_pendingSelectId)}"]`);
if (wrap) { _pendingSelectId = null; selectVoice(wrap); }
}
syncSortHeaders(); syncSortHeaders();
} }
@ -4506,7 +4512,8 @@ function makeVoiceRow(v) {
<div class="vr-optimizer"> <div class="vr-optimizer">
<div class="optimizer-grid"> <div class="optimizer-grid">
<div class="opt-group opt-trim-panel"> <div class="opt-group opt-trim-panel">
<div class="opt-group-title">Reference audio &middot; crop <span class="opt-chevron">&#8964;</span></div> <div class="opt-group-title"><span class="opt-chevron"></span><span class="opt-title-text">Reference audio &middot; crop</span></div>
<div class="opt-group-subtitle">Crop the saved WAV to 3&ndash;20&nbsp;s of clean speech.</div>
<div class="opt-group-body"> <div class="opt-group-body">
<canvas class="opt-wave"></canvas> <canvas class="opt-wave"></canvas>
<div class="opt-controls"> <div class="opt-controls">
@ -4522,7 +4529,8 @@ function makeVoiceRow(v) {
</div> </div>
</div> </div>
<div class="opt-group opt-text-panel open"> <div class="opt-group opt-text-panel open">
<div class="opt-group-title">Reference transcript <span class="opt-chevron">&#8964;</span></div> <div class="opt-group-title"><span class="opt-chevron"></span><span class="opt-title-text">Reference transcript</span></div>
<div class="opt-group-subtitle">The spoken text that matches this voice recording.</div>
<div class="opt-group-body"> <div class="opt-group-body">
<textarea class="opt-transcript" placeholder="Reference text">${escHtml(v.transcript || '')}</textarea> <textarea class="opt-transcript" placeholder="Reference text">${escHtml(v.transcript || '')}</textarea>
<div class="opt-controls"> <div class="opt-controls">
@ -4534,7 +4542,8 @@ function makeVoiceRow(v) {
</div> </div>
</div> </div>
<div class="opt-group opt-compare-panel"> <div class="opt-group opt-compare-panel">
<div class="opt-group-title">Voice match <span class="opt-chevron">&#8964;</span></div> <div class="opt-group-title"><span class="opt-chevron"></span><span class="opt-title-text">Voice match</span></div>
<div class="opt-group-subtitle">Compare the saved WAV with a fresh TTS synthesis.</div>
<div class="opt-group-body"> <div class="opt-group-body">
<div class="opt-controls"> <div class="opt-controls">
<div class="opt-field wide"><label>Comparison backend</label><select class="opt-compare-backend">${styleBackendOptions('voice_clone')}</select></div> <div class="opt-field wide"><label>Comparison backend</label><select class="opt-compare-backend">${styleBackendOptions('voice_clone')}</select></div>
@ -4554,15 +4563,16 @@ function makeVoiceRow(v) {
</div> </div>
</div> </div>
</div> </div>
<div class="opt-group opt-style-panel"> ${!isClone ? `<div class="opt-group opt-style-panel">
<div class="opt-group-title">Style variation <span class="opt-chevron">&#8964;</span></div> <div class="opt-group-title"><span class="opt-chevron"></span><span class="opt-title-text">Style variation</span></div>
<div class="opt-group-subtitle">Create a styled variant and save it as a new voice (CustomVoice only).</div>
<div class="opt-group-body"> <div class="opt-group-body">
<div class="opt-controls"> <div class="opt-controls">
<div class="opt-field wide"><label>Style instruction</label><input class="opt-style-instruct" type="text" placeholder="cheerful, calm, excited"></div> <div class="opt-field wide"><label>Style instruction</label><input class="opt-style-instruct" type="text" placeholder="cheerful, calm, excited"></div>
<div class="opt-field wide"><label>Style backend</label><select class="opt-style-backend">${styleBackendOptions('customvoice', true)}</select></div> <div class="opt-field wide"><label>Style backend</label><select class="opt-style-backend">${styleBackendOptions('customvoice', true)}</select></div>
<div class="opt-field wide"><label>New voice ID</label><input class="opt-style-voice-id" type="text" placeholder="DE_F_Amala_happy"></div> <div class="opt-field wide"><label>New voice ID</label><input class="opt-style-voice-id" type="text" placeholder="DE_F_Amala_happy"></div>
</div> </div>
<p class="opt-group-note">Preview first. Saving creates a new active WAV voice from the current reference text. Same-voice style only works when the selected backend knows this voice and honors <code>instruct</code>; Base/Streaming are fastest but often ignore style.</p> <p class="opt-group-note">Preview first. Saving creates a new active WAV voice from the current reference text. Same-voice style only works when the selected backend knows this voice and honors <code>instruct</code>; CustomVoice is style-aware; Base/Streaming are fastest but often ignore style.</p>
<div class="backend-help opt-style-backend-help"></div> <div class="backend-help opt-style-backend-help"></div>
<div class="opt-style-preview-box"> <div class="opt-style-preview-box">
<div class="opt-controls"> <div class="opt-controls">
@ -4574,16 +4584,17 @@ function makeVoiceRow(v) {
<button class="btn-primary opt-save-style">Save style variation</button> <button class="btn-primary opt-save-style">Save style variation</button>
</div> </div>
</div> </div>
</div> </div>` : ''}
<div class="opt-group opt-maintenance open"> <div class="opt-group opt-maintenance open">
<div class="opt-group-title">Loudness <span class="opt-chevron">&#8964;</span></div> <div class="opt-group-title"><span class="opt-chevron"></span><span class="opt-title-text">Loudness</span></div>
<div class="opt-group-subtitle">Normalize the volume of the reference audio file.</div>
<div class="opt-group-body"> <div class="opt-group-body">
<div class="opt-controls"> <div class="opt-controls">
<div class="opt-field"><label>Target dBFS</label><input class="opt-target-db" type="number" step="0.5" value="-20"></div> <div class="opt-field"><label>Target dBFS</label><input class="opt-target-db" type="number" step="0.5" value="-20"></div>
<button class="btn-secondary opt-db-minus">-</button> <button class="btn-secondary opt-db-minus">-</button>
<button class="btn-secondary opt-db-plus">+</button> <button class="btn-secondary opt-db-plus">+</button>
<button class="btn-secondary opt-db-auto">Auto</button> <button class="btn-secondary opt-db-auto">Auto</button>
<button class="btn-secondary opt-restart-tts">Restart TTS</button> <button class="btn-secondary opt-restart-tts" title="Restarts the WAV-loading TTS backends (Voice Clone + Streaming) so they rescan the active_voices folder">Restart WAV engines</button>
<button class="btn-secondary opt-rebenchmark">Rebenchmark this one</button> <button class="btn-secondary opt-rebenchmark">Rebenchmark this one</button>
<span class="opt-restart-note" hidden>Restart TTS before benchmarking.</span> <span class="opt-restart-note" hidden>Restart TTS before benchmarking.</span>
</div> </div>
@ -4596,7 +4607,8 @@ function makeVoiceRow(v) {
</div> </div>
</div> </div>
`; `;
hydrateVoiceDuration(v, wrap.querySelector('.vr-length')); const vrLengthEl = wrap.querySelector('.vr-length');
hydrateVoiceDuration(v, vrLengthEl);
// Photo upload // Photo upload
const photoCell = wrap.querySelector('.vr-photo'); const photoCell = wrap.querySelector('.vr-photo');
@ -4640,7 +4652,7 @@ function makeVoiceRow(v) {
markVoiceAudioChanged(v); markVoiceAudioChanged(v);
dbValue.textContent = fmtDbfs(v); dbValue.textContent = fmtDbfs(v);
dbCell.title = v.loudness ? `avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : ''; dbCell.title = v.loudness ? `avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : '';
wrap.querySelector('.vr-length').textContent = fmtDuration(v.duration); if (vrLengthEl) vrLengthEl.textContent = fmtDuration(v.duration);
toast('Normalized: ' + v.id, 'success'); toast('Normalized: ' + v.id, 'success');
status(`Normalized ${v.id} to ${target} dBFS. Restart TTS before rebenchmarking.`); status(`Normalized ${v.id} to ${target} dBFS. Restart TTS before rebenchmarking.`);
} catch(e) { } catch(e) {
@ -4732,6 +4744,7 @@ function makeVoiceRow(v) {
// Inline optimizer in Library // Inline optimizer in Library
const editAudioBtn = wrap.querySelector('.edit-audio-btn'); const editAudioBtn = wrap.querySelector('.edit-audio-btn');
const optPanel = wrap.querySelector('.vr-optimizer'); const optPanel = wrap.querySelector('.vr-optimizer');
const vrTypeEl = wrap.querySelector('.vr-type');
const optCanvas = wrap.querySelector('.opt-wave'); const optCanvas = wrap.querySelector('.opt-wave');
const optStart = wrap.querySelector('.opt-start'); const optStart = wrap.querySelector('.opt-start');
const optEnd = wrap.querySelector('.opt-end'); const optEnd = wrap.querySelector('.opt-end');
@ -4771,8 +4784,8 @@ function makeVoiceRow(v) {
optState.buffer = null; optState.buffer = null;
optState.id = null; optState.id = null;
await loadOptimizer(); await loadOptimizer();
wrap.querySelector('.vr-length').textContent = fmtDuration(v.duration); if (vrLengthEl) vrLengthEl.textContent = fmtDuration(v.duration);
wrap.querySelector('.vr-length').title = String(v.duration ?? ''); if (vrLengthEl) vrLengthEl.title = String(v.duration ?? '');
dbValue.textContent = fmtDbfs(v); dbValue.textContent = fmtDbfs(v);
dbCell.title = v.loudness ? `avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : ''; dbCell.title = v.loudness ? `avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : '';
}; };
@ -4790,8 +4803,12 @@ function makeVoiceRow(v) {
}; };
const redrawOpt = () => { const redrawOpt = () => {
if (optState.buffer) drawOptimizerWave(optCanvas, optState.buffer, parseFloat(optStart.value)||0, parseFloat(optEnd.value)||optState.duration); if (!optState.buffer || optCanvas.clientWidth < 4) return;
drawOptimizerWave(optCanvas, optState.buffer, parseFloat(optStart.value)||0, parseFloat(optEnd.value)||optState.duration);
}; };
// Redraw whenever the canvas is resized (handles display:none → visible transition)
const _waveRO = new ResizeObserver(() => redrawOpt());
_waveRO.observe(optCanvas);
const syncCompareReferenceAudio = () => { const syncCompareReferenceAudio = () => {
if (!optState.id || !optCompareRefAudio) return; if (!optState.id || !optCompareRefAudio) return;
const src = '/api/audio/' + optState.id; const src = '/api/audio/' + optState.id;
@ -4897,6 +4914,7 @@ function makeVoiceRow(v) {
setOptStatus(v.needs_tts_restart ? 'Optimizer ready. Restart TTS before benchmarking this edit.' : 'Optimizer ready'); setOptStatus(v.needs_tts_restart ? 'Optimizer ready. Restart TTS before benchmarking this edit.' : 'Optimizer ready');
}; };
wrap._loadOptimizer = loadOptimizer; wrap._loadOptimizer = loadOptimizer;
wrap._redrawOpt = redrawOpt;
editAudioBtn.addEventListener('click', async () => { editAudioBtn.addEventListener('click', async () => {
editAudioBtn.disabled = true; editAudioBtn.disabled = true;
@ -4916,16 +4934,16 @@ function makeVoiceRow(v) {
} }
}); });
[optStart, optEnd].forEach(inp => inp.addEventListener('input', () => { redrawOpt(); syncCompareReferenceAudio(); })); [optStart, optEnd].forEach(inp => inp.addEventListener('input', () => { redrawOpt(); syncCompareReferenceAudio(); }));
optStyleInstruct.addEventListener('input', () => { optStyleInstruct?.addEventListener('input', () => {
if (!optStyleVoiceId.value.trim()) optStyleVoiceId.value = suggestedStyleVoiceId(v.id, optStyleInstruct.value); if (!optStyleVoiceId.value.trim()) optStyleVoiceId.value = suggestedStyleVoiceId(v.id, optStyleInstruct.value);
}); });
optStyleBackend.addEventListener('change', () => updateStyleBackendHelp(wrap)); optStyleBackend?.addEventListener('change', () => updateStyleBackendHelp(wrap));
optCompareBackend.addEventListener('change', () => setOptStatus(`Comparison backend: ${optCompareBackend.options[optCompareBackend.selectedIndex]?.textContent || optCompareBackend.value}`)); optCompareBackend.addEventListener('change', () => setOptStatus(`Comparison backend: ${optCompareBackend.options[optCompareBackend.selectedIndex]?.textContent || optCompareBackend.value}`));
if (optCompareBackend.value === '') { if (optCompareBackend.value === '') {
optCompareBackend.innerHTML = styleBackendOptions('voice_clone'); optCompareBackend.innerHTML = styleBackendOptions('voice_clone');
optCompareBackend.disabled = !availableTtsBackends().length; optCompareBackend.disabled = !availableTtsBackends().length;
} }
updateStyleBackendHelp(wrap); if (optStyleBackend) updateStyleBackendHelp(wrap);
wrap.querySelector('.opt-db-minus').addEventListener('click', () => { optTargetDb.value = (Number(optTargetDb.value || -20) - 1).toFixed(1); }); wrap.querySelector('.opt-db-minus').addEventListener('click', () => { optTargetDb.value = (Number(optTargetDb.value || -20) - 1).toFixed(1); });
wrap.querySelector('.opt-db-plus').addEventListener('click', () => { optTargetDb.value = (Number(optTargetDb.value || -20) + 1).toFixed(1); }); wrap.querySelector('.opt-db-plus').addEventListener('click', () => { optTargetDb.value = (Number(optTargetDb.value || -20) + 1).toFixed(1); });
wrap.querySelector('.opt-db-auto').addEventListener('click', () => { optTargetDb.value = '-20.0'; }); wrap.querySelector('.opt-db-auto').addEventListener('click', () => { optTargetDb.value = '-20.0'; });
@ -5048,23 +5066,22 @@ function makeVoiceRow(v) {
markVoiceAudioChanged(v); markVoiceAudioChanged(v);
refInput.value = v.transcript; refInput.title = v.transcript; refInput.value = v.transcript; refInput.title = v.transcript;
refTranscribeBtn.style.display = v.transcript ? 'none' : ''; refTranscribeBtn.style.display = v.transcript ? 'none' : '';
wrap.querySelector('.vr-type').textContent = voiceFileType(v).toUpperCase(); if (vrTypeEl) { vrTypeEl.textContent = voiceFileType(v).toUpperCase(); vrTypeEl.title = voiceFileType(v); }
wrap.querySelector('.vr-type').title = voiceFileType(v);
await refreshOptimizerFromVoice(); await refreshOptimizerFromVoice();
toast('Voice crop saved: ' + v.id, 'success'); toast('Voice crop saved: ' + v.id, 'success');
markTtsRestartRequired(saved.backup ? 'Crop saved and loaded. Restart TTS before rebenchmarking; undo is available.' : 'Crop saved and loaded. Restart TTS before rebenchmarking.'); markTtsRestartRequired(saved.backup ? 'Crop saved and loaded. Restart TTS before rebenchmarking; undo is available.' : 'Crop saved and loaded. Restart TTS before rebenchmarking.');
} catch(e) { toast('Save crop failed: ' + e.message, 'error'); setOptStatus('Save crop failed'); } } catch(e) { toast('Save crop failed: ' + e.message, 'error'); setOptStatus('Save crop failed'); }
}); });
if (optStyleInstruct) {
const styleVariationInput = () => { const styleVariationInput = () => {
const style = optStyleInstruct.value.trim(); const style = optStyleInstruct.value.trim();
const text = optTranscript.value.trim() || getBenchmarkSampleText(); const text = optTranscript.value.trim() || benchmarkSampleText();
const newId = optStyleVoiceId.value.trim() || suggestedStyleVoiceId(v.id, style); const newId = optStyleVoiceId.value.trim() || suggestedStyleVoiceId(v.id, style);
if (!style) { toast('Enter a style instruction first', 'error'); optStyleInstruct.focus(); return null; } if (!style) { toast('Enter a style instruction first', 'error'); optStyleInstruct.focus(); return null; }
if (!text) { toast('Enter reference text first', 'error'); optTranscript.focus(); return null; } if (!text) { toast('Enter reference text first', 'error'); optTranscript.focus(); return null; }
if (!/^[A-Za-z0-9_\-.]+$/.test(newId)) { toast('Invalid characters in new voice ID', 'error'); optStyleVoiceId.focus(); return null; } if (!/^[A-Za-z0-9_\-.]+$/.test(newId)) { toast('Invalid characters in new voice ID', 'error'); optStyleVoiceId.focus(); return null; }
return {style, text, newId, backend: optStyleBackend.value}; return {style, text, newId, backend: optStyleBackend.value};
}; };
optPreviewStyleBtn.addEventListener('click', async () => { optPreviewStyleBtn.addEventListener('click', async () => {
const input = styleVariationInput(); const input = styleVariationInput();
if (!input) return; if (!input) return;
@ -5084,7 +5101,6 @@ function makeVoiceRow(v) {
optPreviewStyleBtn.disabled = false; optPreviewStyleBtn.disabled = false;
} }
}); });
optSaveStyleBtn.addEventListener('click', async () => { optSaveStyleBtn.addEventListener('click', async () => {
const input = styleVariationInput(); const input = styleVariationInput();
if (!input) return; if (!input) return;
@ -5106,6 +5122,7 @@ function makeVoiceRow(v) {
optSaveStyleBtn.disabled = false; optSaveStyleBtn.disabled = false;
} }
}); });
}
wrap.querySelector('.opt-undo').addEventListener('click', async () => { wrap.querySelector('.opt-undo').addEventListener('click', async () => {
if (!confirm(`Restore the original backup for "${v.id}"?`)) return; if (!confirm(`Restore the original backup for "${v.id}"?`)) return;
@ -5120,8 +5137,7 @@ function makeVoiceRow(v) {
v.path = d.path || v.path; v.path = d.path || v.path;
v.file_type = d.file_type || v.file_type; v.file_type = d.file_type || v.file_type;
markVoiceAudioChanged(v); markVoiceAudioChanged(v);
wrap.querySelector('.vr-type').textContent = voiceFileType(v).toUpperCase(); if (vrTypeEl) { vrTypeEl.textContent = voiceFileType(v).toUpperCase(); vrTypeEl.title = voiceFileType(v); }
wrap.querySelector('.vr-type').title = voiceFileType(v);
await refreshOptimizerFromVoice(); await refreshOptimizerFromVoice();
toast('Original restored: ' + v.id, 'success'); toast('Original restored: ' + v.id, 'success');
markTtsRestartRequired('Original restored. Restart TTS before rebenchmarking.'); markTtsRestartRequired('Original restored. Restart TTS before rebenchmarking.');
@ -5149,17 +5165,19 @@ function makeVoiceRow(v) {
optRestartTtsBtn.addEventListener('click', async () => { optRestartTtsBtn.addEventListener('click', async () => {
optRestartTtsBtn.disabled = true; optRestartTtsBtn.disabled = true;
try { try {
setOptStatus('Restarting TTS so edited voices are rescanned...'); setOptStatus('Restarting WAV backends (Voice Clone + Streaming)…');
const r = await fetch('/api/tts/restart', { method:'POST' }); const r = await fetch('/api/tts/restart', { method:'POST' });
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json(); const d = await r.json();
_voices.forEach(voice => { voice.needs_tts_restart = false; }); _voices.forEach(voice => { voice.needs_tts_restart = false; });
updateLibraryInsights(); updateLibraryInsights();
setVoiceRestartState(false, `TTS restarted (${d.container || 'container'}). Rebenchmark now uses the edited voice.`); const names = (d.restarted || []).join(', ') || 'containers';
toast('TTS restarted. Voices rescanned.', 'success'); const errTxt = (d.errors || []).length ? ` (errors: ${d.errors.join('; ')})` : '';
setVoiceRestartState(false, `Restarted: ${names}${errTxt}. Rebenchmark now uses the edited voice.`);
toast(`TTS restarted: ${names}`, 'success');
} catch(e) { } catch(e) {
toast('Restart TTS failed: ' + e.message, 'error'); toast('Restart TTS failed: ' + e.message, 'error');
setOptStatus('Restart TTS failed'); setOptStatus('Restart TTS failed: ' + e.message);
} finally { } finally {
optRestartTtsBtn.disabled = false; optRestartTtsBtn.disabled = false;
} }
@ -5643,6 +5661,119 @@ $('save-preview-btn').addEventListener('click', () => {
}); });
// ── Performance benchmark ─────────────────────────────────────────────────
(function initPerfBenchmark() {
const perfBackendSel = $('perf-backend-select');
const perfVoiceSel = $('perf-voice-select');
const perfFetchBtn = $('perf-fetch-voices-btn');
const perfRunBtn = $('perf-run-btn');
const perfClearBtn = $('perf-clear-btn');
const perfProgress = $('perf-progress');
const perfResultsCard = $('perf-results-card');
const perfSummary = $('perf-summary');
const perfTbody = $('perf-tbody');
const perfText = $('perf-text');
const perfRunsSel = $('perf-runs');
if (!perfRunBtn) return;
let perfRows = [];
function populatePerfBackends() {
if (!perfBackendSel) return;
const cur = perfBackendSel.value;
perfBackendSel.innerHTML = availableTtsBackends().map(b =>
`<option value="${escHtml(b.id)}"${b.id===cur?' selected':''}>${escHtml(b.label)}</option>`
).join('') || '<option value="">No backends available</option>';
}
populatePerfBackends();
perfFetchBtn.addEventListener('click', async () => {
const backend = perfBackendSel.value;
if (!backend) { toast('Select a backend first', 'error'); return; }
perfFetchBtn.disabled = true;
try {
const rawVoices = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
const cur = perfVoiceSel.value;
perfVoiceSel.innerHTML = rawVoices.map(v => {
const id = backendVoiceId(v);
return `<option value="${escHtml(id)}"${id===cur?' selected':''}>${escHtml(id)}</option>`;
}).join('') || '<option value="">No voices</option>';
} catch(e) { toast('Fetch voices failed: '+e.message, 'error'); }
finally { perfFetchBtn.disabled = false; }
});
function renderPerfTable() {
if (!perfRows.length) { perfResultsCard.style.display='none'; return; }
perfResultsCard.style.display = '';
perfTbody.innerHTML = perfRows.map((r, i) => {
const rtf = r.audioDuration > 0 ? (r.latencyMs / 1000 / r.audioDuration).toFixed(2) : '—';
const ok = r.ok;
return `<tr class="${ok?'':'perf-row-error'}">
<td>${i+1}</td>
<td>${escHtml(r.backend)}</td>
<td>${escHtml(r.voice)}</td>
<td>${ok ? r.latencyMs : '—'}</td>
<td>${ok && r.audioDuration > 0 ? r.audioDuration.toFixed(2) : '—'}</td>
<td>${ok ? rtf : '—'}</td>
<td>${ok ? '<span class="perf-ok">OK</span>' : `<span class="perf-err">${escHtml(r.error||'Error')}</span>`}</td>
</tr>`;
}).join('');
const ok = perfRows.filter(r => r.ok);
if (ok.length) {
const avg = ok.reduce((s,r) => s + r.latencyMs, 0) / ok.length;
const min = Math.min(...ok.map(r => r.latencyMs));
const max = Math.max(...ok.map(r => r.latencyMs));
const avgRtf = ok.filter(r=>r.audioDuration>0).reduce((s,r)=>s+(r.latencyMs/1000/r.audioDuration),0) / Math.max(1,ok.filter(r=>r.audioDuration>0).length);
perfSummary.innerHTML = `
<span class="perf-stat"><strong>${Math.round(avg)} ms</strong> avg latency</span>
<span class="perf-stat"><strong>${min} ms</strong> best</span>
<span class="perf-stat"><strong>${max} ms</strong> worst</span>
<span class="perf-stat"><strong>${avgRtf.toFixed(2)}</strong> avg RTF</span>
<span class="perf-stat ${avgRtf < 1 ? 'perf-good' : 'perf-slow'}">${avgRtf < 1 ? '&#9989; Real-time capable' : '&#9888; Slower than real-time'}</span>
`;
} else { perfSummary.innerHTML = '<span class="perf-err">All runs failed</span>'; }
}
perfClearBtn.addEventListener('click', () => {
perfRows = [];
renderPerfTable();
perfProgress.style.display = 'none';
});
perfRunBtn.addEventListener('click', async () => {
const backend = perfBackendSel.value;
const voice = perfVoiceSel.value;
const text = perfText.value.trim();
const runs = parseInt(perfRunsSel.value) || 3;
if (!backend) { toast('Select a backend first', 'error'); return; }
if (!voice) { toast('Fetch and select a voice first', 'error'); return; }
if (!text) { toast('Enter sample text', 'error'); return; }
perfRunBtn.disabled = true;
perfProgress.style.display = '';
for (let i = 0; i < runs; i++) {
perfProgress.textContent = `Run ${i+1} / ${runs}`;
const row = { backend, voice, ok: false, latencyMs: 0, audioDuration: 0, error: '' };
try {
const t0 = performance.now();
const blob = await fetchTtsPreviewBlob(voice, text, 'wav', '', backend);
row.latencyMs = Math.round(performance.now() - t0);
row.ok = true;
try {
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const buf = await audioCtx.decodeAudioData(await blob.arrayBuffer());
row.audioDuration = buf.duration;
audioCtx.close();
} catch(_) {}
} catch(e) { row.error = e.message; }
perfRows.push(row);
renderPerfTable();
}
perfProgress.textContent = `Done — ${runs} run${runs>1?'s':''} completed.`;
perfRunBtn.disabled = false;
});
})();
// ── STT -> TTS ─────────────────────────────────────────────────────────── // ── STT -> TTS ───────────────────────────────────────────────────────────
let sttTtsSourceId = null; let sttTtsSourceId = null;
@ -6164,6 +6295,237 @@ loadSettings().then(() => {
})(); })();
loadVoiceLibrary().then(renderIntegrationSnippets).catch(e => status('Voice library load failed: ' + e.message)); loadVoiceLibrary().then(renderIntegrationSnippets).catch(e => status('Voice library load failed: ' + e.message));
// ── AI Backends section: category tabs ────────────────────────────────────
(function initLlmCatTabs() {
const tabs = document.querySelectorAll('.llm-cat-tab[data-llm-cat]');
const secs = document.querySelectorAll('.llm-section[data-llm-section]');
if (!tabs.length) return;
function showCat(cat) {
tabs.forEach(t => t.classList.toggle('active', t.dataset.llmCat === cat));
secs.forEach(s => { s.hidden = s.dataset.llmSection !== cat; });
if (cat === 'local') loadLocalContainers();
}
tabs.forEach(t => t.addEventListener('click', () => showCat(t.dataset.llmCat)));
// Load containers immediately since Local is the default active tab
loadLocalContainers();
})();
// ── Local Docker container management ─────────────────────────────────────
async function loadLocalContainers() {
const grid = $('dc-grid');
if (!grid) return;
grid.innerHTML = '<div class="dc-loading">Checking container status&hellip;</div>';
try {
const r = await fetch('/api/local-containers');
const d = await r.json();
renderLocalContainers(d.containers || []);
} catch (e) {
grid.innerHTML = `<div class="dc-loading dc-load-err">Could not reach server: ${escHtml(e.message)}</div>`;
}
}
function renderLocalContainers(containers) {
const grid = $('dc-grid');
if (!grid) return;
if (!containers.length) {
grid.innerHTML = '<div class="dc-loading">No containers defined.</div>';
return;
}
const ROLE_LABEL = { tts: 'TTS', stt: 'STT', 'stt+tts': 'STT · TTS', llm: 'LLM' };
const DC_ICONS = {
'faster-qwen3-tts-voiceclone': '🔊',
'faster-qwen3-tts-voicedesign': '✨',
'faster-qwen3-tts-customvoice': '🎭',
'faster-qwen3-tts-streaming': '⚡',
'parakeet-asr': '🦜',
'magpie-tts': '🐦',
'parakeet-rnnt-nim': '🦜',
};
const roleIcon = { tts: '🔊', stt: '🎙️', 'stt+tts': '🔄', llm: '🤖' };
grid.innerHTML = containers.map(c => {
const st = c.status || 'not_found';
const dotCls = st === 'running' ? 'dc-dot dc-running'
: st === 'exited' || st === 'stopped' ? 'dc-dot dc-stopped'
: 'dc-dot dc-absent';
const stLabel = st === 'running' ? 'Running'
: st === 'exited' ? 'Stopped'
: st === 'stopped' ? 'Stopped'
: st === 'not_found' ? 'Not installed'
: st;
const roleBadge = ROLE_LABEL[c.role] || c.role || '';
const portBadge = c.port ? `<span class="dc-port">:${c.port}</span>` : '';
const installed = st !== 'not_found';
const icon = DC_ICONS[c.name] || roleIcon[c.role] || '📦';
const n = escHtml(c.name);
const actions = installed
? (st === 'running'
? `<button class="btn-secondary dc-btn" data-dc-action="stop" data-dc-name="${n}">Stop</button>
<button class="btn-secondary dc-btn" data-dc-action="restart" data-dc-name="${n}">Restart</button>`
: `<button class="btn-primary dc-btn" data-dc-action="start" data-dc-name="${n}">Start</button>
<button class="btn-secondary dc-btn" data-dc-action="restart" data-dc-name="${n}">Restart</button>`)
: (c.repo
? `<a class="btn-secondary dc-btn" href="${escHtml(c.repo)}" target="_blank" rel="noopener">View on GitHub &#8599;</a>`
: '');
return `<div class="dc-card${st === 'running' ? ' dc-card-running' : ''}">
<div class="dc-card-head">
<span class="dc-icon">${icon}</span>
<span class="${dotCls}" title="${escHtml(stLabel)}"></span>
<span class="dc-name">${escHtml(c.label || c.name)}</span>
<span class="dc-badges">
${roleBadge ? `<span class="dc-role">${escHtml(roleBadge)}</span>` : ''}
${portBadge}
</span>
</div>
<div class="dc-status-label">${escHtml(stLabel)}</div>
${c.description ? `<p class="dc-desc">${escHtml(c.description)}</p>` : ''}
<div class="dc-actions">${actions}</div>
</div>`;
}).join('');
grid.querySelectorAll('.dc-btn[data-dc-action]').forEach(btn => {
btn.addEventListener('click', async () => {
const action = btn.dataset.dcAction;
const name = btn.dataset.dcName;
btn.disabled = true;
btn.textContent = action === 'start' ? 'Starting…' : 'Stopping…';
try {
const r = await fetch(`/api/local-containers/${encodeURIComponent(name)}/${action}`, { method: 'POST' });
const d = await r.json();
if (!d.ok) toast(d.error || `${action} failed`, 'error');
} catch (e) {
toast(`${action} failed: ${e.message}`, 'error');
}
await loadLocalContainers();
});
});
}
$('dc-refresh-btn')?.addEventListener('click', loadLocalContainers);
// ── AI Backends section: copy, API keys, local service connect ─────────────
(function initLlmsSection() {
// Copy buttons
document.querySelectorAll('.llm-copy-btn').forEach(btn => {
btn.addEventListener('click', () => {
const text = btn.dataset.copy || '';
const orig = btn.textContent;
const done = () => { btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = orig; }, 1500); };
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(done).catch(done);
} else {
const ta = document.createElement('textarea');
ta.value = text; ta.style.cssText = 'position:fixed;opacity:0';
document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
done();
}
});
});
// API key inputs — persist to localStorage, eye toggle, saved badge
document.querySelectorAll('.llm-input[data-llm-key]').forEach(inp => {
const key = inp.dataset.llmKey;
const saved = localStorage.getItem('llm-key-' + key);
if (saved) inp.value = saved;
const eye = document.createElement('button');
eye.type = 'button'; eye.className = 'llm-eye-btn'; eye.title = 'Show / hide';
eye.textContent = '👁';
inp.insertAdjacentElement('afterend', eye);
const badge = document.createElement('span');
badge.className = 'llm-saved-badge'; badge.textContent = 'Saved'; badge.hidden = true;
eye.insertAdjacentElement('afterend', badge);
eye.addEventListener('click', () => {
inp.type = inp.type === 'password' ? 'text' : 'password';
eye.classList.toggle('active', inp.type === 'text');
});
let t;
inp.addEventListener('input', () => {
clearTimeout(t);
t = setTimeout(() => {
if (inp.value) localStorage.setItem('llm-key-' + key, inp.value);
else localStorage.removeItem('llm-key-' + key);
badge.hidden = false;
setTimeout(() => { badge.hidden = true; }, 1800);
}, 600);
});
});
// Local service URL inputs + Connect / Disconnect
function normalizeProbeUrl(raw) {
// 0.0.0.0 is a bind address, not routable; from inside Docker use host.docker.internal
return raw.replace(/^(https?:\/\/)0\.0\.0\.0([\/:])/, '$1host.docker.internal$2');
}
async function probeUrl(rawUrl) {
const url = normalizeProbeUrl(rawUrl);
const r = await fetch('/api/probe-url?' + new URLSearchParams({ url }));
return r.json();
}
function applyCardState(card, key, connected, failed) {
card.classList.toggle('llm-local-card-online', connected);
card.classList.toggle('llm-local-card-offline', !connected && !!failed);
localStorage.setItem('llm-local-con-' + key, connected ? '1' : '0');
const btn = card.querySelector('.llm-local-ping');
if (!btn) return;
if (connected) {
btn.textContent = '✓ Disconnect'; btn.dataset.action = 'disconnect';
btn.className = 'llm-local-ping ok';
} else {
btn.textContent = 'Connect'; btn.dataset.action = 'connect';
btn.className = 'llm-local-ping';
}
}
document.querySelectorAll('[data-llm-local-key]').forEach(inp => {
const key = inp.dataset.llmLocalKey;
const card = inp.closest('.llm-local-card');
if (!card) return;
const btn = card.querySelector('.llm-local-ping');
const savedUrl = localStorage.getItem('llm-local-url-' + key);
if (savedUrl) inp.value = savedUrl;
inp.addEventListener('input', () => { localStorage.setItem('llm-local-url-' + key, inp.value); });
if (localStorage.getItem('llm-local-con-' + key) === '1') applyCardState(card, key, true, false);
if (!btn) return;
btn.addEventListener('click', async () => {
const action = btn.dataset.action || 'connect';
if (action === 'disconnect') { applyCardState(card, key, false, false); return; }
const rawUrl = inp.value.trim() || inp.placeholder;
if (!rawUrl) return;
btn.disabled = true;
btn.textContent = 'Connecting…';
try {
const d = await probeUrl(rawUrl);
applyCardState(card, key, d.ok, !d.ok);
if (!d.ok) toast('Cannot reach ' + normalizeProbeUrl(rawUrl) + ': ' + (d.error || 'No response'), 'error');
} catch (e) {
applyCardState(card, key, false, true);
toast('Probe failed: ' + e.message, 'error');
} finally {
btn.disabled = false;
}
});
});
})();
// ── Collapsible cards ────────────────────────────────────────────────────── // ── Collapsible cards ──────────────────────────────────────────────────────
(function initCollapsibleCards() { (function initCollapsibleCards() {
@ -6199,10 +6561,11 @@ loadVoiceLibrary().then(renderIntegrationSnippets).catch(e => status('Voice libr
h2.prepend(chev); h2.prepend(chev);
h2.classList.add('card-collapse-h2'); h2.classList.add('card-collapse-h2');
// Wrap every element after h2 in a single body div // Wrap every element after h2 (skipping .card-subtitle which stays visible) in body
const body = document.createElement('div'); const body = document.createElement('div');
body.className = 'card-col-body'; body.className = 'card-col-body';
let sib = h2.nextElementSibling; let sib = h2.nextElementSibling;
while (sib && sib.classList.contains('card-subtitle')) sib = sib.nextElementSibling;
while (sib) { const nx = sib.nextElementSibling; body.appendChild(sib); sib = nx; } while (sib) { const nx = sib.nextElementSibling; body.appendChild(sib); sib = nx; }
card.appendChild(body); card.appendChild(body);

View File

@ -19,6 +19,7 @@
<div class="tab" data-tab="custom" data-backend-required="customvoice" tabindex="-1"><span class="tab-title">Voice Custom</span></div> <div class="tab" data-tab="custom" data-backend-required="customvoice" tabindex="-1"><span class="tab-title">Voice Custom</span></div>
<div class="tab" data-tab="generation" data-backend-required="any_tts" tabindex="-1"><span class="tab-title">TTS Generation</span></div> <div class="tab" data-tab="generation" data-backend-required="any_tts" tabindex="-1"><span class="tab-title">TTS Generation</span></div>
<div class="tab" data-tab="stt-tts" data-backend-required="any_tts" tabindex="-1"><span class="tab-title">STT-TTS</span></div> <div class="tab" data-tab="stt-tts" data-backend-required="any_tts" tabindex="-1"><span class="tab-title">STT-TTS</span></div>
<div class="tab" data-tab="performance" data-backend-required="any_tts" tabindex="-1"><span class="tab-title">Performance</span></div>
<div class="tab" data-tab="routing" tabindex="-1"><span class="tab-title">Routing</span></div> <div class="tab" data-tab="routing" tabindex="-1"><span class="tab-title">Routing</span></div>
<div class="tab" data-tab="integrations" tabindex="-1"><span class="tab-title">How to</span></div> <div class="tab" data-tab="integrations" tabindex="-1"><span class="tab-title">How to</span></div>
<div class="tab" data-tab="getvoices" tabindex="-1"><span class="tab-title">Get Voices</span></div> <div class="tab" data-tab="getvoices" tabindex="-1"><span class="tab-title">Get Voices</span></div>

View File

@ -10,7 +10,7 @@
<div class="tab-content" id="tab-source"> <div class="tab-content" id="tab-source">
<div class="card"> <div class="card">
<h2>Step 1 &mdash; Load audio</h2> <h2>Step 1 &mdash; Load audio</h2>
<p class="note">Provide the audio you want to clone. Drop a file, paste a YouTube link, or record your microphone.</p> <p class="card-subtitle">Drop a file, paste a URL, or record directly from your microphone.</p>
<div id="drop-zone"> <div id="drop-zone">
<strong>Drop an audio / video file here</strong> <strong>Drop an audio / video file here</strong>
<span>WAV &middot; MP3 &middot; OGG &middot; FLAC &middot; M4A &middot; MP4 &middot; MKV &middot; WEBM</span> <span>WAV &middot; MP3 &middot; OGG &middot; FLAC &middot; M4A &middot; MP4 &middot; MKV &middot; WEBM</span>
@ -20,6 +20,7 @@
</div> </div>
<div class="card"> <div class="card">
<h2>YouTube / URL</h2> <h2>YouTube / URL</h2>
<p class="card-subtitle">Download audio from a direct link or YouTube video.</p>
<div class="url-row"> <div class="url-row">
<input type="text" id="yt-url" placeholder="https://www.youtube.com/watch?v=..."> <input type="text" id="yt-url" placeholder="https://www.youtube.com/watch?v=...">
<button class="btn-primary" id="yt-btn">Download</button> <button class="btn-primary" id="yt-btn">Download</button>
@ -28,6 +29,7 @@
</div> </div>
<div class="card"> <div class="card">
<h2>Microphone</h2> <h2>Microphone</h2>
<p class="card-subtitle">Record a fresh sample with live input level monitoring.</p>
<div class="btn-row"> <div class="btn-row">
<button class="btn-red" id="rec-start-btn">&#9679; Record</button> <button class="btn-red" id="rec-start-btn">&#9679; Record</button>
<button class="btn-secondary" id="rec-stop-btn" disabled>&#9632; Stop</button> <button class="btn-secondary" id="rec-stop-btn" disabled>&#9632; Stop</button>
@ -35,7 +37,8 @@
</div> </div>
</div> </div>
<div class="card" id="waveform-card" style="display:none"> <div class="card" id="waveform-card" style="display:none">
<h2>Step 2 &mdash; Trim selection <span class="note" style="font-weight:400;text-transform:none;letter-spacing:0">(3-20 seconds is ideal)</span></h2> <h2>Step 2 &mdash; Trim selection</h2>
<p class="card-subtitle">Select the best 320 s of clean speech from the waveform.</p>
<div id="waveform-wrap"><div id="waveform"></div></div> <div id="waveform-wrap"><div id="waveform"></div></div>
<div class="time-row"> <div class="time-row">
<label>Start (s) <input type="number" id="trim-start" value="0" min="0" step="0.1"></label> <label>Start (s) <input type="number" id="trim-start" value="0" min="0" step="0.1"></label>
@ -55,7 +58,7 @@
<div class="tab-content" id="tab-save"> <div class="tab-content" id="tab-save">
<div class="card"> <div class="card">
<h2>Step 3 &mdash; Name the voice</h2> <h2>Step 3 &mdash; Name the voice</h2>
<p class="note">Give the voice a unique ID in the format <code>LANG_GENDER_Name</code>, for example <code>EN_F_Anna</code>.</p> <p class="card-subtitle">Assign a unique ID, language, and gender — e.g. <code>EN_F_Anna</code>.</p>
<div class="voice-id-row"> <div class="voice-id-row">
<input type="text" id="voice-id-input" placeholder="e.g. EN_F_Anna or MyCustomVoice" spellcheck="false"> <input type="text" id="voice-id-input" placeholder="e.g. EN_F_Anna or MyCustomVoice" spellcheck="false">
</div> </div>
@ -90,7 +93,7 @@
</div> </div>
<div class="card"> <div class="card">
<h2>Transcript (reference text)</h2> <h2>Transcript (reference text)</h2>
<p class="note">Type what was spoken in the audio, or click Auto-transcribe to fill it automatically.</p> <p class="card-subtitle">The spoken text that matches this audio — type it or auto-transcribe.</p>
<div class="btn-row"> <div class="btn-row">
<button class="btn-secondary" id="transcribe-btn">&#128172; Auto-transcribe</button> <button class="btn-secondary" id="transcribe-btn">&#128172; Auto-transcribe</button>
<span id="transcribe-status" style="font-size:13px;color:var(--subtext)"></span> <span id="transcribe-status" style="font-size:13px;color:var(--subtext)"></span>
@ -99,11 +102,13 @@
</div> </div>
<div class="card"> <div class="card">
<h2>Audio preview</h2> <h2>Audio preview</h2>
<p class="card-subtitle">Play back the trimmed audio before saving to the library.</p>
<audio id="trim-audio" controls style="display:none"></audio> <audio id="trim-audio" controls style="display:none"></audio>
<p id="no-audio-hint" class="note">No audio loaded yet. Load a file above (Step 1) or use Voice Design below.</p> <p id="no-audio-hint" class="note">No audio loaded yet. Load a file above (Step 1) or use Voice Design below.</p>
</div> </div>
<div class="card"> <div class="card">
<h2>Step 4 &mdash; Save to library</h2> <h2>Step 4 &mdash; Save to library</h2>
<p class="card-subtitle">Write the cloned voice to your active voices folder.</p>
<div class="btn-row"> <div class="btn-row">
<button class="btn-green" id="save-btn">&#128190; Save to Voice Library</button> <button class="btn-green" id="save-btn">&#128190; Save to Voice Library</button>
</div> </div>

View File

@ -9,7 +9,7 @@
<div class="tab-content" id="tab-integrations"> <div class="tab-content" id="tab-integrations">
<div class="card"> <div class="card">
<h2>Use voices in other apps</h2> <h2>Use voices in other apps</h2>
<p class="note">The editor creates and manages the voice files. External apps should connect to the Creator proxy or a reachable TTS backend, then use one of the active voice names.</p> <p class="card-subtitle">The editor creates and manages the voice files. External apps should connect to the Creator proxy or a reachable TTS backend, then use one of the active voice names.</p>
<div class="integration-toolbar"> <div class="integration-toolbar">
<button class="btn-primary" id="show-api-btn" type="button">show api</button> <button class="btn-primary" id="show-api-btn" type="button">show api</button>
<button class="btn-secondary" id="integration-refresh-btn">Refresh examples</button> <button class="btn-secondary" id="integration-refresh-btn">Refresh examples</button>
@ -57,7 +57,7 @@
</div> </div>
<div class="card"> <div class="card">
<h2>Important after voice changes</h2> <h2>Important after voice changes</h2>
<p class="note">After enabling, hiding, adding, renaming, cropping, or normalising voices, restart the Qwen3-TTS container so its engine scans the updated <code>active_voices</code> folder. Then refresh the model or voice list in the target app.</p> <p class="card-subtitle">After enabling, hiding, adding, renaming, cropping, or normalising voices, restart the Qwen3-TTS container so its engine scans the updated <code>active_voices</code> folder. Then refresh the model or voice list in the target app.</p>
<p class="note">Virtual VoiceDesign voices are different: they use saved prompt presets through this app's proxy and do not need a WAV export or TTS-container rescan. They do need the <code>faster-qwen3-tts-voicedesign</code> container reachable from Settings.</p> <p class="note">Virtual VoiceDesign voices are different: they use saved prompt presets through this app's proxy and do not need a WAV export or TTS-container rescan. They do need the <code>faster-qwen3-tts-voicedesign</code> container reachable from Settings.</p>
</div> </div>
</div><!-- /tab-integrations --> </div><!-- /tab-integrations -->

View File

@ -23,7 +23,7 @@
<div class="card"> <div class="card">
<h2>Describe the voice</h2> <h2>Describe the voice</h2>
<p class="note">Describe the voice you want. Qwen3-TTS VoiceDesign will synthesise it from the description and sample text.</p> <p class="card-subtitle">Describe the voice you want. Qwen3-TTS VoiceDesign will synthesise it from the description and sample text.</p>
<div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px"> <div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px">
<div class="field"> <div class="field">
<label>Gender</label> <label>Gender</label>
@ -67,7 +67,7 @@
<div class="card"> <div class="card">
<h2>Reference transcript &amp; generate</h2> <h2>Reference transcript &amp; generate</h2>
<p class="note">This shared reference transcript is used by samples, prompt presets, generation, preview, download, and export to the Voice Clone Library.</p> <p class="card-subtitle">This shared reference transcript is used by samples, prompt presets, generation, preview, download, and export to the Voice Clone Library.</p>
<textarea id="design-sample-text" placeholder="The exact text the generated voice should speak...">Hello! This is a voice design sample. I hope you enjoy listening to me.</textarea> <textarea id="design-sample-text" placeholder="The exact text the generated voice should speak...">Hello! This is a voice design sample. I hope you enjoy listening to me.</textarea>
<div class="btn-row"> <div class="btn-row">
<button class="btn-teal" id="design-generate-btn" style="align-self:flex-end">&#10024; Generate voice</button> <button class="btn-teal" id="design-generate-btn" style="align-self:flex-end">&#10024; Generate voice</button>
@ -77,6 +77,7 @@
<div class="card" id="design-result" style="display:none"> <div class="card" id="design-result" style="display:none">
<h2>Generated voice</h2> <h2>Generated voice</h2>
<p class="card-subtitle">Listen to the generated voice, then export it to the Voice Clone Library or download the WAV.</p>
<audio id="design-audio" controls></audio> <audio id="design-audio" controls></audio>
<hr> <hr>
<h2>Export to Voice Clone Library</h2> <h2>Export to Voice Clone Library</h2>
@ -127,6 +128,7 @@
<div class="card"> <div class="card">
<h2>Voice Design prompt library</h2> <h2>Voice Design prompt library</h2>
<p class="card-subtitle">Save, load, and delete named voice description presets for quick reuse.</p>
<div class="preset-row"> <div class="preset-row">
<div class="field"> <div class="field">
<label>Preset</label> <label>Preset</label>
@ -143,7 +145,7 @@
<div class="tab-content" id="tab-custom"> <div class="tab-content" id="tab-custom">
<div class="card" style="border-color: rgba(37,99,235,.25);"> <div class="card" style="border-color: rgba(37,99,235,.25);">
<h2>Custom Voice (Qwen style control)</h2> <h2>Custom Voice (Qwen style control)</h2>
<p class="note">CustomVoice uses Qwen's configured premium/custom speakers. It is the best place to test style instructions when you can use one of the CustomVoice timbres.</p> <p class="card-subtitle">CustomVoice uses Qwen's configured premium/custom speakers. It is the best place to test style instructions when you can use one of the CustomVoice timbres.</p>
<div class="backend-help"> <div class="backend-help">
<strong>Best for style control over configured target timbres.</strong> <strong>Best for style control over configured target timbres.</strong>
<div class="backend-help-tags"> <div class="backend-help-tags">

View File

@ -2,28 +2,298 @@
<span class="section-icon">&#129302;</span> <span class="section-icon">&#129302;</span>
<div class="section-title"> <div class="section-title">
<h2>AI Backends</h2> <h2>AI Backends</h2>
<p>Connect cloud or local services for speech recognition, synthesis, and text generation.</p> <p>Connect local or cloud services for speech recognition, synthesis, and text generation.</p>
</div> </div>
</div> </div>
<!-- Category filter tabs --> <!-- Category tabs: Local / Online -->
<div class="llm-cat-tabs" id="llm-cat-tabs"> <div class="llm-cat-tabs" id="llm-cat-tabs">
<button class="llm-cat-tab active" data-llm-cat="stt">&#127908; ASR &middot; STT</button> <button class="llm-cat-tab active" data-llm-cat="local">&#128187; Local</button>
<button class="llm-cat-tab" data-llm-cat="tts">&#128266; TTS</button> <button class="llm-cat-tab" data-llm-cat="online">&#127760; Online APIs</button>
<button class="llm-cat-tab" data-llm-cat="llm">&#129302; LLM</button>
<button class="llm-cat-tab" data-llm-cat="local">&#128187; Local</button>
</div> </div>
<!-- ── STT section ─────────────────────────────────────────── --> <!-- ── LOCAL section ──────────────────────────────────────── -->
<div class="llm-section" id="llm-sec-stt" data-llm-section="stt"> <div class="llm-section" id="llm-sec-local" data-llm-section="local">
<div class="llm-sec-header"> <div class="llm-sec-header">
<div> <div>
<h3 class="llm-sec-title">ASR &middot; Speech-to-Text</h3> <h3 class="llm-sec-title">Local AI Services</h3>
<p class="llm-sec-note">Used for auto-transcribing reference audio. The app calls these when you click <em>Re-recognise text</em>.</p> <p class="llm-sec-note">Run everything on your own hardware &mdash; no API key, no rate limits, no data leaves your machine.</p>
</div>
<span class="llm-free-badge llm-free-local">100% Local</span>
</div>
<!-- ── Active Docker Stack ──────────────────────────────── -->
<div class="dc-section-head">
<div>
<h4 class="llm-local-cat" style="margin:0">Active Docker Stack</h4>
<p class="llm-sec-note" style="margin:2px 0 0">Start, stop, or install TTS and ASR containers running on this machine.</p>
</div>
<button class="btn-secondary" id="dc-refresh-btn" type="button">&#8635; Refresh</button>
</div>
<div id="dc-grid" class="dc-grid">
<div class="dc-loading">Checking container status&hellip;</div>
</div>
<!-- ── Language Models ──────────────────────────────────── -->
<h4 class="llm-local-cat" style="margin-top:28px">Language Models (LLM)</h4>
<div class="llm-local-grid">
<div class="llm-local-card">
<div class="llm-local-head">
<span class="llm-local-icon">🦙</span>
<span class="llm-local-name">Ollama</span>
<span class="llm-local-compat">OpenAI-compat</span>
</div>
<p class="llm-local-desc">Easiest local LLM setup. Pull any model with one command. Runs Llama, Mistral, Qwen, Phi, Gemma and many more.</p>
<div class="llm-local-url">
<span class="llm-local-url-label">URL</span>
<input class="llm-local-url-inp" type="text" placeholder="http://localhost:11434/v1" data-llm-local-key="ollama" data-llm-local-default="http://localhost:11434/v1" spellcheck="false">
<button class="llm-local-ping" data-ping-key="ollama" title="Test connection">Connect</button>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar">
<span>Quick start</span>
<button class="llm-copy-btn" data-copy="curl https://ollama.ai/install.sh | sh&#10;ollama pull llama3.3">Copy</button>
</div>
<pre>curl https://ollama.ai/install.sh | sh
ollama pull llama3.3</pre>
</div>
<a class="llm-local-link" href="https://ollama.ai" target="_blank" rel="noopener">ollama.ai &#8599;</a>
</div>
<div class="llm-local-card llm-local-card-running">
<div class="llm-local-head">
<span class="llm-local-icon"></span>
<span class="llm-local-name">vLLM</span>
<span class="llm-local-compat llm-compat-detected">Detected in stack</span>
</div>
<p class="llm-local-desc">High-throughput GPU inference. Already in your Docker stack. Serve any HuggingFace model with PagedAttention.</p>
<div class="llm-local-url">
<span class="llm-local-url-label">URL</span>
<input class="llm-local-url-inp" type="text" placeholder="http://localhost:8000/v1" data-llm-local-key="vllm" data-llm-local-default="http://localhost:8000/v1" spellcheck="false">
<button class="llm-local-ping" data-ping-key="vllm" title="Test connection">Connect</button>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar">
<span>Add a model to your stack</span>
<button class="llm-copy-btn" data-copy="--model Qwen/Qwen3-8B --served-model-name qwen3-8b">Copy</button>
</div>
<pre>--model Qwen/Qwen3-8B \
--served-model-name qwen3-8b</pre>
</div>
<a class="llm-local-link" href="https://docs.vllm.ai" target="_blank" rel="noopener">docs.vllm.ai &#8599;</a>
</div>
<div class="llm-local-card">
<div class="llm-local-head">
<span class="llm-local-icon">🎛️</span>
<span class="llm-local-name">LM Studio</span>
<span class="llm-local-compat">OpenAI-compat</span>
</div>
<p class="llm-local-desc">GUI app for running quantized models. Built-in model browser, chat UI, and a local server with OpenAI API.</p>
<div class="llm-local-url">
<span class="llm-local-url-label">URL</span>
<input class="llm-local-url-inp" type="text" placeholder="http://localhost:1234/v1" data-llm-local-key="lmstudio" data-llm-local-default="http://localhost:1234/v1" spellcheck="false">
<button class="llm-local-ping" data-ping-key="lmstudio" title="Test connection">Connect</button>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar"><span>Enable in LM Studio</span></div>
<pre>Developer tab → Start server → Port 1234
Check "Enable CORS" for browser access</pre>
</div>
<a class="llm-local-link" href="https://lmstudio.ai" target="_blank" rel="noopener">lmstudio.ai &#8599;</a>
</div>
<div class="llm-local-card">
<div class="llm-local-head">
<span class="llm-local-icon">🔷</span>
<span class="llm-local-name">llama.cpp</span>
<span class="llm-local-compat">OpenAI-compat</span>
</div>
<p class="llm-local-desc">Lightweight GGUF model server. Minimal RAM usage. Runs on CPU or GPU. Great for embedding in Docker.</p>
<div class="llm-local-url">
<span class="llm-local-url-label">URL</span>
<input class="llm-local-url-inp" type="text" placeholder="http://localhost:8080/v1" data-llm-local-key="llamacpp" data-llm-local-default="http://localhost:8080/v1" spellcheck="false">
<button class="llm-local-ping" data-ping-key="llamacpp" title="Test connection">Connect</button>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar">
<span>Docker one-liner</span>
<button class="llm-copy-btn" data-copy="docker run -p 8080:8080 ghcr.io/ggml-org/llama.cpp:server -hf QuantFactory/Meta-Llama-3-8B-GGUF -hff Meta-Llama-3-8B.Q4_K_M.gguf">Copy</button>
</div>
<pre>docker run -p 8080:8080 ghcr.io/ggml-org/llama.cpp:server \
-hf QuantFactory/Meta-Llama-3-8B-GGUF \
-hff Meta-Llama-3-8B.Q4_K_M.gguf</pre>
</div>
<a class="llm-local-link" href="https://github.com/ggml-org/llama.cpp" target="_blank" rel="noopener">github.com/ggml-org/llama.cpp &#8599;</a>
</div>
</div>
<!-- ── Speech Recognition ───────────────────────────────── -->
<h4 class="llm-local-cat" style="margin-top:24px">Speech Recognition (STT)</h4>
<div class="llm-local-grid">
<div class="llm-local-card">
<div class="llm-local-head">
<span class="llm-local-icon">🎙️</span>
<span class="llm-local-name">faster-whisper-server</span>
<span class="llm-local-compat">OpenAI-compat</span>
</div>
<p class="llm-local-desc">Drop-in local replacement for the Whisper API. GPU-accelerated via CTranslate2. OpenAI-compatible endpoint.</p>
<div class="llm-local-url">
<span class="llm-local-url-label">URL</span>
<input class="llm-local-url-inp" type="text" placeholder="http://localhost:8000/v1" data-llm-local-key="faster-whisper" data-llm-local-default="http://localhost:8000/v1" spellcheck="false">
<button class="llm-local-ping" data-ping-key="faster-whisper" title="Test connection">Connect</button>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar">
<span>docker-compose snippet</span>
<button class="llm-copy-btn" data-copy="services:&#10; whisper:&#10; image: fedirz/faster-whisper-server:latest-cuda&#10; ports: [&quot;8000:8000&quot;]&#10; environment:&#10; - WHISPER__MODEL=large-v3&#10; deploy:&#10; resources:&#10; reservations:&#10; devices: [{driver: nvidia, count: 1, capabilities: [gpu]}]">Copy</button>
</div>
<pre>services:
whisper:
image: fedirz/faster-whisper-server:latest-cuda
ports: ["8000:8000"]
environment:
- WHISPER__MODEL=large-v3
deploy:
resources:
reservations:
devices: [{driver: nvidia, count: 1, capabilities: [gpu]}]</pre>
</div>
<a class="llm-local-link" href="https://github.com/fedirz/faster-whisper-server" target="_blank" rel="noopener">github.com/fedirz/faster-whisper-server &#8599;</a>
</div>
<div class="llm-local-card">
<div class="llm-local-head">
<span class="llm-local-icon">🔉</span>
<span class="llm-local-name">whisper.cpp</span>
<span class="llm-local-compat">HTTP server</span>
</div>
<p class="llm-local-desc">Minimal C++ Whisper with a built-in HTTP server. CPU or Metal/CUDA. Low memory, fast on consumer hardware.</p>
<div class="llm-local-url">
<span class="llm-local-url-label">URL</span>
<input class="llm-local-url-inp" type="text" placeholder="http://localhost:8080" data-llm-local-key="whisper-cpp" data-llm-local-default="http://localhost:8080" spellcheck="false">
<button class="llm-local-ping" data-ping-key="whisper-cpp" title="Test connection">Connect</button>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar">
<span>Build &amp; run</span>
<button class="llm-copy-btn" data-copy="git clone https://github.com/ggml-org/whisper.cpp&#10;cd whisper.cpp && cmake -B build && cmake --build build -j&#10;./build/bin/whisper-server -m models/ggml-large-v3.bin --port 8080">Copy</button>
</div>
<pre>git clone https://github.com/ggml-org/whisper.cpp
cd whisper.cpp && cmake -B build && cmake --build build -j
./build/bin/whisper-server \
-m models/ggml-large-v3.bin --port 8080</pre>
</div>
<a class="llm-local-link" href="https://github.com/ggml-org/whisper.cpp" target="_blank" rel="noopener">github.com/ggml-org/whisper.cpp &#8599;</a>
</div>
</div>
<!-- ── Text-to-Speech ────────────────────────────────────── -->
<h4 class="llm-local-cat" style="margin-top:24px">Text-to-Speech (local TTS)</h4>
<div class="llm-local-grid">
<div class="llm-local-card">
<div class="llm-local-head">
<span class="llm-local-icon">🔊</span>
<span class="llm-local-name">Piper TTS</span>
<span class="llm-local-compat">Fast &middot; offline</span>
</div>
<p class="llm-local-desc">Lightning-fast offline TTS. Runs on CPU in real time. 50+ language voices available. Ideal for low-latency pipelines.</p>
<div class="llm-local-url">
<span class="llm-local-url-label">URL</span>
<input class="llm-local-url-inp" type="text" placeholder="localhost:10200" data-llm-local-key="piper" data-llm-local-default="localhost:10200" spellcheck="false">
<button class="llm-local-ping" data-ping-key="piper" title="Test connection">Connect</button>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar">
<span>Docker</span>
<button class="llm-copy-btn" data-copy="docker run -p 10200:10200 rhasspy/wyoming-piper --voice en_US-lessac-medium">Copy</button>
</div>
<pre>docker run -p 10200:10200 \
rhasspy/wyoming-piper \
--voice en_US-lessac-medium</pre>
</div>
<a class="llm-local-link" href="https://github.com/rhasspy/piper" target="_blank" rel="noopener">github.com/rhasspy/piper &#8599;</a>
</div>
<div class="llm-local-card">
<div class="llm-local-head">
<span class="llm-local-icon">🌸</span>
<span class="llm-local-name">Kokoro FastAPI</span>
<span class="llm-local-compat">OpenAI-compat TTS</span>
</div>
<p class="llm-local-desc">Kokoro-82M running behind an OpenAI-compatible TTS endpoint. Drop-in replacement for OpenAI&rsquo;s TTS API.</p>
<div class="llm-local-url">
<span class="llm-local-url-label">URL</span>
<input class="llm-local-url-inp" type="text" placeholder="http://localhost:8880/v1" data-llm-local-key="kokoro" data-llm-local-default="http://localhost:8880/v1" spellcheck="false">
<button class="llm-local-ping" data-ping-key="kokoro" title="Test connection">Connect</button>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar">
<span>Docker</span>
<button class="llm-copy-btn" data-copy="docker run -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.2">Copy</button>
</div>
<pre>docker run -p 8880:8880 \
ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.2
# GPU:
docker run -p 8880:8880 --gpus all \
ghcr.io/remsky/kokoro-fastapi-gpu:v0.2.2</pre>
</div>
<a class="llm-local-link" href="https://github.com/remsky/Kokoro-FastAPI" target="_blank" rel="noopener">github.com/remsky/Kokoro-FastAPI &#8599;</a>
</div>
<div class="llm-local-card">
<div class="llm-local-head">
<span class="llm-local-icon">🗣️</span>
<span class="llm-local-name">XTTS v2</span>
<span class="llm-local-compat">Voice cloning</span>
</div>
<p class="llm-local-desc">Coqui XTTS &mdash; multilingual voice cloning from a 6-second sample. 17 languages. Compatible with this app&rsquo;s voice library.</p>
<div class="llm-local-url">
<span class="llm-local-url-label">URL</span>
<input class="llm-local-url-inp" type="text" placeholder="http://localhost:8020/tts_to_audio" data-llm-local-key="xtts" data-llm-local-default="http://localhost:8020/tts_to_audio" spellcheck="false">
<button class="llm-local-ping" data-ping-key="xtts" title="Test connection">Connect</button>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar">
<span>Docker</span>
<button class="llm-copy-btn" data-copy="docker run -p 8020:80 --gpus all -v /voices:/voices daswer123/xtts-api-server:latest">Copy</button>
</div>
<pre>docker run -p 8020:80 --gpus all \
-v /voices:/voices \
daswer123/xtts-api-server:latest</pre>
</div>
<a class="llm-local-link" href="https://github.com/daswer123/xtts-api-server" target="_blank" rel="noopener">xtts-api-server &#8599;</a>
</div>
</div>
<div class="llm-local-howto">
<div class="llm-howto-icon">&#128161;</div>
<div class="llm-howto-body">
<strong>Adding a local service to this app</strong>
<p>All OpenAI-compatible services work the same way: open <em>App Routing</em> in the sidebar, add a new backend entry with the local URL, leave the API key field empty, and set it as the default backend for the voice type you want.</p>
</div>
</div>
</div><!-- /local -->
<!-- ── ONLINE section ─────────────────────────────────────── -->
<div class="llm-section" id="llm-sec-online" data-llm-section="online" hidden>
<div class="llm-sec-header">
<div>
<h3 class="llm-sec-title">Online API Services</h3>
<p class="llm-sec-note">Cloud STT, TTS, and LLM APIs. All providers below have a free tier you can start with immediately.</p>
</div> </div>
<span class="llm-free-badge">Free tiers available</span> <span class="llm-free-badge">Free tiers available</span>
</div> </div>
<!-- ── ASR / STT ─────────────────────────────────────────── -->
<h4 class="llm-local-cat" style="margin-bottom:12px">&#127908; ASR &middot; Speech-to-Text</h4>
<div class="llm-service-grid"> <div class="llm-service-grid">
<div class="llm-card llm-card-highlight"> <div class="llm-card llm-card-highlight">
@ -107,18 +377,9 @@
</div> </div>
</div> </div>
</div><!-- /stt -->
<!-- ── TTS section ─────────────────────────────────────────── -->
<div class="llm-section" id="llm-sec-tts" data-llm-section="tts" hidden>
<div class="llm-sec-header">
<div>
<h3 class="llm-sec-title">Text-to-Speech</h3>
<p class="llm-sec-note">Cloud TTS backends you can add as routing targets alongside your local cloned voices.</p>
</div>
<span class="llm-free-badge">Free tiers available</span>
</div>
<!-- ── TTS ──────────────────────────────────────────────── -->
<h4 class="llm-local-cat" style="margin-top:28px;margin-bottom:12px">&#128266; Text-to-Speech</h4>
<div class="llm-service-grid"> <div class="llm-service-grid">
<div class="llm-card llm-card-highlight"> <div class="llm-card llm-card-highlight">
@ -193,18 +454,9 @@
</div> </div>
</div> </div>
</div><!-- /tts -->
<!-- ── LLM section ─────────────────────────────────────────── -->
<div class="llm-section" id="llm-sec-llm" data-llm-section="llm" hidden>
<div class="llm-sec-header">
<div>
<h3 class="llm-sec-title">Large Language Models</h3>
<p class="llm-sec-note">Use LLMs to generate text for TTS, write scripts, clean transcripts, or power agentic voice pipelines.</p>
</div>
<span class="llm-free-badge">Free tiers available</span>
</div>
<!-- ── LLM ──────────────────────────────────────────────── -->
<h4 class="llm-local-cat" style="margin-top:28px;margin-bottom:12px">&#129302; Large Language Models</h4>
<div class="llm-service-grid"> <div class="llm-service-grid">
<div class="llm-card llm-card-highlight"> <div class="llm-card llm-card-highlight">
@ -323,312 +575,5 @@
</div> </div>
</div> </div>
</div><!-- /llm --> </div><!-- /online -->
<!-- ── Local section ───────────────────────────────────────── -->
<div class="llm-section" id="llm-sec-local" data-llm-section="local" hidden>
<div class="llm-sec-header">
<div>
<h3 class="llm-sec-title">Local AI Services</h3>
<p class="llm-sec-note">Run everything on your own hardware &mdash; no API key, no rate limits, no data leaves your machine.</p>
</div>
<span class="llm-free-badge llm-free-local">100% Local</span>
</div>
<!-- Local LLMs -->
<h4 class="llm-local-cat">Language Models (LLM)</h4>
<div class="llm-local-grid">
<div class="llm-local-card">
<div class="llm-local-head">
<span class="llm-local-name">Ollama</span>
<span class="llm-local-compat">OpenAI-compat</span>
</div>
<p class="llm-local-desc">Easiest local LLM setup. Pull any model with one command. Runs Llama, Mistral, Qwen, Phi, Gemma and many more.</p>
<div class="llm-local-url">
<span>Default URL</span>
<code>http://localhost:11434/v1</code>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar">
<span>Quick start</span>
<button class="llm-copy-btn" data-copy="curl https://ollama.ai/install.sh | sh&#10;ollama pull llama3.3">Copy</button>
</div>
<pre>curl https://ollama.ai/install.sh | sh
ollama pull llama3.3</pre>
</div>
<a class="llm-local-link" href="https://ollama.ai" target="_blank" rel="noopener">ollama.ai &#8599;</a>
</div>
<div class="llm-local-card llm-local-card-running">
<div class="llm-local-head">
<span class="llm-local-name">vLLM &#9889;</span>
<span class="llm-local-compat llm-compat-detected">Detected in stack</span>
</div>
<p class="llm-local-desc">High-throughput GPU inference. Already in your Docker stack. Serve any HuggingFace model with PagedAttention.</p>
<div class="llm-local-url">
<span>Default URL</span>
<code>http://localhost:8000/v1</code>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar">
<span>Add a model to your stack</span>
<button class="llm-copy-btn" data-copy="--model Qwen/Qwen3-8B --served-model-name qwen3-8b">Copy</button>
</div>
<pre>--model Qwen/Qwen3-8B \
--served-model-name qwen3-8b</pre>
</div>
<a class="llm-local-link" href="https://docs.vllm.ai" target="_blank" rel="noopener">docs.vllm.ai &#8599;</a>
</div>
<div class="llm-local-card">
<div class="llm-local-head">
<span class="llm-local-name">LM Studio</span>
<span class="llm-local-compat">OpenAI-compat</span>
</div>
<p class="llm-local-desc">GUI app for running quantized models. Built-in model browser, chat UI, and a local server with OpenAI API.</p>
<div class="llm-local-url">
<span>Default URL</span>
<code>http://localhost:1234/v1</code>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar"><span>Enable in LM Studio</span></div>
<pre>Developer tab → Start server → Port 1234
Check "Enable CORS" for browser access</pre>
</div>
<a class="llm-local-link" href="https://lmstudio.ai" target="_blank" rel="noopener">lmstudio.ai &#8599;</a>
</div>
<div class="llm-local-card">
<div class="llm-local-head">
<span class="llm-local-name">llama.cpp</span>
<span class="llm-local-compat">OpenAI-compat</span>
</div>
<p class="llm-local-desc">Lightweight GGUF model server. Minimal RAM usage. Runs on CPU or GPU. Great for embedding in Docker.</p>
<div class="llm-local-url">
<span>Default URL</span>
<code>http://localhost:8080/v1</code>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar">
<span>Docker one-liner</span>
<button class="llm-copy-btn" data-copy="docker run -p 8080:8080 ghcr.io/ggml-org/llama.cpp:server -hf QuantFactory/Meta-Llama-3-8B-GGUF -hff Meta-Llama-3-8B.Q4_K_M.gguf">Copy</button>
</div>
<pre>docker run -p 8080:8080 ghcr.io/ggml-org/llama.cpp:server \
-hf QuantFactory/Meta-Llama-3-8B-GGUF \
-hff Meta-Llama-3-8B.Q4_K_M.gguf</pre>
</div>
<a class="llm-local-link" href="https://github.com/ggml-org/llama.cpp" target="_blank" rel="noopener">github.com/ggml-org/llama.cpp &#8599;</a>
</div>
</div>
<!-- Local STT -->
<h4 class="llm-local-cat" style="margin-top:24px">Speech Recognition (STT)</h4>
<div class="llm-local-grid">
<div class="llm-local-card">
<div class="llm-local-head">
<span class="llm-local-name">faster-whisper-server</span>
<span class="llm-local-compat">OpenAI-compat</span>
</div>
<p class="llm-local-desc">Drop-in local replacement for the Whisper API. GPU-accelerated via CTranslate2. OpenAI-compatible endpoint.</p>
<div class="llm-local-url">
<span>Default URL</span>
<code>http://localhost:8000/v1</code>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar">
<span>docker-compose snippet</span>
<button class="llm-copy-btn" data-copy="services:&#10; whisper:&#10; image: fedirz/faster-whisper-server:latest-cuda&#10; ports: [&quot;8000:8000&quot;]&#10; environment:&#10; - WHISPER__MODEL=large-v3&#10; deploy:&#10; resources:&#10; reservations:&#10; devices: [{driver: nvidia, count: 1, capabilities: [gpu]}]">Copy</button>
</div>
<pre>services:
whisper:
image: fedirz/faster-whisper-server:latest-cuda
ports: ["8000:8000"]
environment:
- WHISPER__MODEL=large-v3
deploy:
resources:
reservations:
devices: [{driver: nvidia, count: 1, capabilities: [gpu]}]</pre>
</div>
<a class="llm-local-link" href="https://github.com/fedirz/faster-whisper-server" target="_blank" rel="noopener">github.com/fedirz/faster-whisper-server &#8599;</a>
</div>
<div class="llm-local-card">
<div class="llm-local-head">
<span class="llm-local-name">whisper.cpp</span>
<span class="llm-local-compat">HTTP server</span>
</div>
<p class="llm-local-desc">Minimal C++ Whisper with a built-in HTTP server. CPU or Metal/CUDA. Low memory, fast on consumer hardware.</p>
<div class="llm-local-url">
<span>Default URL</span>
<code>http://localhost:8080</code>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar">
<span>Build &amp; run</span>
<button class="llm-copy-btn" data-copy="git clone https://github.com/ggml-org/whisper.cpp&#10;cd whisper.cpp && cmake -B build && cmake --build build -j&#10;./build/bin/whisper-server -m models/ggml-large-v3.bin --port 8080">Copy</button>
</div>
<pre>git clone https://github.com/ggml-org/whisper.cpp
cd whisper.cpp && cmake -B build && cmake --build build -j
./build/bin/whisper-server \
-m models/ggml-large-v3.bin --port 8080</pre>
</div>
<a class="llm-local-link" href="https://github.com/ggml-org/whisper.cpp" target="_blank" rel="noopener">github.com/ggml-org/whisper.cpp &#8599;</a>
</div>
</div>
<!-- Local TTS -->
<h4 class="llm-local-cat" style="margin-top:24px">Text-to-Speech (local TTS)</h4>
<div class="llm-local-grid">
<div class="llm-local-card">
<div class="llm-local-head">
<span class="llm-local-name">Piper TTS</span>
<span class="llm-local-compat">Fast · offline</span>
</div>
<p class="llm-local-desc">Lightning-fast offline TTS. Runs on CPU in real time. 50+ language voices available. Ideal for low-latency pipelines.</p>
<div class="llm-local-url">
<span>Wyoming protocol port</span>
<code>localhost:10200</code>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar">
<span>Docker</span>
<button class="llm-copy-btn" data-copy="docker run -p 10200:10200 rhasspy/wyoming-piper --voice en_US-lessac-medium">Copy</button>
</div>
<pre>docker run -p 10200:10200 \
rhasspy/wyoming-piper \
--voice en_US-lessac-medium</pre>
</div>
<a class="llm-local-link" href="https://github.com/rhasspy/piper" target="_blank" rel="noopener">github.com/rhasspy/piper &#8599;</a>
</div>
<div class="llm-local-card">
<div class="llm-local-head">
<span class="llm-local-name">Kokoro FastAPI</span>
<span class="llm-local-compat">OpenAI-compat TTS</span>
</div>
<p class="llm-local-desc">Kokoro-82M running behind an OpenAI-compatible TTS endpoint. Drop-in replacement for OpenAI&rsquo;s TTS API.</p>
<div class="llm-local-url">
<span>Default URL</span>
<code>http://localhost:8880/v1/audio/speech</code>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar">
<span>Docker</span>
<button class="llm-copy-btn" data-copy="docker run -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.2">Copy</button>
</div>
<pre>docker run -p 8880:8880 \
ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.2
# GPU:
docker run -p 8880:8880 --gpus all \
ghcr.io/remsky/kokoro-fastapi-gpu:v0.2.2</pre>
</div>
<a class="llm-local-link" href="https://github.com/remsky/Kokoro-FastAPI" target="_blank" rel="noopener">github.com/remsky/Kokoro-FastAPI &#8599;</a>
</div>
<div class="llm-local-card">
<div class="llm-local-head">
<span class="llm-local-name">XTTS v2</span>
<span class="llm-local-compat">Voice cloning</span>
</div>
<p class="llm-local-desc">Coqui XTTS — multilingual voice cloning from a 6-second sample. 17 languages. Compatible with this app&rsquo;s voice library.</p>
<div class="llm-local-url">
<span>API endpoint</span>
<code>http://localhost:8020/tts_to_audio</code>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar">
<span>Docker</span>
<button class="llm-copy-btn" data-copy="docker run -p 8020:80 --gpus all -v /voices:/voices daswer123/xtts-api-server:latest">Copy</button>
</div>
<pre>docker run -p 8020:80 --gpus all \
-v /voices:/voices \
daswer123/xtts-api-server:latest</pre>
</div>
<a class="llm-local-link" href="https://github.com/daswer123/xtts-api-server" target="_blank" rel="noopener">xtts-api-server &#8599;</a>
</div>
</div>
<!-- Generic how-to tip -->
<div class="llm-local-howto">
<div class="llm-howto-icon">&#128161;</div>
<div class="llm-howto-body">
<strong>Adding a local service to this app</strong>
<p>All OpenAI-compatible services work the same way: open <em>App Routing</em> in the sidebar, add a new backend entry with the local URL as base endpoint, and leave the API key field empty (or enter any string — it&rsquo;s ignored by local servers). Set it as the default backend for the voice type you want (clone, design, or tryout) and the app will start routing requests to it immediately.</p>
</div>
</div>
</div><!-- /local -->
<script>
(function () {
// ── Category tabs ──────────────────────────────────────────
var tabs = document.querySelectorAll('.llm-cat-tab');
tabs.forEach(function (tab) {
tab.addEventListener('click', function () {
var cat = tab.dataset.llmCat;
tabs.forEach(function (t) { t.classList.toggle('active', t === tab); });
document.querySelectorAll('[data-llm-section]').forEach(function (sec) {
sec.hidden = sec.dataset.llmSection !== cat;
});
});
});
// ── Copy buttons ───────────────────────────────────────────
document.querySelectorAll('.llm-copy-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var text = btn.dataset.copy.replace(/&#10;/g, '\n');
navigator.clipboard?.writeText(text).then(function () {
var orig = btn.textContent;
btn.textContent = '✓ Copied';
setTimeout(function () { btn.textContent = orig; }, 1600);
});
});
});
// ── API key inputs: persist + show/hide toggle + saved badge ──
document.querySelectorAll('[data-llm-key]').forEach(function (inp) {
var k = 'llm-key-' + inp.dataset.llmKey;
var stored = localStorage.getItem(k) || '';
inp.value = stored;
// Insert show/hide toggle
var eyeBtn = document.createElement('button');
eyeBtn.type = 'button';
eyeBtn.className = 'llm-eye-btn';
eyeBtn.title = 'Show / hide key';
eyeBtn.textContent = '👁';
eyeBtn.addEventListener('click', function () {
inp.type = inp.type === 'password' ? 'text' : 'password';
eyeBtn.classList.toggle('active', inp.type === 'text');
});
inp.after(eyeBtn);
// Saved badge in parent card head
var card = inp.closest('.llm-card');
var savedBadge = card && card.querySelector('.llm-saved-badge');
if (!savedBadge && card) {
savedBadge = document.createElement('span');
savedBadge.className = 'llm-saved-badge';
savedBadge.textContent = '✓ Key saved';
var head = card.querySelector('.llm-card-head');
if (head) head.appendChild(savedBadge);
}
function updateBadge() {
if (savedBadge) savedBadge.hidden = !inp.value.trim();
}
updateBadge();
inp.addEventListener('input', function () {
localStorage.setItem(k, inp.value.trim());
updateBadge();
});
});
})();
</script>

View File

@ -9,6 +9,7 @@
<div class="tab-content" id="tab-routing"> <div class="tab-content" id="tab-routing">
<div class="card"> <div class="card">
<h2>TTS voice routing</h2> <h2>TTS voice routing</h2>
<p class="card-subtitle">Map incoming app voice names to real cloned voices and backends. Rules are matched top-to-bottom by app name, voice, and detected language.</p>
<div class="routing-help"> <div class="routing-help">
Recommended OpenAI-compatible TTS base URL: <code id="routing-proxy-url"></code>. If an app asks for the full speech endpoint instead of a base URL, use <code>/v1/audio/speech</code>. Recommended OpenAI-compatible TTS base URL: <code id="routing-proxy-url"></code>. If an app asks for the full speech endpoint instead of a base URL, use <code>/v1/audio/speech</code>.
Rules can turn an incoming app voice such as <code>default</code> into a real cloned voice before the request is sent to Qwen3-TTS. Rules can turn an incoming app voice such as <code>default</code> into a real cloned voice before the request is sent to Qwen3-TTS.
@ -50,7 +51,7 @@
<div class="routing-sound-browser-head"> <div class="routing-sound-browser-head">
<div> <div>
<h2>Sound browser</h2> <h2>Sound browser</h2>
<p class="note" id="routing-sound-browser-note">Preview uploaded route sounds, then apply one as a before or after sound.</p> <p class="card-subtitle" id="routing-sound-browser-note">Preview uploaded route sounds, then apply one as a before or after sound.</p>
</div> </div>
<div class="routing-sound-browser-actions"> <div class="routing-sound-browser-actions">
<button class="btn-secondary" id="routing-sound-refresh-btn" type="button">Refresh sounds</button> <button class="btn-secondary" id="routing-sound-refresh-btn" type="button">Refresh sounds</button>
@ -65,7 +66,7 @@
<div class="routing-log-head"> <div class="routing-log-head">
<div> <div>
<h2>Routing log</h2> <h2>Routing log</h2>
<p class="note">Recent route tests and proxy requests. This log is kept in memory and resets when the server restarts.</p> <p class="card-subtitle">Recent route tests and proxy requests. This log is kept in memory and resets when the server restarts.</p>
</div> </div>
<div class="routing-log-actions"> <div class="routing-log-actions">
<select id="routing-log-filter" class="routing-log-filter" title="Filter routing log entries"> <select id="routing-log-filter" class="routing-log-filter" title="Filter routing log entries">

View File

@ -11,7 +11,7 @@
<div class="settings-title-row"> <div class="settings-title-row">
<div> <div>
<h2>Settings</h2> <h2>Settings</h2>
<p class="note">Configure the service URLs you actually use first. Advanced payloads, folders, and keys are tucked away below.</p> <p class="card-subtitle">Configure the service URLs you actually use first. Advanced payloads, folders, and keys are tucked away below.</p>
</div> </div>
<div class="settings-status-pill">Local stack</div> <div class="settings-status-pill">Local stack</div>
</div> </div>

View File

@ -9,6 +9,7 @@
<div class="tab-content" id="tab-getvoices"> <div class="tab-content" id="tab-getvoices">
<div class="card"> <div class="card">
<h2>Get voices</h2> <h2>Get voices</h2>
<p class="card-subtitle">Scrape public voice clip sources, filter by language or gender, then import any direct audio file into your Clone library.</p>
<div class="getvoices-source-editor"> <div class="getvoices-source-editor">
<textarea id="getvoices-sources" spellcheck="false" aria-label="Voice source URLs"></textarea> <textarea id="getvoices-sources" spellcheck="false" aria-label="Voice source URLs"></textarea>
<div class="getvoices-source-buttons"> <div class="getvoices-source-buttons">

View File

@ -10,10 +10,11 @@
<div class="tab-content" id="tab-generation"> <div class="tab-content" id="tab-generation">
<div class="card"> <div class="card">
<h2>TTS generation playground</h2> <h2>TTS generation playground</h2>
<p class="note">Pick any reachable TTS backend, fetch its voices, then synthesize text. WAV/NVIDIA clone backends preserve reference identity; instruction-control backends follow style better.</p> <p class="card-subtitle">Pick any reachable TTS backend, fetch its voices, then synthesize text. WAV/NVIDIA clone backends preserve reference identity; instruction-control backends follow style better.</p>
</div> </div>
<div class="card"> <div class="card">
<h2>Generate speech</h2> <h2>Generate speech</h2>
<p class="card-subtitle">Select a backend, fetch its voice list, then synthesize any text with optional style instruction.</p>
<div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px"> <div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px">
<div class="field"> <div class="field">
<label>Backend</label> <label>Backend</label>
@ -78,10 +79,11 @@
<div class="tab-content" id="tab-stt-tts"> <div class="tab-content" id="tab-stt-tts">
<div class="card"> <div class="card">
<h2>STT &#8594; TTS workspace</h2> <h2>STT &#8594; TTS workspace</h2>
<p class="note">Upload speech audio, transcribe it with the configured STT endpoint, then synthesize the resulting text with any available TTS backend.</p> <p class="card-subtitle">Upload speech audio, transcribe it with the configured STT endpoint, then synthesize the resulting text with any available TTS backend.</p>
</div> </div>
<div class="card"> <div class="card">
<h2>Source speech</h2> <h2>Source speech</h2>
<p class="card-subtitle">Record from your microphone or upload an audio file, then transcribe it.</p>
<div class="settings-grid"> <div class="settings-grid">
<div class="s-field"> <div class="s-field">
<label>Speech recognition</label> <label>Speech recognition</label>
@ -118,6 +120,7 @@
</div> </div>
<div class="card"> <div class="card">
<h2>Synthesize transcription</h2> <h2>Synthesize transcription</h2>
<p class="card-subtitle">Choose a TTS backend and voice, then generate audio from the transcribed text.</p>
<div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px"> <div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px">
<div class="field"> <div class="field">
<label>Backend</label> <label>Backend</label>
@ -155,3 +158,58 @@
<audio id="stt-tts-output-audio" controls style="display:none"></audio> <audio id="stt-tts-output-audio" controls style="display:none"></audio>
</div> </div>
</div><!-- /tab-stt-tts --> </div><!-- /tab-stt-tts -->
<!-- Performance benchmarking -->
<div class="tab-content" id="tab-performance">
<div class="card">
<h2>Performance benchmark</h2>
<p class="card-subtitle">Measure synthesis latency and real-time factor for any backend and voice.</p>
<div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px">
<div class="field">
<label>Backend</label>
<select id="perf-backend-select"><option value="">Checking backends...</option></select>
</div>
<div class="field">
<label>Voice</label>
<div style="display:flex;gap:8px">
<button class="btn-secondary" id="perf-fetch-voices-btn">Fetch voices</button>
<select id="perf-voice-select"><option value="">— select after fetch —</option></select>
</div>
</div>
<div class="field">
<label>Runs</label>
<select id="perf-runs">
<option value="1">1 run</option>
<option value="3" selected>3 runs</option>
<option value="5">5 runs</option>
<option value="10">10 runs</option>
</select>
</div>
</div>
<div class="field">
<label>Sample text</label>
<textarea id="perf-text" rows="3">Hello, how are you today? Please read this sample clearly for a fair voice benchmark.</textarea>
</div>
<div class="btn-row">
<button class="btn-primary" id="perf-run-btn">&#9654; Run benchmark</button>
<button class="btn-secondary" id="perf-clear-btn">Clear results</button>
</div>
<div id="perf-progress" class="note" style="display:none"></div>
</div>
<div class="card" id="perf-results-card" style="display:none">
<h2>Results</h2>
<p class="card-subtitle">Latency per run. RTF = synthesis time / audio duration (lower is better).</p>
<div class="perf-summary" id="perf-summary"></div>
<div class="perf-table-wrap">
<table class="perf-table" id="perf-table">
<thead>
<tr>
<th>#</th><th>Backend</th><th>Voice</th>
<th>Latency (ms)</th><th>Audio (s)</th><th>RTF</th><th>Status</th>
</tr>
</thead>
<tbody id="perf-tbody"></tbody>
</table>
</div>
</div>
</div><!-- /tab-performance -->

View File

@ -1163,27 +1163,47 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.inspector-body .optimizer-grid { display: contents; } .inspector-body .optimizer-grid { display: contents; }
.inspector-body .opt-status { display: none !important; } .inspector-body .opt-status { display: none !important; }
/* Each opt-group as a clean collapsible card */ /* Each opt-group as a collapsible card — same visual language as .card */
.inspector-body .opt-group { .inspector-body .opt-group {
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
/* no overflow:hidden here — it clips title text at rounded corners */
} }
/* Title row — mirrors .card-collapse-h2 */
.inspector-body .opt-group-title { .inspector-body .opt-group-title {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
padding: 10px 10px; font-size: 14px; font-weight: 700; letter-spacing: .05em; padding: 12px 14px; font-size: 11px; font-weight: 700; letter-spacing: .08em;
text-transform: uppercase; color: var(--subtext); text-transform: uppercase; color: var(--subtext);
cursor: pointer; user-select: none; cursor: pointer; user-select: none; transition: background .15s;
transition: background .15s; border-radius: calc(var(--radius) - 1px); /* match inner corner */
} }
.inspector-body .opt-group-title:hover { background: var(--border); } .inspector-body .opt-group-title:hover { background: var(--panel); }
.opt-chevron { .inspector-body .opt-group.open > .opt-group-title {
margin-left: auto; font-size: 14px; line-height: 1; color: var(--subtext); border-bottom: 1px solid var(--border);
transition: transform .2s; display: inline-block; flex-shrink: 0; border-bottom-left-radius: 0; border-bottom-right-radius: 0;
} }
.inspector-body .opt-group.open > .opt-group-title { border-bottom: 1px solid var(--border); }
.inspector-body .opt-group.open .opt-chevron { transform: none; }
.inspector-body .opt-group:not(.open) .opt-chevron { transform: rotate(-90deg); }
/* Body: smooth height transition */ /* Chevron — identical to .card-chev */
.opt-chevron {
display: inline-block; flex-shrink: 0; width: 16px; height: 16px;
border-radius: 4px; background: var(--panel); border: 1px solid var(--border);
color: var(--accent); font-size: 10px; line-height: 16px; text-align: center;
transition: transform .2s;
}
.opt-chevron::before { content: '▾'; }
.inspector-body .opt-group:not(.open) .opt-chevron { transform: rotate(-90deg); }
.inspector-body .opt-group.open .opt-chevron { transform: none; }
/* Title text fills the rest of the row */
.opt-title-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Subtitle — visible only when expanded, indented to align with title text */
.inspector-body .opt-group-subtitle {
font-size: 12px; color: var(--subtext); padding: 0 14px 10px 38px; line-height: 1.5;
}
.inspector-body .opt-group:not(.open) > .opt-group-subtitle { display: none; }
/* Body — hidden when collapsed */
.opt-group-body { overflow: hidden; } .opt-group-body { overflow: hidden; }
.inspector-body .opt-group:not(.open) > .opt-group-body { display: none; } .inspector-body .opt-group:not(.open) > .opt-group-body { display: none; }
.opt-group-footer { display: flex; justify-content: flex-end; padding: 8px 14px 12px; } .opt-group-footer { display: flex; justify-content: flex-end; padding: 8px 14px 12px; }
@ -1381,19 +1401,22 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
padding-bottom: 7px; border-bottom: 1px solid var(--border); padding-bottom: 7px; border-bottom: 1px solid var(--border);
} }
.llm-local-grid { .llm-local-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); gap: 12px; display: grid; grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); gap: 12px;
margin-bottom: 6px; margin-bottom: 6px;
} }
.llm-local-card { .llm-local-card {
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
padding: 14px 16px; display: flex; flex-direction: column; gap: 9px; padding: 14px 16px; display: flex; flex-direction: column; gap: 9px;
transition: box-shadow .2s, border-color .2s; transition: box-shadow .2s, border-color .2s, background .2s;
} }
.llm-local-card:hover { box-shadow: 0 4px 16px rgba(13,148,136,.12); border-color: rgba(13,148,136,.35); } .llm-local-card:hover { box-shadow: 0 4px 16px rgba(13,148,136,.12); border-color: rgba(13,148,136,.35); }
.llm-local-card-running { border-color: var(--teal); background: rgba(13,148,136,.025); } .llm-local-card-running { border-color: var(--teal); background: rgba(13,148,136,.025); }
.llm-local-card-online { border-color: rgba(22,163,74,.6); background: rgba(22,163,74,.04); }
.llm-local-card-offline { border-color: rgba(220,38,38,.4); background: rgba(220,38,38,.03); }
.llm-local-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .llm-local-head { display: flex; align-items: center; gap: 8px; }
.llm-local-name { font-size: 13.5px; font-weight: 700; color: var(--text); } .llm-local-icon { font-size: 20px; flex-shrink: 0; width: 26px; text-align: center; line-height: 1; }
.llm-local-name { font-size: 13.5px; font-weight: 700; color: var(--text); flex: 1; min-width: 0; }
.llm-local-compat { .llm-local-compat {
font-size: 10px; font-weight: 600; color: var(--subtext); font-size: 10px; font-weight: 600; color: var(--subtext);
background: var(--panel); border: 1px solid var(--border); background: var(--panel); border: 1px solid var(--border);
@ -1405,11 +1428,33 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.llm-local-desc { font-size: 12px; color: var(--subtext); line-height: 1.5; margin: 0; } .llm-local-desc { font-size: 12px; color: var(--subtext); line-height: 1.5; margin: 0; }
.llm-local-url { .llm-local-url {
display: flex; align-items: center; gap: 7px; flex-wrap: wrap; display: flex; align-items: center; gap: 7px; flex-wrap: nowrap;
font-size: 14px; font-weight: 600; color: var(--subtext); font-size: 12px; font-weight: 600; color: var(--subtext);
background: var(--panel); padding: 4px 9px; border-radius: 5px; border: 1px solid var(--border); background: var(--panel); padding: 4px 9px; border-radius: 5px; border: 1px solid var(--border);
transition: border-color .15s;
} }
.llm-local-url:focus-within { border-color: var(--accent); }
.llm-local-url code { font-family: monospace; font-size: 14px; color: var(--teal); } .llm-local-url code { font-family: monospace; font-size: 14px; color: var(--teal); }
.llm-local-url-label { white-space: nowrap; flex-shrink: 0; }
.llm-local-url-inp {
flex: 1; min-width: 0; font-family: monospace; font-size: 13px; color: var(--teal);
background: transparent; border: none; outline: none; padding: 0;
}
.llm-local-url-inp::placeholder { color: var(--teal); opacity: .55; }
.llm-local-ping {
flex-shrink: 0; font-size: 10px; padding: 2px 9px; border-radius: 4px;
border: 1px solid var(--border); background: transparent; cursor: pointer;
color: var(--subtext); font-family: inherit; transition: border-color .12s, color .12s, background .12s;
white-space: nowrap;
}
.llm-local-ping:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
.llm-local-ping:disabled { opacity: .55; cursor: default; }
.llm-local-ping.ok {
border-color: var(--green); color: var(--green); background: rgba(22,163,74,.07);
font-weight: 600;
}
.llm-local-ping.ok:hover { background: rgba(220,38,38,.07); border-color: var(--red); color: var(--red); }
.llm-local-ping.err { border-color: var(--red); color: var(--red); }
/* Code snippet block with dark theme */ /* Code snippet block with dark theme */
.llm-local-snippet { background: #1b1f2e; border-radius: 8px; overflow: hidden; } .llm-local-snippet { background: #1b1f2e; border-radius: 8px; overflow: hidden; }
@ -1524,3 +1569,71 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.el-pager-info { flex: 1; text-align: center; font-size: 12px; color: var(--subtext); } .el-pager-info { flex: 1; text-align: center; font-size: 12px; color: var(--subtext); }
/* Bottom action buttons */ /* Bottom action buttons */
/* ── Card subtitle & opt-group subtitle ─────────────────────────────────── */
.card-subtitle { font-size: 12px; color: var(--subtext); line-height: 1.5; margin: 2px 0 6px; }
.opt-group-subtitle { font-size: 11px; color: var(--subtext); padding: 0 14px 6px; line-height: 1.4; }
/* ── Docker container grid ──────────────────────────────────────────────── */
.dc-section-head {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 12px; margin: 0 0 12px;
}
.dc-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(310px, 1fr));
gap: 12px;
margin-bottom: 8px;
}
.dc-loading {
grid-column: 1 / -1; padding: 18px 0; text-align: center;
font-size: 13px; color: var(--subtext);
}
.dc-load-err { color: var(--red) !important; }
.dc-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 14px 16px;
display: flex; flex-direction: column; gap: 6px;
transition: border-color .15s, box-shadow .15s;
}
.dc-card:hover { box-shadow: var(--shadow); border-color: rgba(37,99,235,.3); }
.dc-card-running { border-color: rgba(22,163,74,.45); background: rgba(22,163,74,.025); }
.dc-card-head {
display: flex; align-items: center; gap: 8px; margin-bottom: 2px;
}
.dc-icon { font-size: 20px; flex-shrink: 0; width: 26px; text-align: center; line-height: 1; }
.dc-dot {
width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0;
}
.dc-running { background: var(--green); box-shadow: 0 0 0 3px rgba(22,163,74,.18); }
.dc-stopped { background: var(--yellow); }
.dc-absent { background: var(--border); }
.dc-name { font-size: 13px; font-weight: 700; color: var(--text); flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dc-badges { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
.dc-role {
font-size: 10px; font-weight: 700; padding: 1px 5px; border-radius: 3px;
background: var(--accent); color: #fff; white-space: nowrap;
}
.dc-port {
font-size: 10px; font-weight: 600; padding: 1px 5px; border-radius: 3px;
background: var(--panel); border: 1px solid var(--border); color: var(--subtext);
font-family: monospace;
}
.dc-status-label { font-size: 11px; color: var(--subtext); margin-bottom: 2px; }
.dc-desc { font-size: 12px; color: var(--subtext); line-height: 1.45; margin: 0; flex: 1; }
.dc-actions { margin-top: 6px; display: flex; gap: 6px; flex-wrap: wrap; }
.dc-btn { font-size: 12px; padding: 4px 12px; }
/* ── Performance benchmark ──────────────────────────────────────────────── */
.perf-table-wrap { overflow-x: auto; margin-top: 12px; }
.perf-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.perf-table th, .perf-table td { padding: 7px 10px; border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; }
.perf-table th { color: var(--subtext); font-size: 11px; text-transform: uppercase; letter-spacing: .05em; }
.perf-row-error td { color: var(--subtext); }
.perf-ok { color: var(--green); font-weight: 600; }
.perf-err { color: var(--red); }
.perf-summary { display: flex; flex-wrap: wrap; gap: 10px 18px; padding: 10px 0 4px; font-size: 13px; }
.perf-stat { display: flex; align-items: center; gap: 5px; }
.perf-good { color: var(--green); }
.perf-slow { color: var(--yellow); }