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:
parent
04b82e49d8
commit
90cc7b7eb1
@ -10,15 +10,23 @@ services:
|
||||
ports:
|
||||
- "7890:7890"
|
||||
|
||||
# Give the app user access to the Docker socket (GID of docker group on host)
|
||||
group_add:
|
||||
- "${DOCKER_GID:-988}"
|
||||
|
||||
volumes:
|
||||
- ${VOICE_HOST_DIR:-./voices}:/voices:rw
|
||||
- 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:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- VOICES_DIR=/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
|
||||
|
||||
extra_hosts:
|
||||
|
||||
385
server.py
385
server.py
@ -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_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_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_TARGET_DBFS = float(os.environ.get("VOICE_TARGET_DBFS", "-20.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
|
||||
|
||||
|
||||
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]:
|
||||
sock_path = os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")
|
||||
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}
|
||||
|
||||
|
||||
# ── 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) ────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/download-yt")
|
||||
@ -2599,6 +2752,77 @@ def _normalize_voice_source_urls(urls: list[str] | None = None) -> list[str]:
|
||||
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:
|
||||
lower = url.lower()
|
||||
if "aiartes.com/voiceai" in lower:
|
||||
@ -2613,6 +2837,8 @@ def _scrape_voice_source_url(url: str) -> dict:
|
||||
return _scrape_freesound_scott_simpson()
|
||||
if "lanceblairvo.com/raw-voiceover-samples" in lower:
|
||||
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):
|
||||
return _direct_audio_source(url)
|
||||
return _generic_audio_page_source(url)
|
||||
@ -2916,30 +3142,159 @@ def _clear_tts_restart_flags(settings: dict | None = None) -> int:
|
||||
cleared += 1
|
||||
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")
|
||||
async def restart_tts_container():
|
||||
container = os.environ.get("TTS_CONTAINER_NAME", _TTS_CONTAINER).strip()
|
||||
if not container:
|
||||
raise HTTPException(400, "TTS_CONTAINER_NAME is not configured")
|
||||
containers = _tts_container_names()
|
||||
if not containers:
|
||||
raise HTTPException(400, "No TTS container names configured (set TTS_CONTAINER_NAMES in docker-compose.yml)")
|
||||
|
||||
path = f"/containers/{quote(container, safe='')}/restart?t=10"
|
||||
try:
|
||||
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):
|
||||
detail = raw.split("\r\n\r\n", 1)[-1].strip() or f"Docker API returned HTTP {code}"
|
||||
raise HTTPException(502, detail)
|
||||
results = []
|
||||
errors = []
|
||||
for container in containers:
|
||||
path = f"/containers/{quote(container, safe='')}/restart?t=10"
|
||||
try:
|
||||
code, raw = _docker_post(path)
|
||||
if code not in (204, 304):
|
||||
detail = raw.split("\r\n\r\n", 1)[-1].strip() or f"HTTP {code}"
|
||||
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
|
||||
try:
|
||||
cleared = _clear_tts_restart_flags()
|
||||
except Exception as 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")
|
||||
|
||||
571
static/app.js
571
static/app.js
@ -104,10 +104,10 @@ function selectVoice(wrap) {
|
||||
</div>
|
||||
<div class="insp-hd-divider"></div>
|
||||
<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>
|
||||
<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>
|
||||
<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;
|
||||
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]);
|
||||
createSearchablePicker(flagSpan, items, applyFlag, {
|
||||
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 langWrap = inspector.querySelector('.insp-lang-wrap');
|
||||
|
||||
@ -220,7 +220,7 @@ function selectVoice(wrap) {
|
||||
if (langCodeEl) langCodeEl.textContent = 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]);
|
||||
createSearchablePicker(langWrap, items, applyLang, {
|
||||
placeholder: 'Language…',
|
||||
@ -357,14 +357,16 @@ function selectVoice(wrap) {
|
||||
|
||||
const maintTitle = body.querySelector('.opt-maintenance .opt-group-title');
|
||||
if (maintTitle) {
|
||||
maintTitle.innerHTML = `Loudness <span class="opt-group-meta">Current ${escHtml(dbfs)} dBFS</span> <span class="opt-chevron">⌄</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 ────────────────────────────────────────────────
|
||||
body.querySelectorAll('.opt-group').forEach(group => {
|
||||
const title = group.querySelector(':scope > .opt-group-title');
|
||||
if (!title) return;
|
||||
title.addEventListener('click', () => group.classList.toggle('open'));
|
||||
title.addEventListener('click', () => {
|
||||
group.classList.toggle('open');
|
||||
});
|
||||
});
|
||||
|
||||
// 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 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 canGetVoice = !!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 canGetVoice = !!(item.import_url || item.audio_url);
|
||||
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 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>` : '';
|
||||
@ -963,32 +966,24 @@ function setLibAddSourcePreview(meta = {}) {
|
||||
|
||||
async function getSourceVoiceInLibrary(meta) {
|
||||
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 gender = sourceGenderCode(meta.gender, meta.name);
|
||||
if ($('lib-add-url')) $('lib-add-url').value = meta.url;
|
||||
if ($('lib-add-lang')) $('lib-add-lang').value = lang;
|
||||
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'}...`);
|
||||
const voiceId = suggestedVoiceIdFromSourceName(meta.name, lang, gender);
|
||||
toast(`Importing ${meta.name || 'voice'}…`, 'info');
|
||||
try {
|
||||
const r = await fetch('/api/import-source-audio', {
|
||||
const r = await fetch('/api/quick-import-voice', {
|
||||
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); }
|
||||
const d = await r.json();
|
||||
loadLibAddAudio(d.id, d.duration, meta.name || 'Source voice');
|
||||
setLibAddStatus(`Source voice loaded: ${meta.name || 'external voice'}${meta.imageUrl ? ' (image will attach on save)' : ''}`);
|
||||
toast('Voice imported into Voice Clone', 'success');
|
||||
_pendingSelectId = d.voice_id;
|
||||
navTo('s-voices');
|
||||
_libraryLoadPromise = null;
|
||||
await loadVoiceLibrary();
|
||||
toast(`Saved as ${d.voice_id}`, 'success');
|
||||
} catch(e) {
|
||||
setLibAddStatus('Source import failed');
|
||||
toast('Source import failed: ' + e.message, 'error');
|
||||
toast('Import failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@ -1852,6 +1847,12 @@ async function refreshTtsBackendAvailability(selected = '') {
|
||||
sel.innerHTML = styleBackendOptions(prev);
|
||||
sel.disabled = !availableTtsBackends().length;
|
||||
});
|
||||
const perfSel = $('perf-backend-select');
|
||||
if (perfSel) {
|
||||
const prev = perfSel.value;
|
||||
perfSel.innerHTML = ttsBackendOptions(prev);
|
||||
perfSel.disabled = !availableTtsBackends().length;
|
||||
}
|
||||
updateBackendHelp();
|
||||
updateStyleBackendHelp();
|
||||
updateBackendDependentTabs();
|
||||
@ -2797,6 +2798,7 @@ $('save-btn').addEventListener('click', async () => {
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
let _voices = [];
|
||||
let _pendingSelectId = null;
|
||||
let _sortField = 'id';
|
||||
let _sortDir = 1; // 1 = asc, -1 = desc
|
||||
let _libraryIssueFilter = '';
|
||||
@ -4251,6 +4253,10 @@ function renderVoiceList() {
|
||||
return;
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
@ -4506,7 +4512,8 @@ function makeVoiceRow(v) {
|
||||
<div class="vr-optimizer">
|
||||
<div class="optimizer-grid">
|
||||
<div class="opt-group opt-trim-panel">
|
||||
<div class="opt-group-title">Reference audio · crop <span class="opt-chevron">⌄</span></div>
|
||||
<div class="opt-group-title"><span class="opt-chevron"></span><span class="opt-title-text">Reference audio · crop</span></div>
|
||||
<div class="opt-group-subtitle">Crop the saved WAV to 3–20 s of clean speech.</div>
|
||||
<div class="opt-group-body">
|
||||
<canvas class="opt-wave"></canvas>
|
||||
<div class="opt-controls">
|
||||
@ -4522,7 +4529,8 @@ function makeVoiceRow(v) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="opt-group opt-text-panel open">
|
||||
<div class="opt-group-title">Reference transcript <span class="opt-chevron">⌄</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">
|
||||
<textarea class="opt-transcript" placeholder="Reference text">${escHtml(v.transcript || '')}</textarea>
|
||||
<div class="opt-controls">
|
||||
@ -4534,7 +4542,8 @@ function makeVoiceRow(v) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="opt-group opt-compare-panel">
|
||||
<div class="opt-group-title">Voice match <span class="opt-chevron">⌄</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-controls">
|
||||
<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 class="opt-group opt-style-panel">
|
||||
<div class="opt-group-title">Style variation <span class="opt-chevron">⌄</span></div>
|
||||
${!isClone ? `<div class="opt-group opt-style-panel">
|
||||
<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-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 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>
|
||||
<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="opt-style-preview-box">
|
||||
<div class="opt-controls">
|
||||
@ -4574,16 +4584,17 @@ function makeVoiceRow(v) {
|
||||
<button class="btn-primary opt-save-style">Save style variation</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
<div class="opt-group opt-maintenance open">
|
||||
<div class="opt-group-title">Loudness <span class="opt-chevron">⌄</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-controls">
|
||||
<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-plus">+</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>
|
||||
<span class="opt-restart-note" hidden>Restart TTS before benchmarking.</span>
|
||||
</div>
|
||||
@ -4596,7 +4607,8 @@ function makeVoiceRow(v) {
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
hydrateVoiceDuration(v, wrap.querySelector('.vr-length'));
|
||||
const vrLengthEl = wrap.querySelector('.vr-length');
|
||||
hydrateVoiceDuration(v, vrLengthEl);
|
||||
|
||||
// Photo upload
|
||||
const photoCell = wrap.querySelector('.vr-photo');
|
||||
@ -4640,7 +4652,7 @@ function makeVoiceRow(v) {
|
||||
markVoiceAudioChanged(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' : ''}` : '';
|
||||
wrap.querySelector('.vr-length').textContent = fmtDuration(v.duration);
|
||||
if (vrLengthEl) vrLengthEl.textContent = fmtDuration(v.duration);
|
||||
toast('Normalized: ' + v.id, 'success');
|
||||
status(`Normalized ${v.id} to ${target} dBFS. Restart TTS before rebenchmarking.`);
|
||||
} catch(e) {
|
||||
@ -4732,6 +4744,7 @@ function makeVoiceRow(v) {
|
||||
// Inline optimizer in Library
|
||||
const editAudioBtn = wrap.querySelector('.edit-audio-btn');
|
||||
const optPanel = wrap.querySelector('.vr-optimizer');
|
||||
const vrTypeEl = wrap.querySelector('.vr-type');
|
||||
const optCanvas = wrap.querySelector('.opt-wave');
|
||||
const optStart = wrap.querySelector('.opt-start');
|
||||
const optEnd = wrap.querySelector('.opt-end');
|
||||
@ -4771,8 +4784,8 @@ function makeVoiceRow(v) {
|
||||
optState.buffer = null;
|
||||
optState.id = null;
|
||||
await loadOptimizer();
|
||||
wrap.querySelector('.vr-length').textContent = fmtDuration(v.duration);
|
||||
wrap.querySelector('.vr-length').title = String(v.duration ?? '');
|
||||
if (vrLengthEl) vrLengthEl.textContent = fmtDuration(v.duration);
|
||||
if (vrLengthEl) vrLengthEl.title = String(v.duration ?? '');
|
||||
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' : ''}` : '';
|
||||
};
|
||||
@ -4790,8 +4803,12 @@ function makeVoiceRow(v) {
|
||||
};
|
||||
|
||||
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 = () => {
|
||||
if (!optState.id || !optCompareRefAudio) return;
|
||||
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');
|
||||
};
|
||||
wrap._loadOptimizer = loadOptimizer;
|
||||
wrap._redrawOpt = redrawOpt;
|
||||
|
||||
editAudioBtn.addEventListener('click', async () => {
|
||||
editAudioBtn.disabled = true;
|
||||
@ -4916,16 +4934,16 @@ function makeVoiceRow(v) {
|
||||
}
|
||||
});
|
||||
[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);
|
||||
});
|
||||
optStyleBackend.addEventListener('change', () => updateStyleBackendHelp(wrap));
|
||||
optStyleBackend?.addEventListener('change', () => updateStyleBackendHelp(wrap));
|
||||
optCompareBackend.addEventListener('change', () => setOptStatus(`Comparison backend: ${optCompareBackend.options[optCompareBackend.selectedIndex]?.textContent || optCompareBackend.value}`));
|
||||
if (optCompareBackend.value === '') {
|
||||
optCompareBackend.innerHTML = styleBackendOptions('voice_clone');
|
||||
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-plus').addEventListener('click', () => { optTargetDb.value = (Number(optTargetDb.value || -20) + 1).toFixed(1); });
|
||||
wrap.querySelector('.opt-db-auto').addEventListener('click', () => { optTargetDb.value = '-20.0'; });
|
||||
@ -5048,64 +5066,63 @@ function makeVoiceRow(v) {
|
||||
markVoiceAudioChanged(v);
|
||||
refInput.value = v.transcript; refInput.title = v.transcript;
|
||||
refTranscribeBtn.style.display = v.transcript ? 'none' : '';
|
||||
wrap.querySelector('.vr-type').textContent = voiceFileType(v).toUpperCase();
|
||||
wrap.querySelector('.vr-type').title = voiceFileType(v);
|
||||
if (vrTypeEl) { vrTypeEl.textContent = voiceFileType(v).toUpperCase(); vrTypeEl.title = voiceFileType(v); }
|
||||
await refreshOptimizerFromVoice();
|
||||
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.');
|
||||
} catch(e) { toast('Save crop failed: ' + e.message, 'error'); setOptStatus('Save crop failed'); }
|
||||
});
|
||||
const styleVariationInput = () => {
|
||||
const style = optStyleInstruct.value.trim();
|
||||
const text = optTranscript.value.trim() || getBenchmarkSampleText();
|
||||
const newId = optStyleVoiceId.value.trim() || suggestedStyleVoiceId(v.id, style);
|
||||
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 (!/^[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};
|
||||
};
|
||||
|
||||
optPreviewStyleBtn.addEventListener('click', async () => {
|
||||
const input = styleVariationInput();
|
||||
if (!input) return;
|
||||
optPreviewStyleBtn.disabled = true;
|
||||
try {
|
||||
setOptStatus('Synthesizing style preview...');
|
||||
const blob = await fetchTtsPreviewBlob(v.id, input.text, 'wav', input.style, input.backend);
|
||||
if (optStyleAudio.src) URL.revokeObjectURL(optStyleAudio.src);
|
||||
optStyleAudio.src = URL.createObjectURL(blob);
|
||||
optStyleAudio.style.display = '';
|
||||
await optStyleAudio.play().catch(()=>{});
|
||||
setOptStatus('Style preview ready. If it sounds right, save it as a new voice.');
|
||||
} catch(e) {
|
||||
toast('Style preview failed: ' + e.message, 'error');
|
||||
setOptStatus('Style preview failed');
|
||||
} finally {
|
||||
optPreviewStyleBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
optSaveStyleBtn.addEventListener('click', async () => {
|
||||
const input = styleVariationInput();
|
||||
if (!input) return;
|
||||
optSaveStyleBtn.disabled = true;
|
||||
try {
|
||||
setOptStatus(`Synthesizing style variation ${input.newId}...`);
|
||||
const r = await fetch('/api/tts-style-variation', {method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({source_voice:v.id, voice_id:input.newId, text:input.text, instruct:input.style, backend:input.backend})});
|
||||
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
||||
const d = await r.json();
|
||||
toast('Style variation saved: ' + d.voice_id, 'success');
|
||||
setOptStatus('Style variation saved. Restart TTS so the backend scans the new voice.');
|
||||
await loadVoiceLibrary();
|
||||
renderIntegrationSnippets();
|
||||
} catch(e) {
|
||||
toast('Style variation failed: ' + e.message, 'error');
|
||||
setOptStatus('Style variation failed');
|
||||
} finally {
|
||||
optSaveStyleBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
if (optStyleInstruct) {
|
||||
const styleVariationInput = () => {
|
||||
const style = optStyleInstruct.value.trim();
|
||||
const text = optTranscript.value.trim() || benchmarkSampleText();
|
||||
const newId = optStyleVoiceId.value.trim() || suggestedStyleVoiceId(v.id, style);
|
||||
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 (!/^[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};
|
||||
};
|
||||
optPreviewStyleBtn.addEventListener('click', async () => {
|
||||
const input = styleVariationInput();
|
||||
if (!input) return;
|
||||
optPreviewStyleBtn.disabled = true;
|
||||
try {
|
||||
setOptStatus('Synthesizing style preview...');
|
||||
const blob = await fetchTtsPreviewBlob(v.id, input.text, 'wav', input.style, input.backend);
|
||||
if (optStyleAudio.src) URL.revokeObjectURL(optStyleAudio.src);
|
||||
optStyleAudio.src = URL.createObjectURL(blob);
|
||||
optStyleAudio.style.display = '';
|
||||
await optStyleAudio.play().catch(()=>{});
|
||||
setOptStatus('Style preview ready. If it sounds right, save it as a new voice.');
|
||||
} catch(e) {
|
||||
toast('Style preview failed: ' + e.message, 'error');
|
||||
setOptStatus('Style preview failed');
|
||||
} finally {
|
||||
optPreviewStyleBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
optSaveStyleBtn.addEventListener('click', async () => {
|
||||
const input = styleVariationInput();
|
||||
if (!input) return;
|
||||
optSaveStyleBtn.disabled = true;
|
||||
try {
|
||||
setOptStatus(`Synthesizing style variation ${input.newId}...`);
|
||||
const r = await fetch('/api/tts-style-variation', {method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({source_voice:v.id, voice_id:input.newId, text:input.text, instruct:input.style, backend:input.backend})});
|
||||
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
||||
const d = await r.json();
|
||||
toast('Style variation saved: ' + d.voice_id, 'success');
|
||||
setOptStatus('Style variation saved. Restart TTS so the backend scans the new voice.');
|
||||
await loadVoiceLibrary();
|
||||
renderIntegrationSnippets();
|
||||
} catch(e) {
|
||||
toast('Style variation failed: ' + e.message, 'error');
|
||||
setOptStatus('Style variation failed');
|
||||
} finally {
|
||||
optSaveStyleBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
wrap.querySelector('.opt-undo').addEventListener('click', async () => {
|
||||
if (!confirm(`Restore the original backup for "${v.id}"?`)) return;
|
||||
@ -5120,8 +5137,7 @@ function makeVoiceRow(v) {
|
||||
v.path = d.path || v.path;
|
||||
v.file_type = d.file_type || v.file_type;
|
||||
markVoiceAudioChanged(v);
|
||||
wrap.querySelector('.vr-type').textContent = voiceFileType(v).toUpperCase();
|
||||
wrap.querySelector('.vr-type').title = voiceFileType(v);
|
||||
if (vrTypeEl) { vrTypeEl.textContent = voiceFileType(v).toUpperCase(); vrTypeEl.title = voiceFileType(v); }
|
||||
await refreshOptimizerFromVoice();
|
||||
toast('Original restored: ' + v.id, 'success');
|
||||
markTtsRestartRequired('Original restored. Restart TTS before rebenchmarking.');
|
||||
@ -5149,17 +5165,19 @@ function makeVoiceRow(v) {
|
||||
optRestartTtsBtn.addEventListener('click', async () => {
|
||||
optRestartTtsBtn.disabled = true;
|
||||
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' });
|
||||
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
||||
const d = await r.json();
|
||||
_voices.forEach(voice => { voice.needs_tts_restart = false; });
|
||||
updateLibraryInsights();
|
||||
setVoiceRestartState(false, `TTS restarted (${d.container || 'container'}). Rebenchmark now uses the edited voice.`);
|
||||
toast('TTS restarted. Voices rescanned.', 'success');
|
||||
const names = (d.restarted || []).join(', ') || 'containers';
|
||||
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) {
|
||||
toast('Restart TTS failed: ' + e.message, 'error');
|
||||
setOptStatus('Restart TTS failed');
|
||||
setOptStatus('Restart TTS failed: ' + e.message);
|
||||
} finally {
|
||||
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 ? '✅ Real-time capable' : '⚠ 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 ───────────────────────────────────────────────────────────
|
||||
|
||||
let sttTtsSourceId = null;
|
||||
@ -6164,6 +6295,237 @@ loadSettings().then(() => {
|
||||
})();
|
||||
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…</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 ↗</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 ──────────────────────────────────────────────────────
|
||||
|
||||
(function initCollapsibleCards() {
|
||||
@ -6199,10 +6561,11 @@ loadVoiceLibrary().then(renderIntegrationSnippets).catch(e => status('Voice libr
|
||||
h2.prepend(chev);
|
||||
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');
|
||||
body.className = 'card-col-body';
|
||||
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; }
|
||||
card.appendChild(body);
|
||||
|
||||
|
||||
@ -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="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="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="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>
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
<div class="tab-content" id="tab-source">
|
||||
<div class="card">
|
||||
<h2>Step 1 — 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">
|
||||
<strong>Drop an audio / video file here</strong>
|
||||
<span>WAV · MP3 · OGG · FLAC · M4A · MP4 · MKV · WEBM</span>
|
||||
@ -20,6 +20,7 @@
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>YouTube / URL</h2>
|
||||
<p class="card-subtitle">Download audio from a direct link or YouTube video.</p>
|
||||
<div class="url-row">
|
||||
<input type="text" id="yt-url" placeholder="https://www.youtube.com/watch?v=...">
|
||||
<button class="btn-primary" id="yt-btn">Download</button>
|
||||
@ -28,6 +29,7 @@
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Microphone</h2>
|
||||
<p class="card-subtitle">Record a fresh sample with live input level monitoring.</p>
|
||||
<div class="btn-row">
|
||||
<button class="btn-red" id="rec-start-btn">● Record</button>
|
||||
<button class="btn-secondary" id="rec-stop-btn" disabled>■ Stop</button>
|
||||
@ -35,7 +37,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" id="waveform-card" style="display:none">
|
||||
<h2>Step 2 — 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 — Trim selection</h2>
|
||||
<p class="card-subtitle">Select the best 3–20 s of clean speech from the waveform.</p>
|
||||
<div id="waveform-wrap"><div id="waveform"></div></div>
|
||||
<div class="time-row">
|
||||
<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="card">
|
||||
<h2>Step 3 — 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">
|
||||
<input type="text" id="voice-id-input" placeholder="e.g. EN_F_Anna or MyCustomVoice" spellcheck="false">
|
||||
</div>
|
||||
@ -90,7 +93,7 @@
|
||||
</div>
|
||||
<div class="card">
|
||||
<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">
|
||||
<button class="btn-secondary" id="transcribe-btn">💬 Auto-transcribe</button>
|
||||
<span id="transcribe-status" style="font-size:13px;color:var(--subtext)"></span>
|
||||
@ -99,11 +102,13 @@
|
||||
</div>
|
||||
<div class="card">
|
||||
<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>
|
||||
<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 class="card">
|
||||
<h2>Step 4 — Save to library</h2>
|
||||
<p class="card-subtitle">Write the cloned voice to your active voices folder.</p>
|
||||
<div class="btn-row">
|
||||
<button class="btn-green" id="save-btn">💾 Save to Voice Library</button>
|
||||
</div>
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
<div class="tab-content" id="tab-integrations">
|
||||
<div class="card">
|
||||
<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">
|
||||
<button class="btn-primary" id="show-api-btn" type="button">show api</button>
|
||||
<button class="btn-secondary" id="integration-refresh-btn">Refresh examples</button>
|
||||
@ -57,7 +57,7 @@
|
||||
</div>
|
||||
<div class="card">
|
||||
<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>
|
||||
</div>
|
||||
</div><!-- /tab-integrations -->
|
||||
|
||||
@ -23,7 +23,7 @@
|
||||
|
||||
<div class="card">
|
||||
<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="field">
|
||||
<label>Gender</label>
|
||||
@ -67,7 +67,7 @@
|
||||
|
||||
<div class="card">
|
||||
<h2>Reference transcript & 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>
|
||||
<div class="btn-row">
|
||||
<button class="btn-teal" id="design-generate-btn" style="align-self:flex-end">✨ Generate voice</button>
|
||||
@ -77,6 +77,7 @@
|
||||
|
||||
<div class="card" id="design-result" style="display:none">
|
||||
<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>
|
||||
<hr>
|
||||
<h2>Export to Voice Clone Library</h2>
|
||||
@ -127,6 +128,7 @@
|
||||
|
||||
<div class="card">
|
||||
<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="field">
|
||||
<label>Preset</label>
|
||||
@ -143,7 +145,7 @@
|
||||
<div class="tab-content" id="tab-custom">
|
||||
<div class="card" style="border-color: rgba(37,99,235,.25);">
|
||||
<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">
|
||||
<strong>Best for style control over configured target timbres.</strong>
|
||||
<div class="backend-help-tags">
|
||||
|
||||
@ -2,28 +2,298 @@
|
||||
<span class="section-icon">🤖</span>
|
||||
<div class="section-title">
|
||||
<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>
|
||||
|
||||
<!-- Category filter tabs -->
|
||||
<!-- Category tabs: Local / Online -->
|
||||
<div class="llm-cat-tabs" id="llm-cat-tabs">
|
||||
<button class="llm-cat-tab active" data-llm-cat="stt">🎤 ASR · STT</button>
|
||||
<button class="llm-cat-tab" data-llm-cat="tts">🔊 TTS</button>
|
||||
<button class="llm-cat-tab" data-llm-cat="llm">🤖 LLM</button>
|
||||
<button class="llm-cat-tab" data-llm-cat="local">💻 Local</button>
|
||||
<button class="llm-cat-tab active" data-llm-cat="local">💻 Local</button>
|
||||
<button class="llm-cat-tab" data-llm-cat="online">🌐 Online APIs</button>
|
||||
</div>
|
||||
|
||||
<!-- ── STT section ─────────────────────────────────────────── -->
|
||||
<div class="llm-section" id="llm-sec-stt" data-llm-section="stt">
|
||||
<!-- ── LOCAL section ──────────────────────────────────────── -->
|
||||
<div class="llm-section" id="llm-sec-local" data-llm-section="local">
|
||||
<div class="llm-sec-header">
|
||||
<div>
|
||||
<h3 class="llm-sec-title">ASR · Speech-to-Text</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>
|
||||
<h3 class="llm-sec-title">Local AI Services</h3>
|
||||
<p class="llm-sec-note">Run everything on your own hardware — 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">↻ Refresh</button>
|
||||
</div>
|
||||
<div id="dc-grid" class="dc-grid">
|
||||
<div class="dc-loading">Checking container status…</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 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 ↗</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 ↗</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 ↗</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 ↗</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: 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]}]">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 ↗</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 & run</span>
|
||||
<button class="llm-copy-btn" data-copy="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">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 ↗</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 · 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 ↗</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’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 ↗</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 — multilingual voice cloning from a 6-second sample. 17 languages. Compatible with this app’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 ↗</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="llm-local-howto">
|
||||
<div class="llm-howto-icon">💡</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>
|
||||
<span class="llm-free-badge">Free tiers available</span>
|
||||
</div>
|
||||
|
||||
<!-- ── ASR / STT ─────────────────────────────────────────── -->
|
||||
<h4 class="llm-local-cat" style="margin-bottom:12px">🎤 ASR · Speech-to-Text</h4>
|
||||
<div class="llm-service-grid">
|
||||
|
||||
<div class="llm-card llm-card-highlight">
|
||||
@ -107,18 +377,9 @@
|
||||
</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">🔊 Text-to-Speech</h4>
|
||||
<div class="llm-service-grid">
|
||||
|
||||
<div class="llm-card llm-card-highlight">
|
||||
@ -193,18 +454,9 @@
|
||||
</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">🤖 Large Language Models</h4>
|
||||
<div class="llm-service-grid">
|
||||
|
||||
<div class="llm-card llm-card-highlight">
|
||||
@ -323,312 +575,5 @@
|
||||
</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 — 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 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 ↗</a>
|
||||
</div>
|
||||
|
||||
<div class="llm-local-card llm-local-card-running">
|
||||
<div class="llm-local-head">
|
||||
<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>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 ↗</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 ↗</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 ↗</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: 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]}]">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 ↗</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 & run</span>
|
||||
<button class="llm-copy-btn" data-copy="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">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 ↗</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 ↗</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’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 ↗</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’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 ↗</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Generic how-to tip -->
|
||||
<div class="llm-local-howto">
|
||||
<div class="llm-howto-icon">💡</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’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(/ /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>
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
<div class="tab-content" id="tab-routing">
|
||||
<div class="card">
|
||||
<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">
|
||||
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.
|
||||
@ -50,7 +51,7 @@
|
||||
<div class="routing-sound-browser-head">
|
||||
<div>
|
||||
<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 class="routing-sound-browser-actions">
|
||||
<button class="btn-secondary" id="routing-sound-refresh-btn" type="button">Refresh sounds</button>
|
||||
@ -65,7 +66,7 @@
|
||||
<div class="routing-log-head">
|
||||
<div>
|
||||
<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 class="routing-log-actions">
|
||||
<select id="routing-log-filter" class="routing-log-filter" title="Filter routing log entries">
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
<div class="settings-title-row">
|
||||
<div>
|
||||
<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 class="settings-status-pill">Local stack</div>
|
||||
</div>
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
<div class="tab-content" id="tab-getvoices">
|
||||
<div class="card">
|
||||
<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">
|
||||
<textarea id="getvoices-sources" spellcheck="false" aria-label="Voice source URLs"></textarea>
|
||||
<div class="getvoices-source-buttons">
|
||||
|
||||
@ -10,10 +10,11 @@
|
||||
<div class="tab-content" id="tab-generation">
|
||||
<div class="card">
|
||||
<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 class="card">
|
||||
<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="field">
|
||||
<label>Backend</label>
|
||||
@ -78,10 +79,11 @@
|
||||
<div class="tab-content" id="tab-stt-tts">
|
||||
<div class="card">
|
||||
<h2>STT → 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 class="card">
|
||||
<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="s-field">
|
||||
<label>Speech recognition</label>
|
||||
@ -118,6 +120,7 @@
|
||||
</div>
|
||||
<div class="card">
|
||||
<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="field">
|
||||
<label>Backend</label>
|
||||
@ -155,3 +158,58 @@
|
||||
<audio id="stt-tts-output-audio" controls style="display:none"></audio>
|
||||
</div>
|
||||
</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">▶ 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 -->
|
||||
|
||||
151
static/style.css
151
static/style.css
@ -1163,27 +1163,47 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
.inspector-body .optimizer-grid { display: contents; }
|
||||
.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 {
|
||||
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 {
|
||||
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);
|
||||
cursor: pointer; user-select: none;
|
||||
transition: background .15s;
|
||||
cursor: pointer; user-select: none; transition: background .15s;
|
||||
border-radius: calc(var(--radius) - 1px); /* match inner corner */
|
||||
}
|
||||
.inspector-body .opt-group-title:hover { background: var(--border); }
|
||||
.opt-chevron {
|
||||
margin-left: auto; font-size: 14px; line-height: 1; color: var(--subtext);
|
||||
transition: transform .2s; display: inline-block; flex-shrink: 0;
|
||||
.inspector-body .opt-group-title:hover { background: var(--panel); }
|
||||
.inspector-body .opt-group.open > .opt-group-title {
|
||||
border-bottom: 1px solid var(--border);
|
||||
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; }
|
||||
.inspector-body .opt-group:not(.open) > .opt-group-body { display: none; }
|
||||
.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);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.llm-local-card {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
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-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-name { font-size: 13.5px; font-weight: 700; color: var(--text); }
|
||||
.llm-local-head { display: flex; align-items: center; gap: 8px; }
|
||||
.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 {
|
||||
font-size: 10px; font-weight: 600; color: var(--subtext);
|
||||
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-url {
|
||||
display: flex; align-items: center; gap: 7px; flex-wrap: wrap;
|
||||
font-size: 14px; font-weight: 600; color: var(--subtext);
|
||||
display: flex; align-items: center; gap: 7px; flex-wrap: nowrap;
|
||||
font-size: 12px; font-weight: 600; color: var(--subtext);
|
||||
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-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 */
|
||||
.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); }
|
||||
|
||||
/* 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); }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user