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:
|
ports:
|
||||||
- "7890:7890"
|
- "7890:7890"
|
||||||
|
|
||||||
|
# Give the app user access to the Docker socket (GID of docker group on host)
|
||||||
|
group_add:
|
||||||
|
- "${DOCKER_GID:-988}"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- ${VOICE_HOST_DIR:-./voices}:/voices:rw
|
- ${VOICE_HOST_DIR:-./voices}:/voices:rw
|
||||||
- tts-voice-creator-clone-and-design-2:/home/app/.config/tts-voice-creator
|
- tts-voice-creator-clone-and-design-2:/home/app/.config/tts-voice-creator
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- ./static:/app/static:ro
|
||||||
|
- ./server.py:/app/server.py:ro
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- VOICES_DIR=/voices
|
- VOICES_DIR=/voices
|
||||||
- OUTPUT_DIR=/voices/active_voices
|
- OUTPUT_DIR=/voices/active_voices
|
||||||
- TTS_CONTAINER_NAME=faster-qwen3-tts
|
# Containers that load WAV files and need a restart after voice changes
|
||||||
|
- TTS_CONTAINER_NAMES=faster-qwen3-tts-voiceclone,faster-qwen3-tts-streaming
|
||||||
- VOICE_DESIGN_MODEL=Qwen3-TTS-12Hz-1.7B-VoiceDesign
|
- VOICE_DESIGN_MODEL=Qwen3-TTS-12Hz-1.7B-VoiceDesign
|
||||||
|
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
|
|||||||
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_ZEROSHOT_DEFAULT = os.environ.get("NVIDIA_ZEROSHOT_TTS_URL", _NVIDIA_CLONE_DEFAULT)
|
||||||
_NVIDIA_FLOW_DEFAULT = os.environ.get("NVIDIA_FLOW_TTS_URL", "http://host.docker.internal:8094")
|
_NVIDIA_FLOW_DEFAULT = os.environ.get("NVIDIA_FLOW_TTS_URL", "http://host.docker.internal:8094")
|
||||||
_TTS_CONTAINER = os.environ.get("TTS_CONTAINER_NAME", "faster-qwen3-tts")
|
_TTS_CONTAINER = os.environ.get("TTS_CONTAINER_NAME", "faster-qwen3-tts")
|
||||||
|
_TTS_CONTAINERS_RAW = os.environ.get("TTS_CONTAINER_NAMES", "") # comma-separated override
|
||||||
_VOICE_DESIGN_MODEL = os.environ.get("VOICE_DESIGN_MODEL", "Qwen3-TTS-12Hz-1.7B-VoiceDesign")
|
_VOICE_DESIGN_MODEL = os.environ.get("VOICE_DESIGN_MODEL", "Qwen3-TTS-12Hz-1.7B-VoiceDesign")
|
||||||
_VOICE_TARGET_DBFS = float(os.environ.get("VOICE_TARGET_DBFS", "-20.0"))
|
_VOICE_TARGET_DBFS = float(os.environ.get("VOICE_TARGET_DBFS", "-20.0"))
|
||||||
_VOICE_PEAK_DBFS = float(os.environ.get("VOICE_PEAK_DBFS", "-1.0"))
|
_VOICE_PEAK_DBFS = float(os.environ.get("VOICE_PEAK_DBFS", "-1.0"))
|
||||||
@ -192,6 +193,62 @@ def _copy_limited(src, dest, limit: int) -> int:
|
|||||||
return total
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_chunked_bytes(data: bytes) -> bytes:
|
||||||
|
result = bytearray()
|
||||||
|
pos = 0
|
||||||
|
while pos < len(data):
|
||||||
|
end = data.find(b"\r\n", pos)
|
||||||
|
if end < 0:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
size = int(data[pos:end].split(b";")[0].strip(), 16)
|
||||||
|
except ValueError:
|
||||||
|
break
|
||||||
|
if size == 0:
|
||||||
|
break
|
||||||
|
pos = end + 2
|
||||||
|
result.extend(data[pos:pos + size])
|
||||||
|
pos += size + 2
|
||||||
|
return bytes(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _docker_get_json(path: str) -> tuple[int, dict | list | None]:
|
||||||
|
sock_path = os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")
|
||||||
|
if not Path(sock_path).exists():
|
||||||
|
raise RuntimeError(f"Docker socket not found: {sock_path}")
|
||||||
|
request = (
|
||||||
|
f"GET {path} HTTP/1.1\r\n"
|
||||||
|
"Host: docker\r\n"
|
||||||
|
"Connection: close\r\n\r\n"
|
||||||
|
).encode("utf-8")
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
||||||
|
sock.settimeout(10)
|
||||||
|
sock.connect(sock_path)
|
||||||
|
sock.sendall(request)
|
||||||
|
while True:
|
||||||
|
data = sock.recv(65536)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
chunks.append(data)
|
||||||
|
raw = b"".join(chunks)
|
||||||
|
header_end = raw.find(b"\r\n\r\n")
|
||||||
|
if header_end < 0:
|
||||||
|
raise RuntimeError("Invalid Docker HTTP response")
|
||||||
|
header_str = raw[:header_end].decode("utf-8", errors="replace")
|
||||||
|
body_bytes = raw[header_end + 4:]
|
||||||
|
status_line = header_str.splitlines()[0]
|
||||||
|
m = re.match(r"HTTP/\S+\s+(\d+)", status_line)
|
||||||
|
if not m:
|
||||||
|
raise RuntimeError(f"Invalid Docker status line: {status_line!r}")
|
||||||
|
code = int(m.group(1))
|
||||||
|
if "transfer-encoding: chunked" in header_str.lower():
|
||||||
|
body_bytes = _decode_chunked_bytes(body_bytes)
|
||||||
|
if not body_bytes.strip():
|
||||||
|
return code, None
|
||||||
|
return code, json.loads(body_bytes)
|
||||||
|
|
||||||
|
|
||||||
def _docker_post(path: str) -> tuple[int, str]:
|
def _docker_post(path: str) -> tuple[int, str]:
|
||||||
sock_path = os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")
|
sock_path = os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")
|
||||||
if not Path(sock_path).exists():
|
if not Path(sock_path).exists():
|
||||||
@ -1456,6 +1513,102 @@ async def import_source_audio(request: Request):
|
|||||||
return {"id": fid, "duration": _duration(wav), "filename": filename or dest.name, "audio_url": audio_url}
|
return {"id": fid, "duration": _duration(wav), "filename": filename or dest.name, "audio_url": audio_url}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Audio proxy (CORS bypass for external sources) ────────────────────────────
|
||||||
|
|
||||||
|
_PROXY_AUDIO_DOMAINS: set[str] = {
|
||||||
|
"drive.usercontent.google.com",
|
||||||
|
"drive.google.com",
|
||||||
|
"aiartes.com",
|
||||||
|
"freesound.org",
|
||||||
|
"lanceblairvo.com",
|
||||||
|
"raw.githubusercontent.com",
|
||||||
|
"sample-files.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/proxy-audio")
|
||||||
|
async def proxy_audio(url: str):
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ("http", "https"):
|
||||||
|
raise HTTPException(400, "Only http/https URLs are supported")
|
||||||
|
domain = parsed.netloc.lower().lstrip("www.")
|
||||||
|
if not any(domain == d or domain.endswith("." + d) for d in _PROXY_AUDIO_DOMAINS):
|
||||||
|
raise HTTPException(403, f"Domain not in audio proxy allowlist: {parsed.netloc}")
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, headers=_VOICE_SOURCE_HEADERS, timeout=30, stream=True)
|
||||||
|
resp.raise_for_status()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(502, f"Proxy fetch failed: {e}")
|
||||||
|
content_type = resp.headers.get("content-type", "audio/mpeg")
|
||||||
|
|
||||||
|
def _stream():
|
||||||
|
for chunk in resp.iter_content(65536):
|
||||||
|
if chunk:
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
_stream(), media_type=content_type,
|
||||||
|
headers={"Cache-Control": "public, max-age=3600", "Access-Control-Allow-Origin": "*"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Quick voice import (download + save directly to library) ──────────────────
|
||||||
|
|
||||||
|
@app.post("/api/quick-import-voice")
|
||||||
|
async def quick_import_voice(request: Request):
|
||||||
|
data = await request.json()
|
||||||
|
audio_url = _source_import_url(str(data.get("audio_url") or ""))
|
||||||
|
voice_id = re.sub(r"[^A-Za-z0-9_\-\.]", "_", str(data.get("voice_id") or "").strip())[:80]
|
||||||
|
if not voice_id:
|
||||||
|
raise HTTPException(400, "voice_id is required")
|
||||||
|
transcript = str(data.get("transcript") or "").strip()
|
||||||
|
|
||||||
|
suffix = Path(urlparse(audio_url).path).suffix.lower()
|
||||||
|
if suffix not in _AUDIO_EXTS:
|
||||||
|
suffix = ".bin"
|
||||||
|
dest = TEMP_DIR / f"{uuid.uuid4().hex}{suffix}"
|
||||||
|
try:
|
||||||
|
with requests.get(audio_url, headers=_VOICE_SOURCE_HEADERS, timeout=30, stream=True) as r:
|
||||||
|
r.raise_for_status()
|
||||||
|
total = 0
|
||||||
|
with dest.open("wb") as f:
|
||||||
|
for chunk in r.iter_content(1024 * 1024):
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
total += len(chunk)
|
||||||
|
if total > _source_download_limit("_MAX_UPLOAD_BYTES", 1024):
|
||||||
|
raise HTTPException(413, "Downloaded audio is too large")
|
||||||
|
f.write(chunk)
|
||||||
|
wav = _to_wav_24k(dest)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(400, f"Audio download failed: {e}")
|
||||||
|
|
||||||
|
settings = _load_settings()
|
||||||
|
out_dir = _active_voices_dir(settings)
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
final_id = voice_id
|
||||||
|
if (out_dir / f"{final_id}.wav").exists():
|
||||||
|
for i in range(2, 1000):
|
||||||
|
candidate = f"{voice_id}_{i}"
|
||||||
|
if not (out_dir / f"{candidate}.wav").exists():
|
||||||
|
final_id = candidate
|
||||||
|
break
|
||||||
|
|
||||||
|
wav_dest = out_dir / f"{final_id}.wav"
|
||||||
|
_remove_audio_variants(out_dir, final_id)
|
||||||
|
loudness = _export_normalized_wav(wav, wav_dest)
|
||||||
|
if transcript:
|
||||||
|
(out_dir / f"{final_id}.reference.txt").write_text(transcript, encoding="utf-8")
|
||||||
|
meta = _load_meta(wav_dest)
|
||||||
|
meta["enabled"] = True
|
||||||
|
meta["loudness"] = loudness
|
||||||
|
_save_meta(wav_dest, meta)
|
||||||
|
return {"voice_id": final_id, "loudness": loudness}
|
||||||
|
|
||||||
|
|
||||||
# ── YouTube download (SSE) ────────────────────────────────────────────────────
|
# ── YouTube download (SSE) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/api/download-yt")
|
@app.get("/api/download-yt")
|
||||||
@ -2599,6 +2752,77 @@ def _normalize_voice_source_urls(urls: list[str] | None = None) -> list[str]:
|
|||||||
return normalized[:24]
|
return normalized[:24]
|
||||||
|
|
||||||
|
|
||||||
|
_GDRIVE_MIME_EXT = {
|
||||||
|
"audio/mpeg": "mp3", "audio/mp3": "mp3",
|
||||||
|
"audio/wav": "wav", "audio/x-wav": "wav",
|
||||||
|
"audio/ogg": "ogg", "audio/vorbis": "ogg",
|
||||||
|
"audio/flac": "flac", "audio/x-flac": "flac",
|
||||||
|
"audio/aac": "aac", "audio/x-aac": "aac",
|
||||||
|
"audio/x-m4a": "m4a", "audio/m4a": "m4a", "audio/mp4": "m4a",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _scrape_google_drive_folder(url: str) -> dict:
|
||||||
|
m = re.search(r"/folders/([A-Za-z0-9_-]+)", url)
|
||||||
|
if not m:
|
||||||
|
return _generic_audio_page_source(url)
|
||||||
|
folder_id = m.group(1)
|
||||||
|
source_id = _voice_source_id(url)
|
||||||
|
source_name = "Google Drive"
|
||||||
|
embed_url = f"https://drive.google.com/embeddedfolderview?id={folder_id}#list"
|
||||||
|
try:
|
||||||
|
body = _source_get(embed_url).text
|
||||||
|
except Exception:
|
||||||
|
return _generic_audio_page_source(url)
|
||||||
|
|
||||||
|
items = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for chunk in re.split(r"(?=<div class=\"flip-entry\" id=\"entry-)", body):
|
||||||
|
eid = re.search(r'id="entry-([A-Za-z0-9_-]+)"', chunk)
|
||||||
|
title_m = re.search(r'class="flip-entry-title">(.*?)</div>', chunk)
|
||||||
|
mime_m = re.search(r"type/([a-zA-Z0-9/+\-]+)", chunk)
|
||||||
|
if not eid or not title_m:
|
||||||
|
continue
|
||||||
|
file_id = eid.group(1)
|
||||||
|
if file_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(file_id)
|
||||||
|
filename = title_m.group(1).strip()
|
||||||
|
mime = mime_m.group(1).lower() if mime_m else ""
|
||||||
|
if not mime.startswith("audio"):
|
||||||
|
continue
|
||||||
|
ext = _GDRIVE_MIME_EXT.get(mime, Path(filename).suffix.lstrip(".").lower() or "mp3")
|
||||||
|
gdrive_dl = f"https://drive.usercontent.google.com/download?id={file_id}&export=download&authuser=0"
|
||||||
|
proxy_url = f"/api/proxy-audio?url={quote(gdrive_dl)}"
|
||||||
|
stem = Path(filename).stem.replace("-", " ").replace("_", " ").strip()
|
||||||
|
lang = _guess_language(filename)
|
||||||
|
gender = _guess_gender(filename)
|
||||||
|
item = _source_item(
|
||||||
|
source_id, source_name,
|
||||||
|
stem, "Google Drive audio",
|
||||||
|
url,
|
||||||
|
audio_url=proxy_url,
|
||||||
|
file_type=ext,
|
||||||
|
category="Google Drive",
|
||||||
|
description=f"Audio file from Google Drive folder.",
|
||||||
|
language=lang,
|
||||||
|
gender=gender,
|
||||||
|
)
|
||||||
|
item["import_url"] = gdrive_dl
|
||||||
|
items.append(item)
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
items.append(_source_item(
|
||||||
|
source_id, source_name,
|
||||||
|
"Google Drive folder", "Source page",
|
||||||
|
url,
|
||||||
|
category="Google Drive",
|
||||||
|
description="No audio files found in this Google Drive folder. It may be private or empty.",
|
||||||
|
))
|
||||||
|
|
||||||
|
return _source_result(source_id, source_name, url, f"{len(items)} audio files from Google Drive folder.", items)
|
||||||
|
|
||||||
|
|
||||||
def _scrape_voice_source_url(url: str) -> dict:
|
def _scrape_voice_source_url(url: str) -> dict:
|
||||||
lower = url.lower()
|
lower = url.lower()
|
||||||
if "aiartes.com/voiceai" in lower:
|
if "aiartes.com/voiceai" in lower:
|
||||||
@ -2613,6 +2837,8 @@ def _scrape_voice_source_url(url: str) -> dict:
|
|||||||
return _scrape_freesound_scott_simpson()
|
return _scrape_freesound_scott_simpson()
|
||||||
if "lanceblairvo.com/raw-voiceover-samples" in lower:
|
if "lanceblairvo.com/raw-voiceover-samples" in lower:
|
||||||
return _scrape_lanceblair_raw_samples()
|
return _scrape_lanceblair_raw_samples()
|
||||||
|
if "drive.google.com/drive/folders/" in lower or "drive.google.com/open?id=" in lower:
|
||||||
|
return _scrape_google_drive_folder(url)
|
||||||
if _DIRECT_AUDIO_RE.search(lower):
|
if _DIRECT_AUDIO_RE.search(lower):
|
||||||
return _direct_audio_source(url)
|
return _direct_audio_source(url)
|
||||||
return _generic_audio_page_source(url)
|
return _generic_audio_page_source(url)
|
||||||
@ -2916,30 +3142,159 @@ def _clear_tts_restart_flags(settings: dict | None = None) -> int:
|
|||||||
cleared += 1
|
cleared += 1
|
||||||
return cleared
|
return cleared
|
||||||
|
|
||||||
|
def _tts_container_names() -> list[str]:
|
||||||
|
"""Return the list of TTS container names to restart, from env vars."""
|
||||||
|
multi = os.environ.get("TTS_CONTAINER_NAMES", _TTS_CONTAINERS_RAW).strip()
|
||||||
|
if multi:
|
||||||
|
return [c.strip() for c in multi.split(",") if c.strip()]
|
||||||
|
single = os.environ.get("TTS_CONTAINER_NAME", _TTS_CONTAINER).strip()
|
||||||
|
return [single] if single else []
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/tts/restart")
|
@app.post("/api/tts/restart")
|
||||||
async def restart_tts_container():
|
async def restart_tts_container():
|
||||||
container = os.environ.get("TTS_CONTAINER_NAME", _TTS_CONTAINER).strip()
|
containers = _tts_container_names()
|
||||||
if not container:
|
if not containers:
|
||||||
raise HTTPException(400, "TTS_CONTAINER_NAME is not configured")
|
raise HTTPException(400, "No TTS container names configured (set TTS_CONTAINER_NAMES in docker-compose.yml)")
|
||||||
|
|
||||||
path = f"/containers/{quote(container, safe='')}/restart?t=10"
|
results = []
|
||||||
try:
|
errors = []
|
||||||
code, raw = _docker_post(path)
|
for container in containers:
|
||||||
except PermissionError:
|
path = f"/containers/{quote(container, safe='')}/restart?t=10"
|
||||||
raise HTTPException(502, "No permission to access /var/run/docker.sock from this container")
|
try:
|
||||||
except Exception as e:
|
code, raw = _docker_post(path)
|
||||||
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"HTTP {code}"
|
||||||
if code not in (204, 304):
|
errors.append(f"{container}: {detail}")
|
||||||
detail = raw.split("\r\n\r\n", 1)[-1].strip() or f"Docker API returned HTTP {code}"
|
else:
|
||||||
raise HTTPException(502, detail)
|
results.append(container)
|
||||||
|
except PermissionError:
|
||||||
|
raise HTTPException(502, "No permission to access /var/run/docker.sock — is the socket mounted in docker-compose.yml?")
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"{container}: {e}")
|
||||||
|
|
||||||
cleared = 0
|
cleared = 0
|
||||||
try:
|
try:
|
||||||
cleared = _clear_tts_restart_flags()
|
cleared = _clear_tts_restart_flags()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Could not clear TTS restart flags: %s", e)
|
logger.warning("Could not clear TTS restart flags: %s", e)
|
||||||
return {"ok": True, "container": container, "cleared_restart_flags": cleared}
|
|
||||||
|
if errors and not results:
|
||||||
|
raise HTTPException(502, "; ".join(errors))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"restarted": results,
|
||||||
|
"errors": errors,
|
||||||
|
"cleared_restart_flags": cleared,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/tts/restart-info")
|
||||||
|
async def tts_restart_info():
|
||||||
|
containers = _tts_container_names()
|
||||||
|
sock_ok = Path(os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")).exists()
|
||||||
|
return {"containers": containers, "socket_available": sock_ok}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Local Docker container management ────────────────────────────────────────
|
||||||
|
|
||||||
|
_LOCAL_CONTAINER_DEFS: list[dict] = [
|
||||||
|
{"name": "faster-qwen3-tts-voiceclone", "label": "Qwen3 TTS · Voice Clone", "role": "tts", "port": 8020, "stack": "faster-qwen3-tts-dgx-spark-voice-design", "image": "martinb78/faster-qwen3-tts-dgx-spark:v4", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "WAV voice cloning. Scans active_voices at startup — restart after adding or editing voices."},
|
||||||
|
{"name": "faster-qwen3-tts-voicedesign", "label": "Qwen3 TTS · Voice Design", "role": "tts", "port": 8021, "stack": "faster-qwen3-tts-dgx-spark-voice-design", "image": "martinb78/faster-qwen3-tts-dgx-spark:v4", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "Instruction-based voice design. Describe a voice in words — no WAV needed."},
|
||||||
|
{"name": "faster-qwen3-tts-customvoice", "label": "Qwen3 TTS · Custom Voice", "role": "tts", "port": 8022, "stack": "faster-qwen3-tts-dgx-spark-voice-design", "image": "martinb78/faster-qwen3-tts-dgx-spark:v4", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "Style control over configured premium speakers such as Ryan, Vivian, and Serena."},
|
||||||
|
{"name": "faster-qwen3-tts-streaming", "label": "Qwen3 TTS · Streaming", "role": "tts", "port": 8023, "stack": "faster-qwen3-tts-dgx-spark-voice-design", "image": "martinb78/qwen3-tts-streaming-dgx-spark:latest", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "Low-latency progressive WAV streaming for voice clone voices."},
|
||||||
|
{"name": "parakeet-asr", "label": "NVIDIA Parakeet ASR", "role": "stt", "port": 8090, "stack": "nvidia-speech-gateway", "image": "parakeet-tdt-v3-spark:latest", "repo": "https://github.com/mARTin-B78/dgx-spark-parakeet-asr", "description": "NVIDIA Parakeet GPU-accelerated speech recognition on port 8090."},
|
||||||
|
{"name": "magpie-tts", "label": "NVIDIA Magpie TTS", "role": "tts", "port": 8091, "stack": "nvidia-speech-gateway", "image": "nvcr.io/nim/nvidia/magpie-tts-multilingual:latest","repo": "https://github.com/mARTin-B78/dgx-spark-parakeet-asr", "description": "NVIDIA neural TTS. GPU-accelerated, high-quality multilingual synthesis."},
|
||||||
|
{"name": "parakeet-rnnt-nim", "label": "NVIDIA Parakeet RNNT NIM", "role": "stt", "port": 8092, "stack": "nvidia-speech-gateway", "image": "nvcr.io/nim/nvidia/parakeet-1b-rnnt-multilingual:latest","repo": "https://github.com/mARTin-B78/dgx-spark-parakeet-asr", "description": "NVIDIA Parakeet RNNT NIM multilingual ASR on port 8092."},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _container_status(name: str) -> dict:
|
||||||
|
try:
|
||||||
|
code, data = _docker_get_json(f"/containers/{quote(name, safe='')}/json")
|
||||||
|
if code == 404:
|
||||||
|
return {"status": "not_found"}
|
||||||
|
if code == 200 and isinstance(data, dict):
|
||||||
|
state = data.get("State", {})
|
||||||
|
return {
|
||||||
|
"status": state.get("Status", "unknown"),
|
||||||
|
"running": state.get("Running", False),
|
||||||
|
"paused": state.get("Paused", False),
|
||||||
|
"started_at": state.get("StartedAt", ""),
|
||||||
|
"image": data.get("Config", {}).get("Image", ""),
|
||||||
|
}
|
||||||
|
return {"status": "error", "detail": f"HTTP {code}"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "detail": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/local-containers")
|
||||||
|
async def get_local_containers():
|
||||||
|
sock_ok = Path(os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")).exists()
|
||||||
|
results = []
|
||||||
|
for defn in _LOCAL_CONTAINER_DEFS:
|
||||||
|
entry = {k: v for k, v in defn.items()}
|
||||||
|
if sock_ok:
|
||||||
|
entry.update(_container_status(defn["name"]))
|
||||||
|
else:
|
||||||
|
entry["status"] = "no_socket"
|
||||||
|
results.append(entry)
|
||||||
|
return {"containers": results, "socket_available": sock_ok}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/local-containers/{name}/start")
|
||||||
|
async def start_local_container(name: str):
|
||||||
|
if not any(c["name"] == name for c in _LOCAL_CONTAINER_DEFS):
|
||||||
|
raise HTTPException(404, f"Unknown container: {name}")
|
||||||
|
try:
|
||||||
|
code, _ = _docker_post(f"/containers/{quote(name, safe='')}/start")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(502, f"Docker start failed: {e}")
|
||||||
|
if code not in (204, 304):
|
||||||
|
raise HTTPException(502, f"Docker API returned HTTP {code}")
|
||||||
|
return {"ok": True, "name": name, **_container_status(name)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/local-containers/{name}/stop")
|
||||||
|
async def stop_local_container(name: str):
|
||||||
|
if not any(c["name"] == name for c in _LOCAL_CONTAINER_DEFS):
|
||||||
|
raise HTTPException(404, f"Unknown container: {name}")
|
||||||
|
try:
|
||||||
|
code, _ = _docker_post(f"/containers/{quote(name, safe='')}/stop?t=10")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(502, f"Docker stop failed: {e}")
|
||||||
|
if code not in (204, 304):
|
||||||
|
raise HTTPException(502, f"Docker API returned HTTP {code}")
|
||||||
|
return {"ok": True, "name": name, **_container_status(name)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/local-containers/{name}/restart")
|
||||||
|
async def restart_local_container(name: str):
|
||||||
|
if not any(c["name"] == name for c in _LOCAL_CONTAINER_DEFS):
|
||||||
|
raise HTTPException(404, f"Unknown container: {name}")
|
||||||
|
try:
|
||||||
|
code, _ = _docker_post(f"/containers/{quote(name, safe='')}/restart?t=10")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(502, f"Docker restart failed: {e}")
|
||||||
|
if code not in (204, 304):
|
||||||
|
raise HTTPException(502, f"Docker API returned HTTP {code}")
|
||||||
|
return {"ok": True, "name": name, **_container_status(name)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/probe-url")
|
||||||
|
async def probe_url(url: str):
|
||||||
|
"""Server-side reachability check — avoids browser CORS restrictions."""
|
||||||
|
try:
|
||||||
|
r = requests.get(url, timeout=5, allow_redirects=True,
|
||||||
|
headers={"User-Agent": "TTS-Voice-Creator/probe"})
|
||||||
|
return {"ok": True, "status": r.status_code}
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
return {"ok": False, "error": "Connection refused"}
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
return {"ok": False, "error": "Timeout"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/tts/restart-flags/clear")
|
@app.post("/api/tts/restart-flags/clear")
|
||||||
|
|||||||
571
static/app.js
571
static/app.js
@ -104,10 +104,10 @@ function selectVoice(wrap) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="insp-hd-divider"></div>
|
<div class="insp-hd-divider"></div>
|
||||||
<div class="insp-subtitle">
|
<div class="insp-subtitle">
|
||||||
<span class="insp-flag" title="Double-click to change accent flag">
|
<span class="insp-flag" title="Click to change accent flag">
|
||||||
<span class="insp-flag-icon">${flagIconHtml}</span>
|
<span class="insp-flag-icon">${flagIconHtml}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="insp-lang-wrap" title="Double-click to change language">
|
<span class="insp-lang-wrap" title="Click to change language">
|
||||||
<span class="insp-lang-code">${escHtml(langCode)}</span>
|
<span class="insp-lang-code">${escHtml(langCode)}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="insp-gender-label" title="Click to cycle gender">${escHtml(genderLabelHtml)}</span>
|
<span class="insp-gender-label" title="Click to cycle gender">${escHtml(genderLabelHtml)}</span>
|
||||||
@ -202,7 +202,7 @@ function selectVoice(wrap) {
|
|||||||
if (flagIconEl) flagIconEl.innerHTML = fi;
|
if (flagIconEl) flagIconEl.innerHTML = fi;
|
||||||
await saveMeta(voiceId, { flag: cc });
|
await saveMeta(voiceId, { flag: cc });
|
||||||
};
|
};
|
||||||
flagSpan.addEventListener('dblclick', () => {
|
flagSpan.addEventListener('click', () => {
|
||||||
const items = (FLAG_OPTIONS[langCode.toUpperCase()] || ALL_FLAGS).map(([cc, name]) => [cc.toLowerCase(), name]);
|
const items = (FLAG_OPTIONS[langCode.toUpperCase()] || ALL_FLAGS).map(([cc, name]) => [cc.toLowerCase(), name]);
|
||||||
createSearchablePicker(flagSpan, items, applyFlag, {
|
createSearchablePicker(flagSpan, items, applyFlag, {
|
||||||
placeholder: 'Country or accent…',
|
placeholder: 'Country or accent…',
|
||||||
@ -211,7 +211,7 @@ function selectVoice(wrap) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Language — double-click lang code to change ───────────────────────────
|
// ── Language — click lang code to change ─────────────────────────────────
|
||||||
const langCodeEl = inspector.querySelector('.insp-lang-code');
|
const langCodeEl = inspector.querySelector('.insp-lang-code');
|
||||||
const langWrap = inspector.querySelector('.insp-lang-wrap');
|
const langWrap = inspector.querySelector('.insp-lang-wrap');
|
||||||
|
|
||||||
@ -220,7 +220,7 @@ function selectVoice(wrap) {
|
|||||||
if (langCodeEl) langCodeEl.textContent = newLang;
|
if (langCodeEl) langCodeEl.textContent = newLang;
|
||||||
await saveMeta(voiceId, { lang: newLang });
|
await saveMeta(voiceId, { lang: newLang });
|
||||||
};
|
};
|
||||||
langWrap?.addEventListener('dblclick', () => {
|
langWrap?.addEventListener('click', () => {
|
||||||
const items = LANGS.map(l => [l, LANGUAGE_LABELS[l] ? `${LANGUAGE_LABELS[l]} (${l})` : l]);
|
const items = LANGS.map(l => [l, LANGUAGE_LABELS[l] ? `${LANGUAGE_LABELS[l]} (${l})` : l]);
|
||||||
createSearchablePicker(langWrap, items, applyLang, {
|
createSearchablePicker(langWrap, items, applyLang, {
|
||||||
placeholder: 'Language…',
|
placeholder: 'Language…',
|
||||||
@ -357,14 +357,16 @@ function selectVoice(wrap) {
|
|||||||
|
|
||||||
const maintTitle = body.querySelector('.opt-maintenance .opt-group-title');
|
const maintTitle = body.querySelector('.opt-maintenance .opt-group-title');
|
||||||
if (maintTitle) {
|
if (maintTitle) {
|
||||||
maintTitle.innerHTML = `Loudness <span class="opt-group-meta">Current ${escHtml(dbfs)} dBFS</span> <span class="opt-chevron">⌄</span>`;
|
maintTitle.innerHTML = `<span class="opt-chevron"></span><span class="opt-title-text">Loudness <span class="opt-group-meta">Current ${escHtml(dbfs)} dBFS</span></span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Collapsible opt-groups ────────────────────────────────────────────────
|
// ── Collapsible opt-groups ────────────────────────────────────────────────
|
||||||
body.querySelectorAll('.opt-group').forEach(group => {
|
body.querySelectorAll('.opt-group').forEach(group => {
|
||||||
const title = group.querySelector(':scope > .opt-group-title');
|
const title = group.querySelector(':scope > .opt-group-title');
|
||||||
if (!title) return;
|
if (!title) return;
|
||||||
title.addEventListener('click', () => group.classList.toggle('open'));
|
title.addEventListener('click', () => {
|
||||||
|
group.classList.toggle('open');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track extracted elements for restoreToRow
|
// Track extracted elements for restoreToRow
|
||||||
@ -837,8 +839,9 @@ function renderGetVoices() {
|
|||||||
const thumb = item.image_url ? `<img class="voice-source-thumb" src="${escHtml(item.image_url)}" alt="">` : `<div class="voice-source-thumb"></div>`;
|
const thumb = item.image_url ? `<img class="voice-source-thumb" src="${escHtml(item.image_url)}" alt="">` : `<div class="voice-source-thumb"></div>`;
|
||||||
const audio = item.audio_url ? `<audio controls preload="none" src="${escHtml(item.audio_url)}"></audio>` : '';
|
const audio = item.audio_url ? `<audio controls preload="none" src="${escHtml(item.audio_url)}"></audio>` : '';
|
||||||
const audioLink = item.audio_url ? `<a class="btn-secondary" href="${escHtml(item.audio_url)}" target="_blank" rel="noopener">Open audio</a>` : '';
|
const audioLink = item.audio_url ? `<a class="btn-secondary" href="${escHtml(item.audio_url)}" target="_blank" rel="noopener">Open audio</a>` : '';
|
||||||
const canGetVoice = !!item.audio_url;
|
const canGetVoice = !!(item.import_url || item.audio_url);
|
||||||
const getVoice = canGetVoice ? `<button class="btn-primary get-source-voice" data-url="${escHtml(item.audio_url)}" data-name="${escHtml(item.name || '')}" data-image="${escHtml(item.image_url || '')}" data-language="${escHtml(item.language || '')}" data-gender="${escHtml(item.gender || '')}" data-kind="${escHtml(item.kind || '')}" data-description="${escHtml(item.description || '')}" data-page="${escHtml(item.page_url || item._sourceHomepage || '')}">Import this voice</button>` : '';
|
const importUrl = item.import_url || item.audio_url;
|
||||||
|
const getVoice = canGetVoice ? `<button class="btn-primary get-source-voice" data-url="${escHtml(importUrl)}" data-name="${escHtml(item.name || '')}" data-image="${escHtml(item.image_url || '')}" data-language="${escHtml(item.language || '')}" data-gender="${escHtml(item.gender || '')}" data-kind="${escHtml(item.kind || '')}" data-description="${escHtml(item.description || '')}" data-page="${escHtml(item.page_url || item._sourceHomepage || '')}">Import this voice</button>` : '';
|
||||||
const type = item.file_type ? `<span class="voice-source-pill">${escHtml(String(item.file_type).toUpperCase())}</span>` : '';
|
const type = item.file_type ? `<span class="voice-source-pill">${escHtml(String(item.file_type).toUpperCase())}</span>` : '';
|
||||||
const language = item.language ? `<span class="voice-source-pill">${escHtml(item.language)}</span>` : '';
|
const language = item.language ? `<span class="voice-source-pill">${escHtml(item.language)}</span>` : '';
|
||||||
const gender = item.gender ? `<span class="voice-source-pill">${escHtml(item.gender)}</span>` : '';
|
const gender = item.gender ? `<span class="voice-source-pill">${escHtml(item.gender)}</span>` : '';
|
||||||
@ -963,32 +966,24 @@ function setLibAddSourcePreview(meta = {}) {
|
|||||||
|
|
||||||
async function getSourceVoiceInLibrary(meta) {
|
async function getSourceVoiceInLibrary(meta) {
|
||||||
if (!meta.url) { toast('This source has no direct audio URL', 'error'); return; }
|
if (!meta.url) { toast('This source has no direct audio URL', 'error'); return; }
|
||||||
switchTab('library');
|
|
||||||
const panel = $('lib-add-panel');
|
|
||||||
if (panel && !panel.classList.contains('open')) panel.classList.add('open');
|
|
||||||
const lang = sourceLanguageCode(meta.language);
|
const lang = sourceLanguageCode(meta.language);
|
||||||
const gender = sourceGenderCode(meta.gender, meta.name);
|
const gender = sourceGenderCode(meta.gender, meta.name);
|
||||||
if ($('lib-add-url')) $('lib-add-url').value = meta.url;
|
const voiceId = suggestedVoiceIdFromSourceName(meta.name, lang, gender);
|
||||||
if ($('lib-add-lang')) $('lib-add-lang').value = lang;
|
toast(`Importing ${meta.name || 'voice'}…`, 'info');
|
||||||
if ($('lib-add-gender')) $('lib-add-gender').value = gender;
|
|
||||||
if ($('lib-add-voice-id')) $('lib-add-voice-id').value = suggestedVoiceIdFromSourceName(meta.name, lang, gender);
|
|
||||||
if ($('lib-add-transcript')) $('lib-add-transcript').value = '';
|
|
||||||
if (window.libAddState) window.libAddState.pendingSource = {...meta, language: lang, gender};
|
|
||||||
setLibAddSourcePreview({...meta, language: lang, gender});
|
|
||||||
setLibAddStatus(`Importing source audio: ${meta.name || 'external voice'}...`);
|
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/import-source-audio', {
|
const r = await fetch('/api/quick-import-voice', {
|
||||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({audio_url: meta.url, name: meta.name || 'Source voice'})
|
body: JSON.stringify({audio_url: meta.url, voice_id: voiceId, transcript: ''})
|
||||||
});
|
});
|
||||||
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
loadLibAddAudio(d.id, d.duration, meta.name || 'Source voice');
|
_pendingSelectId = d.voice_id;
|
||||||
setLibAddStatus(`Source voice loaded: ${meta.name || 'external voice'}${meta.imageUrl ? ' (image will attach on save)' : ''}`);
|
navTo('s-voices');
|
||||||
toast('Voice imported into Voice Clone', 'success');
|
_libraryLoadPromise = null;
|
||||||
|
await loadVoiceLibrary();
|
||||||
|
toast(`Saved as ${d.voice_id}`, 'success');
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
setLibAddStatus('Source import failed');
|
toast('Import failed: ' + e.message, 'error');
|
||||||
toast('Source import failed: ' + e.message, 'error');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1852,6 +1847,12 @@ async function refreshTtsBackendAvailability(selected = '') {
|
|||||||
sel.innerHTML = styleBackendOptions(prev);
|
sel.innerHTML = styleBackendOptions(prev);
|
||||||
sel.disabled = !availableTtsBackends().length;
|
sel.disabled = !availableTtsBackends().length;
|
||||||
});
|
});
|
||||||
|
const perfSel = $('perf-backend-select');
|
||||||
|
if (perfSel) {
|
||||||
|
const prev = perfSel.value;
|
||||||
|
perfSel.innerHTML = ttsBackendOptions(prev);
|
||||||
|
perfSel.disabled = !availableTtsBackends().length;
|
||||||
|
}
|
||||||
updateBackendHelp();
|
updateBackendHelp();
|
||||||
updateStyleBackendHelp();
|
updateStyleBackendHelp();
|
||||||
updateBackendDependentTabs();
|
updateBackendDependentTabs();
|
||||||
@ -2797,6 +2798,7 @@ $('save-btn').addEventListener('click', async () => {
|
|||||||
// ══════════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
let _voices = [];
|
let _voices = [];
|
||||||
|
let _pendingSelectId = null;
|
||||||
let _sortField = 'id';
|
let _sortField = 'id';
|
||||||
let _sortDir = 1; // 1 = asc, -1 = desc
|
let _sortDir = 1; // 1 = asc, -1 = desc
|
||||||
let _libraryIssueFilter = '';
|
let _libraryIssueFilter = '';
|
||||||
@ -4251,6 +4253,10 @@ function renderVoiceList() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
filtered.forEach(v => list.appendChild(makeVoiceRow(v)));
|
filtered.forEach(v => list.appendChild(makeVoiceRow(v)));
|
||||||
|
if (_pendingSelectId) {
|
||||||
|
const wrap = list.querySelector(`[data-id="${CSS.escape(_pendingSelectId)}"]`);
|
||||||
|
if (wrap) { _pendingSelectId = null; selectVoice(wrap); }
|
||||||
|
}
|
||||||
syncSortHeaders();
|
syncSortHeaders();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4506,7 +4512,8 @@ function makeVoiceRow(v) {
|
|||||||
<div class="vr-optimizer">
|
<div class="vr-optimizer">
|
||||||
<div class="optimizer-grid">
|
<div class="optimizer-grid">
|
||||||
<div class="opt-group opt-trim-panel">
|
<div class="opt-group opt-trim-panel">
|
||||||
<div class="opt-group-title">Reference audio · 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">
|
<div class="opt-group-body">
|
||||||
<canvas class="opt-wave"></canvas>
|
<canvas class="opt-wave"></canvas>
|
||||||
<div class="opt-controls">
|
<div class="opt-controls">
|
||||||
@ -4522,7 +4529,8 @@ function makeVoiceRow(v) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="opt-group opt-text-panel open">
|
<div class="opt-group opt-text-panel open">
|
||||||
<div class="opt-group-title">Reference transcript <span class="opt-chevron">⌄</span></div>
|
<div class="opt-group-title"><span class="opt-chevron"></span><span class="opt-title-text">Reference transcript</span></div>
|
||||||
|
<div class="opt-group-subtitle">The spoken text that matches this voice recording.</div>
|
||||||
<div class="opt-group-body">
|
<div class="opt-group-body">
|
||||||
<textarea class="opt-transcript" placeholder="Reference text">${escHtml(v.transcript || '')}</textarea>
|
<textarea class="opt-transcript" placeholder="Reference text">${escHtml(v.transcript || '')}</textarea>
|
||||||
<div class="opt-controls">
|
<div class="opt-controls">
|
||||||
@ -4534,7 +4542,8 @@ function makeVoiceRow(v) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="opt-group opt-compare-panel">
|
<div class="opt-group opt-compare-panel">
|
||||||
<div class="opt-group-title">Voice match <span class="opt-chevron">⌄</span></div>
|
<div class="opt-group-title"><span class="opt-chevron"></span><span class="opt-title-text">Voice match</span></div>
|
||||||
|
<div class="opt-group-subtitle">Compare the saved WAV with a fresh TTS synthesis.</div>
|
||||||
<div class="opt-group-body">
|
<div class="opt-group-body">
|
||||||
<div class="opt-controls">
|
<div class="opt-controls">
|
||||||
<div class="opt-field wide"><label>Comparison backend</label><select class="opt-compare-backend">${styleBackendOptions('voice_clone')}</select></div>
|
<div class="opt-field wide"><label>Comparison backend</label><select class="opt-compare-backend">${styleBackendOptions('voice_clone')}</select></div>
|
||||||
@ -4554,15 +4563,16 @@ function makeVoiceRow(v) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="opt-group opt-style-panel">
|
${!isClone ? `<div class="opt-group opt-style-panel">
|
||||||
<div class="opt-group-title">Style variation <span class="opt-chevron">⌄</span></div>
|
<div class="opt-group-title"><span class="opt-chevron"></span><span class="opt-title-text">Style variation</span></div>
|
||||||
|
<div class="opt-group-subtitle">Create a styled variant and save it as a new voice (CustomVoice only).</div>
|
||||||
<div class="opt-group-body">
|
<div class="opt-group-body">
|
||||||
<div class="opt-controls">
|
<div class="opt-controls">
|
||||||
<div class="opt-field wide"><label>Style instruction</label><input class="opt-style-instruct" type="text" placeholder="cheerful, calm, excited"></div>
|
<div class="opt-field wide"><label>Style instruction</label><input class="opt-style-instruct" type="text" placeholder="cheerful, calm, excited"></div>
|
||||||
<div class="opt-field wide"><label>Style backend</label><select class="opt-style-backend">${styleBackendOptions('customvoice', true)}</select></div>
|
<div class="opt-field wide"><label>Style backend</label><select class="opt-style-backend">${styleBackendOptions('customvoice', true)}</select></div>
|
||||||
<div class="opt-field wide"><label>New voice ID</label><input class="opt-style-voice-id" type="text" placeholder="DE_F_Amala_happy"></div>
|
<div class="opt-field wide"><label>New voice ID</label><input class="opt-style-voice-id" type="text" placeholder="DE_F_Amala_happy"></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="opt-group-note">Preview first. Saving creates a new active WAV voice from the current reference text. Same-voice style only works when the selected backend knows this voice and honors <code>instruct</code>; Base/Streaming are fastest but often ignore style.</p>
|
<p class="opt-group-note">Preview first. Saving creates a new active WAV voice from the current reference text. Same-voice style only works when the selected backend knows this voice and honors <code>instruct</code>; CustomVoice is style-aware; Base/Streaming are fastest but often ignore style.</p>
|
||||||
<div class="backend-help opt-style-backend-help"></div>
|
<div class="backend-help opt-style-backend-help"></div>
|
||||||
<div class="opt-style-preview-box">
|
<div class="opt-style-preview-box">
|
||||||
<div class="opt-controls">
|
<div class="opt-controls">
|
||||||
@ -4574,16 +4584,17 @@ function makeVoiceRow(v) {
|
|||||||
<button class="btn-primary opt-save-style">Save style variation</button>
|
<button class="btn-primary opt-save-style">Save style variation</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>` : ''}
|
||||||
<div class="opt-group opt-maintenance open">
|
<div class="opt-group opt-maintenance open">
|
||||||
<div class="opt-group-title">Loudness <span class="opt-chevron">⌄</span></div>
|
<div class="opt-group-title"><span class="opt-chevron"></span><span class="opt-title-text">Loudness</span></div>
|
||||||
|
<div class="opt-group-subtitle">Normalize the volume of the reference audio file.</div>
|
||||||
<div class="opt-group-body">
|
<div class="opt-group-body">
|
||||||
<div class="opt-controls">
|
<div class="opt-controls">
|
||||||
<div class="opt-field"><label>Target dBFS</label><input class="opt-target-db" type="number" step="0.5" value="-20"></div>
|
<div class="opt-field"><label>Target dBFS</label><input class="opt-target-db" type="number" step="0.5" value="-20"></div>
|
||||||
<button class="btn-secondary opt-db-minus">-</button>
|
<button class="btn-secondary opt-db-minus">-</button>
|
||||||
<button class="btn-secondary opt-db-plus">+</button>
|
<button class="btn-secondary opt-db-plus">+</button>
|
||||||
<button class="btn-secondary opt-db-auto">Auto</button>
|
<button class="btn-secondary opt-db-auto">Auto</button>
|
||||||
<button class="btn-secondary opt-restart-tts">Restart TTS</button>
|
<button class="btn-secondary opt-restart-tts" title="Restarts the WAV-loading TTS backends (Voice Clone + Streaming) so they rescan the active_voices folder">Restart WAV engines</button>
|
||||||
<button class="btn-secondary opt-rebenchmark">Rebenchmark this one</button>
|
<button class="btn-secondary opt-rebenchmark">Rebenchmark this one</button>
|
||||||
<span class="opt-restart-note" hidden>Restart TTS before benchmarking.</span>
|
<span class="opt-restart-note" hidden>Restart TTS before benchmarking.</span>
|
||||||
</div>
|
</div>
|
||||||
@ -4596,7 +4607,8 @@ function makeVoiceRow(v) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
hydrateVoiceDuration(v, wrap.querySelector('.vr-length'));
|
const vrLengthEl = wrap.querySelector('.vr-length');
|
||||||
|
hydrateVoiceDuration(v, vrLengthEl);
|
||||||
|
|
||||||
// Photo upload
|
// Photo upload
|
||||||
const photoCell = wrap.querySelector('.vr-photo');
|
const photoCell = wrap.querySelector('.vr-photo');
|
||||||
@ -4640,7 +4652,7 @@ function makeVoiceRow(v) {
|
|||||||
markVoiceAudioChanged(v);
|
markVoiceAudioChanged(v);
|
||||||
dbValue.textContent = fmtDbfs(v);
|
dbValue.textContent = fmtDbfs(v);
|
||||||
dbCell.title = v.loudness ? `avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : '';
|
dbCell.title = v.loudness ? `avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : '';
|
||||||
wrap.querySelector('.vr-length').textContent = fmtDuration(v.duration);
|
if (vrLengthEl) vrLengthEl.textContent = fmtDuration(v.duration);
|
||||||
toast('Normalized: ' + v.id, 'success');
|
toast('Normalized: ' + v.id, 'success');
|
||||||
status(`Normalized ${v.id} to ${target} dBFS. Restart TTS before rebenchmarking.`);
|
status(`Normalized ${v.id} to ${target} dBFS. Restart TTS before rebenchmarking.`);
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
@ -4732,6 +4744,7 @@ function makeVoiceRow(v) {
|
|||||||
// Inline optimizer in Library
|
// Inline optimizer in Library
|
||||||
const editAudioBtn = wrap.querySelector('.edit-audio-btn');
|
const editAudioBtn = wrap.querySelector('.edit-audio-btn');
|
||||||
const optPanel = wrap.querySelector('.vr-optimizer');
|
const optPanel = wrap.querySelector('.vr-optimizer');
|
||||||
|
const vrTypeEl = wrap.querySelector('.vr-type');
|
||||||
const optCanvas = wrap.querySelector('.opt-wave');
|
const optCanvas = wrap.querySelector('.opt-wave');
|
||||||
const optStart = wrap.querySelector('.opt-start');
|
const optStart = wrap.querySelector('.opt-start');
|
||||||
const optEnd = wrap.querySelector('.opt-end');
|
const optEnd = wrap.querySelector('.opt-end');
|
||||||
@ -4771,8 +4784,8 @@ function makeVoiceRow(v) {
|
|||||||
optState.buffer = null;
|
optState.buffer = null;
|
||||||
optState.id = null;
|
optState.id = null;
|
||||||
await loadOptimizer();
|
await loadOptimizer();
|
||||||
wrap.querySelector('.vr-length').textContent = fmtDuration(v.duration);
|
if (vrLengthEl) vrLengthEl.textContent = fmtDuration(v.duration);
|
||||||
wrap.querySelector('.vr-length').title = String(v.duration ?? '');
|
if (vrLengthEl) vrLengthEl.title = String(v.duration ?? '');
|
||||||
dbValue.textContent = fmtDbfs(v);
|
dbValue.textContent = fmtDbfs(v);
|
||||||
dbCell.title = v.loudness ? `avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : '';
|
dbCell.title = v.loudness ? `avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : '';
|
||||||
};
|
};
|
||||||
@ -4790,8 +4803,12 @@ function makeVoiceRow(v) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const redrawOpt = () => {
|
const redrawOpt = () => {
|
||||||
if (optState.buffer) drawOptimizerWave(optCanvas, optState.buffer, parseFloat(optStart.value)||0, parseFloat(optEnd.value)||optState.duration);
|
if (!optState.buffer || optCanvas.clientWidth < 4) return;
|
||||||
|
drawOptimizerWave(optCanvas, optState.buffer, parseFloat(optStart.value)||0, parseFloat(optEnd.value)||optState.duration);
|
||||||
};
|
};
|
||||||
|
// Redraw whenever the canvas is resized (handles display:none → visible transition)
|
||||||
|
const _waveRO = new ResizeObserver(() => redrawOpt());
|
||||||
|
_waveRO.observe(optCanvas);
|
||||||
const syncCompareReferenceAudio = () => {
|
const syncCompareReferenceAudio = () => {
|
||||||
if (!optState.id || !optCompareRefAudio) return;
|
if (!optState.id || !optCompareRefAudio) return;
|
||||||
const src = '/api/audio/' + optState.id;
|
const src = '/api/audio/' + optState.id;
|
||||||
@ -4897,6 +4914,7 @@ function makeVoiceRow(v) {
|
|||||||
setOptStatus(v.needs_tts_restart ? 'Optimizer ready. Restart TTS before benchmarking this edit.' : 'Optimizer ready');
|
setOptStatus(v.needs_tts_restart ? 'Optimizer ready. Restart TTS before benchmarking this edit.' : 'Optimizer ready');
|
||||||
};
|
};
|
||||||
wrap._loadOptimizer = loadOptimizer;
|
wrap._loadOptimizer = loadOptimizer;
|
||||||
|
wrap._redrawOpt = redrawOpt;
|
||||||
|
|
||||||
editAudioBtn.addEventListener('click', async () => {
|
editAudioBtn.addEventListener('click', async () => {
|
||||||
editAudioBtn.disabled = true;
|
editAudioBtn.disabled = true;
|
||||||
@ -4916,16 +4934,16 @@ function makeVoiceRow(v) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
[optStart, optEnd].forEach(inp => inp.addEventListener('input', () => { redrawOpt(); syncCompareReferenceAudio(); }));
|
[optStart, optEnd].forEach(inp => inp.addEventListener('input', () => { redrawOpt(); syncCompareReferenceAudio(); }));
|
||||||
optStyleInstruct.addEventListener('input', () => {
|
optStyleInstruct?.addEventListener('input', () => {
|
||||||
if (!optStyleVoiceId.value.trim()) optStyleVoiceId.value = suggestedStyleVoiceId(v.id, optStyleInstruct.value);
|
if (!optStyleVoiceId.value.trim()) optStyleVoiceId.value = suggestedStyleVoiceId(v.id, optStyleInstruct.value);
|
||||||
});
|
});
|
||||||
optStyleBackend.addEventListener('change', () => updateStyleBackendHelp(wrap));
|
optStyleBackend?.addEventListener('change', () => updateStyleBackendHelp(wrap));
|
||||||
optCompareBackend.addEventListener('change', () => setOptStatus(`Comparison backend: ${optCompareBackend.options[optCompareBackend.selectedIndex]?.textContent || optCompareBackend.value}`));
|
optCompareBackend.addEventListener('change', () => setOptStatus(`Comparison backend: ${optCompareBackend.options[optCompareBackend.selectedIndex]?.textContent || optCompareBackend.value}`));
|
||||||
if (optCompareBackend.value === '') {
|
if (optCompareBackend.value === '') {
|
||||||
optCompareBackend.innerHTML = styleBackendOptions('voice_clone');
|
optCompareBackend.innerHTML = styleBackendOptions('voice_clone');
|
||||||
optCompareBackend.disabled = !availableTtsBackends().length;
|
optCompareBackend.disabled = !availableTtsBackends().length;
|
||||||
}
|
}
|
||||||
updateStyleBackendHelp(wrap);
|
if (optStyleBackend) updateStyleBackendHelp(wrap);
|
||||||
wrap.querySelector('.opt-db-minus').addEventListener('click', () => { optTargetDb.value = (Number(optTargetDb.value || -20) - 1).toFixed(1); });
|
wrap.querySelector('.opt-db-minus').addEventListener('click', () => { optTargetDb.value = (Number(optTargetDb.value || -20) - 1).toFixed(1); });
|
||||||
wrap.querySelector('.opt-db-plus').addEventListener('click', () => { optTargetDb.value = (Number(optTargetDb.value || -20) + 1).toFixed(1); });
|
wrap.querySelector('.opt-db-plus').addEventListener('click', () => { optTargetDb.value = (Number(optTargetDb.value || -20) + 1).toFixed(1); });
|
||||||
wrap.querySelector('.opt-db-auto').addEventListener('click', () => { optTargetDb.value = '-20.0'; });
|
wrap.querySelector('.opt-db-auto').addEventListener('click', () => { optTargetDb.value = '-20.0'; });
|
||||||
@ -5048,64 +5066,63 @@ function makeVoiceRow(v) {
|
|||||||
markVoiceAudioChanged(v);
|
markVoiceAudioChanged(v);
|
||||||
refInput.value = v.transcript; refInput.title = v.transcript;
|
refInput.value = v.transcript; refInput.title = v.transcript;
|
||||||
refTranscribeBtn.style.display = v.transcript ? 'none' : '';
|
refTranscribeBtn.style.display = v.transcript ? 'none' : '';
|
||||||
wrap.querySelector('.vr-type').textContent = voiceFileType(v).toUpperCase();
|
if (vrTypeEl) { vrTypeEl.textContent = voiceFileType(v).toUpperCase(); vrTypeEl.title = voiceFileType(v); }
|
||||||
wrap.querySelector('.vr-type').title = voiceFileType(v);
|
|
||||||
await refreshOptimizerFromVoice();
|
await refreshOptimizerFromVoice();
|
||||||
toast('Voice crop saved: ' + v.id, 'success');
|
toast('Voice crop saved: ' + v.id, 'success');
|
||||||
markTtsRestartRequired(saved.backup ? 'Crop saved and loaded. Restart TTS before rebenchmarking; undo is available.' : 'Crop saved and loaded. Restart TTS before rebenchmarking.');
|
markTtsRestartRequired(saved.backup ? 'Crop saved and loaded. Restart TTS before rebenchmarking; undo is available.' : 'Crop saved and loaded. Restart TTS before rebenchmarking.');
|
||||||
} catch(e) { toast('Save crop failed: ' + e.message, 'error'); setOptStatus('Save crop failed'); }
|
} catch(e) { toast('Save crop failed: ' + e.message, 'error'); setOptStatus('Save crop failed'); }
|
||||||
});
|
});
|
||||||
const styleVariationInput = () => {
|
if (optStyleInstruct) {
|
||||||
const style = optStyleInstruct.value.trim();
|
const styleVariationInput = () => {
|
||||||
const text = optTranscript.value.trim() || getBenchmarkSampleText();
|
const style = optStyleInstruct.value.trim();
|
||||||
const newId = optStyleVoiceId.value.trim() || suggestedStyleVoiceId(v.id, style);
|
const text = optTranscript.value.trim() || benchmarkSampleText();
|
||||||
if (!style) { toast('Enter a style instruction first', 'error'); optStyleInstruct.focus(); return null; }
|
const newId = optStyleVoiceId.value.trim() || suggestedStyleVoiceId(v.id, style);
|
||||||
if (!text) { toast('Enter reference text first', 'error'); optTranscript.focus(); return null; }
|
if (!style) { toast('Enter a style instruction first', 'error'); optStyleInstruct.focus(); return null; }
|
||||||
if (!/^[A-Za-z0-9_\-.]+$/.test(newId)) { toast('Invalid characters in new voice ID', 'error'); optStyleVoiceId.focus(); return null; }
|
if (!text) { toast('Enter reference text first', 'error'); optTranscript.focus(); return null; }
|
||||||
return {style, text, newId, backend: optStyleBackend.value};
|
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 () => {
|
optPreviewStyleBtn.addEventListener('click', async () => {
|
||||||
const input = styleVariationInput();
|
const input = styleVariationInput();
|
||||||
if (!input) return;
|
if (!input) return;
|
||||||
optPreviewStyleBtn.disabled = true;
|
optPreviewStyleBtn.disabled = true;
|
||||||
try {
|
try {
|
||||||
setOptStatus('Synthesizing style preview...');
|
setOptStatus('Synthesizing style preview...');
|
||||||
const blob = await fetchTtsPreviewBlob(v.id, input.text, 'wav', input.style, input.backend);
|
const blob = await fetchTtsPreviewBlob(v.id, input.text, 'wav', input.style, input.backend);
|
||||||
if (optStyleAudio.src) URL.revokeObjectURL(optStyleAudio.src);
|
if (optStyleAudio.src) URL.revokeObjectURL(optStyleAudio.src);
|
||||||
optStyleAudio.src = URL.createObjectURL(blob);
|
optStyleAudio.src = URL.createObjectURL(blob);
|
||||||
optStyleAudio.style.display = '';
|
optStyleAudio.style.display = '';
|
||||||
await optStyleAudio.play().catch(()=>{});
|
await optStyleAudio.play().catch(()=>{});
|
||||||
setOptStatus('Style preview ready. If it sounds right, save it as a new voice.');
|
setOptStatus('Style preview ready. If it sounds right, save it as a new voice.');
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
toast('Style preview failed: ' + e.message, 'error');
|
toast('Style preview failed: ' + e.message, 'error');
|
||||||
setOptStatus('Style preview failed');
|
setOptStatus('Style preview failed');
|
||||||
} finally {
|
} finally {
|
||||||
optPreviewStyleBtn.disabled = false;
|
optPreviewStyleBtn.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
optSaveStyleBtn.addEventListener('click', async () => {
|
||||||
optSaveStyleBtn.addEventListener('click', async () => {
|
const input = styleVariationInput();
|
||||||
const input = styleVariationInput();
|
if (!input) return;
|
||||||
if (!input) return;
|
optSaveStyleBtn.disabled = true;
|
||||||
optSaveStyleBtn.disabled = true;
|
try {
|
||||||
try {
|
setOptStatus(`Synthesizing style variation ${input.newId}...`);
|
||||||
setOptStatus(`Synthesizing style variation ${input.newId}...`);
|
const r = await fetch('/api/tts-style-variation', {method:'POST',headers:{'Content-Type':'application/json'},
|
||||||
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})});
|
||||||
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); }
|
||||||
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
const d = await r.json();
|
||||||
const d = await r.json();
|
toast('Style variation saved: ' + d.voice_id, 'success');
|
||||||
toast('Style variation saved: ' + d.voice_id, 'success');
|
setOptStatus('Style variation saved. Restart TTS so the backend scans the new voice.');
|
||||||
setOptStatus('Style variation saved. Restart TTS so the backend scans the new voice.');
|
await loadVoiceLibrary();
|
||||||
await loadVoiceLibrary();
|
renderIntegrationSnippets();
|
||||||
renderIntegrationSnippets();
|
} catch(e) {
|
||||||
} catch(e) {
|
toast('Style variation failed: ' + e.message, 'error');
|
||||||
toast('Style variation failed: ' + e.message, 'error');
|
setOptStatus('Style variation failed');
|
||||||
setOptStatus('Style variation failed');
|
} finally {
|
||||||
} finally {
|
optSaveStyleBtn.disabled = false;
|
||||||
optSaveStyleBtn.disabled = false;
|
}
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
wrap.querySelector('.opt-undo').addEventListener('click', async () => {
|
wrap.querySelector('.opt-undo').addEventListener('click', async () => {
|
||||||
if (!confirm(`Restore the original backup for "${v.id}"?`)) return;
|
if (!confirm(`Restore the original backup for "${v.id}"?`)) return;
|
||||||
@ -5120,8 +5137,7 @@ function makeVoiceRow(v) {
|
|||||||
v.path = d.path || v.path;
|
v.path = d.path || v.path;
|
||||||
v.file_type = d.file_type || v.file_type;
|
v.file_type = d.file_type || v.file_type;
|
||||||
markVoiceAudioChanged(v);
|
markVoiceAudioChanged(v);
|
||||||
wrap.querySelector('.vr-type').textContent = voiceFileType(v).toUpperCase();
|
if (vrTypeEl) { vrTypeEl.textContent = voiceFileType(v).toUpperCase(); vrTypeEl.title = voiceFileType(v); }
|
||||||
wrap.querySelector('.vr-type').title = voiceFileType(v);
|
|
||||||
await refreshOptimizerFromVoice();
|
await refreshOptimizerFromVoice();
|
||||||
toast('Original restored: ' + v.id, 'success');
|
toast('Original restored: ' + v.id, 'success');
|
||||||
markTtsRestartRequired('Original restored. Restart TTS before rebenchmarking.');
|
markTtsRestartRequired('Original restored. Restart TTS before rebenchmarking.');
|
||||||
@ -5149,17 +5165,19 @@ function makeVoiceRow(v) {
|
|||||||
optRestartTtsBtn.addEventListener('click', async () => {
|
optRestartTtsBtn.addEventListener('click', async () => {
|
||||||
optRestartTtsBtn.disabled = true;
|
optRestartTtsBtn.disabled = true;
|
||||||
try {
|
try {
|
||||||
setOptStatus('Restarting TTS so edited voices are rescanned...');
|
setOptStatus('Restarting WAV backends (Voice Clone + Streaming)…');
|
||||||
const r = await fetch('/api/tts/restart', { method:'POST' });
|
const r = await fetch('/api/tts/restart', { method:'POST' });
|
||||||
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
_voices.forEach(voice => { voice.needs_tts_restart = false; });
|
_voices.forEach(voice => { voice.needs_tts_restart = false; });
|
||||||
updateLibraryInsights();
|
updateLibraryInsights();
|
||||||
setVoiceRestartState(false, `TTS restarted (${d.container || 'container'}). Rebenchmark now uses the edited voice.`);
|
const names = (d.restarted || []).join(', ') || 'containers';
|
||||||
toast('TTS restarted. Voices rescanned.', 'success');
|
const errTxt = (d.errors || []).length ? ` (errors: ${d.errors.join('; ')})` : '';
|
||||||
|
setVoiceRestartState(false, `Restarted: ${names}${errTxt}. Rebenchmark now uses the edited voice.`);
|
||||||
|
toast(`TTS restarted: ${names}`, 'success');
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
toast('Restart TTS failed: ' + e.message, 'error');
|
toast('Restart TTS failed: ' + e.message, 'error');
|
||||||
setOptStatus('Restart TTS failed');
|
setOptStatus('Restart TTS failed: ' + e.message);
|
||||||
} finally {
|
} finally {
|
||||||
optRestartTtsBtn.disabled = false;
|
optRestartTtsBtn.disabled = false;
|
||||||
}
|
}
|
||||||
@ -5643,6 +5661,119 @@ $('save-preview-btn').addEventListener('click', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ── Performance benchmark ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
(function initPerfBenchmark() {
|
||||||
|
const perfBackendSel = $('perf-backend-select');
|
||||||
|
const perfVoiceSel = $('perf-voice-select');
|
||||||
|
const perfFetchBtn = $('perf-fetch-voices-btn');
|
||||||
|
const perfRunBtn = $('perf-run-btn');
|
||||||
|
const perfClearBtn = $('perf-clear-btn');
|
||||||
|
const perfProgress = $('perf-progress');
|
||||||
|
const perfResultsCard = $('perf-results-card');
|
||||||
|
const perfSummary = $('perf-summary');
|
||||||
|
const perfTbody = $('perf-tbody');
|
||||||
|
const perfText = $('perf-text');
|
||||||
|
const perfRunsSel = $('perf-runs');
|
||||||
|
if (!perfRunBtn) return;
|
||||||
|
|
||||||
|
let perfRows = [];
|
||||||
|
|
||||||
|
function populatePerfBackends() {
|
||||||
|
if (!perfBackendSel) return;
|
||||||
|
const cur = perfBackendSel.value;
|
||||||
|
perfBackendSel.innerHTML = availableTtsBackends().map(b =>
|
||||||
|
`<option value="${escHtml(b.id)}"${b.id===cur?' selected':''}>${escHtml(b.label)}</option>`
|
||||||
|
).join('') || '<option value="">No backends available</option>';
|
||||||
|
}
|
||||||
|
populatePerfBackends();
|
||||||
|
|
||||||
|
perfFetchBtn.addEventListener('click', async () => {
|
||||||
|
const backend = perfBackendSel.value;
|
||||||
|
if (!backend) { toast('Select a backend first', 'error'); return; }
|
||||||
|
perfFetchBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const rawVoices = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
|
||||||
|
const cur = perfVoiceSel.value;
|
||||||
|
perfVoiceSel.innerHTML = rawVoices.map(v => {
|
||||||
|
const id = backendVoiceId(v);
|
||||||
|
return `<option value="${escHtml(id)}"${id===cur?' selected':''}>${escHtml(id)}</option>`;
|
||||||
|
}).join('') || '<option value="">No voices</option>';
|
||||||
|
} catch(e) { toast('Fetch voices failed: '+e.message, 'error'); }
|
||||||
|
finally { perfFetchBtn.disabled = false; }
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderPerfTable() {
|
||||||
|
if (!perfRows.length) { perfResultsCard.style.display='none'; return; }
|
||||||
|
perfResultsCard.style.display = '';
|
||||||
|
perfTbody.innerHTML = perfRows.map((r, i) => {
|
||||||
|
const rtf = r.audioDuration > 0 ? (r.latencyMs / 1000 / r.audioDuration).toFixed(2) : '—';
|
||||||
|
const ok = r.ok;
|
||||||
|
return `<tr class="${ok?'':'perf-row-error'}">
|
||||||
|
<td>${i+1}</td>
|
||||||
|
<td>${escHtml(r.backend)}</td>
|
||||||
|
<td>${escHtml(r.voice)}</td>
|
||||||
|
<td>${ok ? r.latencyMs : '—'}</td>
|
||||||
|
<td>${ok && r.audioDuration > 0 ? r.audioDuration.toFixed(2) : '—'}</td>
|
||||||
|
<td>${ok ? rtf : '—'}</td>
|
||||||
|
<td>${ok ? '<span class="perf-ok">OK</span>' : `<span class="perf-err">${escHtml(r.error||'Error')}</span>`}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
const ok = perfRows.filter(r => r.ok);
|
||||||
|
if (ok.length) {
|
||||||
|
const avg = ok.reduce((s,r) => s + r.latencyMs, 0) / ok.length;
|
||||||
|
const min = Math.min(...ok.map(r => r.latencyMs));
|
||||||
|
const max = Math.max(...ok.map(r => r.latencyMs));
|
||||||
|
const avgRtf = ok.filter(r=>r.audioDuration>0).reduce((s,r)=>s+(r.latencyMs/1000/r.audioDuration),0) / Math.max(1,ok.filter(r=>r.audioDuration>0).length);
|
||||||
|
perfSummary.innerHTML = `
|
||||||
|
<span class="perf-stat"><strong>${Math.round(avg)} ms</strong> avg latency</span>
|
||||||
|
<span class="perf-stat"><strong>${min} ms</strong> best</span>
|
||||||
|
<span class="perf-stat"><strong>${max} ms</strong> worst</span>
|
||||||
|
<span class="perf-stat"><strong>${avgRtf.toFixed(2)}</strong> avg RTF</span>
|
||||||
|
<span class="perf-stat ${avgRtf < 1 ? 'perf-good' : 'perf-slow'}">${avgRtf < 1 ? '✅ 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 ───────────────────────────────────────────────────────────
|
// ── STT -> TTS ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let sttTtsSourceId = null;
|
let sttTtsSourceId = null;
|
||||||
@ -6164,6 +6295,237 @@ loadSettings().then(() => {
|
|||||||
})();
|
})();
|
||||||
loadVoiceLibrary().then(renderIntegrationSnippets).catch(e => status('Voice library load failed: ' + e.message));
|
loadVoiceLibrary().then(renderIntegrationSnippets).catch(e => status('Voice library load failed: ' + e.message));
|
||||||
|
|
||||||
|
// ── AI Backends section: category tabs ────────────────────────────────────
|
||||||
|
|
||||||
|
(function initLlmCatTabs() {
|
||||||
|
const tabs = document.querySelectorAll('.llm-cat-tab[data-llm-cat]');
|
||||||
|
const secs = document.querySelectorAll('.llm-section[data-llm-section]');
|
||||||
|
if (!tabs.length) return;
|
||||||
|
|
||||||
|
function showCat(cat) {
|
||||||
|
tabs.forEach(t => t.classList.toggle('active', t.dataset.llmCat === cat));
|
||||||
|
secs.forEach(s => { s.hidden = s.dataset.llmSection !== cat; });
|
||||||
|
if (cat === 'local') loadLocalContainers();
|
||||||
|
}
|
||||||
|
|
||||||
|
tabs.forEach(t => t.addEventListener('click', () => showCat(t.dataset.llmCat)));
|
||||||
|
// Load containers immediately since Local is the default active tab
|
||||||
|
loadLocalContainers();
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── Local Docker container management ─────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadLocalContainers() {
|
||||||
|
const grid = $('dc-grid');
|
||||||
|
if (!grid) return;
|
||||||
|
grid.innerHTML = '<div class="dc-loading">Checking container status…</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 ──────────────────────────────────────────────────────
|
// ── Collapsible cards ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
(function initCollapsibleCards() {
|
(function initCollapsibleCards() {
|
||||||
@ -6199,10 +6561,11 @@ loadVoiceLibrary().then(renderIntegrationSnippets).catch(e => status('Voice libr
|
|||||||
h2.prepend(chev);
|
h2.prepend(chev);
|
||||||
h2.classList.add('card-collapse-h2');
|
h2.classList.add('card-collapse-h2');
|
||||||
|
|
||||||
// Wrap every element after h2 in a single body div
|
// Wrap every element after h2 (skipping .card-subtitle which stays visible) in body
|
||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
body.className = 'card-col-body';
|
body.className = 'card-col-body';
|
||||||
let sib = h2.nextElementSibling;
|
let sib = h2.nextElementSibling;
|
||||||
|
while (sib && sib.classList.contains('card-subtitle')) sib = sib.nextElementSibling;
|
||||||
while (sib) { const nx = sib.nextElementSibling; body.appendChild(sib); sib = nx; }
|
while (sib) { const nx = sib.nextElementSibling; body.appendChild(sib); sib = nx; }
|
||||||
card.appendChild(body);
|
card.appendChild(body);
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
<div class="tab" data-tab="custom" data-backend-required="customvoice" tabindex="-1"><span class="tab-title">Voice Custom</span></div>
|
<div class="tab" data-tab="custom" data-backend-required="customvoice" tabindex="-1"><span class="tab-title">Voice Custom</span></div>
|
||||||
<div class="tab" data-tab="generation" data-backend-required="any_tts" tabindex="-1"><span class="tab-title">TTS Generation</span></div>
|
<div class="tab" data-tab="generation" data-backend-required="any_tts" tabindex="-1"><span class="tab-title">TTS Generation</span></div>
|
||||||
<div class="tab" data-tab="stt-tts" data-backend-required="any_tts" tabindex="-1"><span class="tab-title">STT-TTS</span></div>
|
<div class="tab" data-tab="stt-tts" data-backend-required="any_tts" tabindex="-1"><span class="tab-title">STT-TTS</span></div>
|
||||||
|
<div class="tab" data-tab="performance" data-backend-required="any_tts" tabindex="-1"><span class="tab-title">Performance</span></div>
|
||||||
<div class="tab" data-tab="routing" tabindex="-1"><span class="tab-title">Routing</span></div>
|
<div class="tab" data-tab="routing" tabindex="-1"><span class="tab-title">Routing</span></div>
|
||||||
<div class="tab" data-tab="integrations" tabindex="-1"><span class="tab-title">How to</span></div>
|
<div class="tab" data-tab="integrations" tabindex="-1"><span class="tab-title">How to</span></div>
|
||||||
<div class="tab" data-tab="getvoices" tabindex="-1"><span class="tab-title">Get Voices</span></div>
|
<div class="tab" data-tab="getvoices" tabindex="-1"><span class="tab-title">Get Voices</span></div>
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
<div class="tab-content" id="tab-source">
|
<div class="tab-content" id="tab-source">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Step 1 — Load audio</h2>
|
<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">
|
<div id="drop-zone">
|
||||||
<strong>Drop an audio / video file here</strong>
|
<strong>Drop an audio / video file here</strong>
|
||||||
<span>WAV · MP3 · OGG · FLAC · M4A · MP4 · MKV · WEBM</span>
|
<span>WAV · MP3 · OGG · FLAC · M4A · MP4 · MKV · WEBM</span>
|
||||||
@ -20,6 +20,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>YouTube / URL</h2>
|
<h2>YouTube / URL</h2>
|
||||||
|
<p class="card-subtitle">Download audio from a direct link or YouTube video.</p>
|
||||||
<div class="url-row">
|
<div class="url-row">
|
||||||
<input type="text" id="yt-url" placeholder="https://www.youtube.com/watch?v=...">
|
<input type="text" id="yt-url" placeholder="https://www.youtube.com/watch?v=...">
|
||||||
<button class="btn-primary" id="yt-btn">Download</button>
|
<button class="btn-primary" id="yt-btn">Download</button>
|
||||||
@ -28,6 +29,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Microphone</h2>
|
<h2>Microphone</h2>
|
||||||
|
<p class="card-subtitle">Record a fresh sample with live input level monitoring.</p>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button class="btn-red" id="rec-start-btn">● Record</button>
|
<button class="btn-red" id="rec-start-btn">● Record</button>
|
||||||
<button class="btn-secondary" id="rec-stop-btn" disabled>■ Stop</button>
|
<button class="btn-secondary" id="rec-stop-btn" disabled>■ Stop</button>
|
||||||
@ -35,7 +37,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card" id="waveform-card" style="display:none">
|
<div class="card" id="waveform-card" style="display:none">
|
||||||
<h2>Step 2 — 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 id="waveform-wrap"><div id="waveform"></div></div>
|
||||||
<div class="time-row">
|
<div class="time-row">
|
||||||
<label>Start (s) <input type="number" id="trim-start" value="0" min="0" step="0.1"></label>
|
<label>Start (s) <input type="number" id="trim-start" value="0" min="0" step="0.1"></label>
|
||||||
@ -55,7 +58,7 @@
|
|||||||
<div class="tab-content" id="tab-save">
|
<div class="tab-content" id="tab-save">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Step 3 — Name the voice</h2>
|
<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">
|
<div class="voice-id-row">
|
||||||
<input type="text" id="voice-id-input" placeholder="e.g. EN_F_Anna or MyCustomVoice" spellcheck="false">
|
<input type="text" id="voice-id-input" placeholder="e.g. EN_F_Anna or MyCustomVoice" spellcheck="false">
|
||||||
</div>
|
</div>
|
||||||
@ -90,7 +93,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Transcript (reference text)</h2>
|
<h2>Transcript (reference text)</h2>
|
||||||
<p class="note">Type what was spoken in the audio, or click Auto-transcribe to fill it automatically.</p>
|
<p class="card-subtitle">The spoken text that matches this audio — type it or auto-transcribe.</p>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button class="btn-secondary" id="transcribe-btn">💬 Auto-transcribe</button>
|
<button class="btn-secondary" id="transcribe-btn">💬 Auto-transcribe</button>
|
||||||
<span id="transcribe-status" style="font-size:13px;color:var(--subtext)"></span>
|
<span id="transcribe-status" style="font-size:13px;color:var(--subtext)"></span>
|
||||||
@ -99,11 +102,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Audio preview</h2>
|
<h2>Audio preview</h2>
|
||||||
|
<p class="card-subtitle">Play back the trimmed audio before saving to the library.</p>
|
||||||
<audio id="trim-audio" controls style="display:none"></audio>
|
<audio id="trim-audio" controls style="display:none"></audio>
|
||||||
<p id="no-audio-hint" class="note">No audio loaded yet. Load a file above (Step 1) or use Voice Design below.</p>
|
<p id="no-audio-hint" class="note">No audio loaded yet. Load a file above (Step 1) or use Voice Design below.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Step 4 — Save to library</h2>
|
<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">
|
<div class="btn-row">
|
||||||
<button class="btn-green" id="save-btn">💾 Save to Voice Library</button>
|
<button class="btn-green" id="save-btn">💾 Save to Voice Library</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
<div class="tab-content" id="tab-integrations">
|
<div class="tab-content" id="tab-integrations">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Use voices in other apps</h2>
|
<h2>Use voices in other apps</h2>
|
||||||
<p class="note">The editor creates and manages the voice files. External apps should connect to the Creator proxy or a reachable TTS backend, then use one of the active voice names.</p>
|
<p class="card-subtitle">The editor creates and manages the voice files. External apps should connect to the Creator proxy or a reachable TTS backend, then use one of the active voice names.</p>
|
||||||
<div class="integration-toolbar">
|
<div class="integration-toolbar">
|
||||||
<button class="btn-primary" id="show-api-btn" type="button">show api</button>
|
<button class="btn-primary" id="show-api-btn" type="button">show api</button>
|
||||||
<button class="btn-secondary" id="integration-refresh-btn">Refresh examples</button>
|
<button class="btn-secondary" id="integration-refresh-btn">Refresh examples</button>
|
||||||
@ -57,7 +57,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Important after voice changes</h2>
|
<h2>Important after voice changes</h2>
|
||||||
<p class="note">After enabling, hiding, adding, renaming, cropping, or normalising voices, restart the Qwen3-TTS container so its engine scans the updated <code>active_voices</code> folder. Then refresh the model or voice list in the target app.</p>
|
<p class="card-subtitle">After enabling, hiding, adding, renaming, cropping, or normalising voices, restart the Qwen3-TTS container so its engine scans the updated <code>active_voices</code> folder. Then refresh the model or voice list in the target app.</p>
|
||||||
<p class="note">Virtual VoiceDesign voices are different: they use saved prompt presets through this app's proxy and do not need a WAV export or TTS-container rescan. They do need the <code>faster-qwen3-tts-voicedesign</code> container reachable from Settings.</p>
|
<p class="note">Virtual VoiceDesign voices are different: they use saved prompt presets through this app's proxy and do not need a WAV export or TTS-container rescan. They do need the <code>faster-qwen3-tts-voicedesign</code> container reachable from Settings.</p>
|
||||||
</div>
|
</div>
|
||||||
</div><!-- /tab-integrations -->
|
</div><!-- /tab-integrations -->
|
||||||
|
|||||||
@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Describe the voice</h2>
|
<h2>Describe the voice</h2>
|
||||||
<p class="note">Describe the voice you want. Qwen3-TTS VoiceDesign will synthesise it from the description and sample text.</p>
|
<p class="card-subtitle">Describe the voice you want. Qwen3-TTS VoiceDesign will synthesise it from the description and sample text.</p>
|
||||||
<div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px">
|
<div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Gender</label>
|
<label>Gender</label>
|
||||||
@ -67,7 +67,7 @@
|
|||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Reference transcript & generate</h2>
|
<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>
|
<textarea id="design-sample-text" placeholder="The exact text the generated voice should speak...">Hello! This is a voice design sample. I hope you enjoy listening to me.</textarea>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button class="btn-teal" id="design-generate-btn" style="align-self:flex-end">✨ Generate voice</button>
|
<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">
|
<div class="card" id="design-result" style="display:none">
|
||||||
<h2>Generated voice</h2>
|
<h2>Generated voice</h2>
|
||||||
|
<p class="card-subtitle">Listen to the generated voice, then export it to the Voice Clone Library or download the WAV.</p>
|
||||||
<audio id="design-audio" controls></audio>
|
<audio id="design-audio" controls></audio>
|
||||||
<hr>
|
<hr>
|
||||||
<h2>Export to Voice Clone Library</h2>
|
<h2>Export to Voice Clone Library</h2>
|
||||||
@ -127,6 +128,7 @@
|
|||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Voice Design prompt library</h2>
|
<h2>Voice Design prompt library</h2>
|
||||||
|
<p class="card-subtitle">Save, load, and delete named voice description presets for quick reuse.</p>
|
||||||
<div class="preset-row">
|
<div class="preset-row">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Preset</label>
|
<label>Preset</label>
|
||||||
@ -143,7 +145,7 @@
|
|||||||
<div class="tab-content" id="tab-custom">
|
<div class="tab-content" id="tab-custom">
|
||||||
<div class="card" style="border-color: rgba(37,99,235,.25);">
|
<div class="card" style="border-color: rgba(37,99,235,.25);">
|
||||||
<h2>Custom Voice (Qwen style control)</h2>
|
<h2>Custom Voice (Qwen style control)</h2>
|
||||||
<p class="note">CustomVoice uses Qwen's configured premium/custom speakers. It is the best place to test style instructions when you can use one of the CustomVoice timbres.</p>
|
<p class="card-subtitle">CustomVoice uses Qwen's configured premium/custom speakers. It is the best place to test style instructions when you can use one of the CustomVoice timbres.</p>
|
||||||
<div class="backend-help">
|
<div class="backend-help">
|
||||||
<strong>Best for style control over configured target timbres.</strong>
|
<strong>Best for style control over configured target timbres.</strong>
|
||||||
<div class="backend-help-tags">
|
<div class="backend-help-tags">
|
||||||
|
|||||||
@ -2,28 +2,298 @@
|
|||||||
<span class="section-icon">🤖</span>
|
<span class="section-icon">🤖</span>
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<h2>AI Backends</h2>
|
<h2>AI Backends</h2>
|
||||||
<p>Connect cloud or local services for speech recognition, synthesis, and text generation.</p>
|
<p>Connect local or cloud services for speech recognition, synthesis, and text generation.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Category filter tabs -->
|
<!-- Category tabs: Local / Online -->
|
||||||
<div class="llm-cat-tabs" id="llm-cat-tabs">
|
<div class="llm-cat-tabs" id="llm-cat-tabs">
|
||||||
<button class="llm-cat-tab active" data-llm-cat="stt">🎤 ASR · STT</button>
|
<button class="llm-cat-tab active" data-llm-cat="local">💻 Local</button>
|
||||||
<button class="llm-cat-tab" data-llm-cat="tts">🔊 TTS</button>
|
<button class="llm-cat-tab" data-llm-cat="online">🌐 Online APIs</button>
|
||||||
<button class="llm-cat-tab" data-llm-cat="llm">🤖 LLM</button>
|
|
||||||
<button class="llm-cat-tab" data-llm-cat="local">💻 Local</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── STT section ─────────────────────────────────────────── -->
|
<!-- ── LOCAL section ──────────────────────────────────────── -->
|
||||||
<div class="llm-section" id="llm-sec-stt" data-llm-section="stt">
|
<div class="llm-section" id="llm-sec-local" data-llm-section="local">
|
||||||
<div class="llm-sec-header">
|
<div class="llm-sec-header">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="llm-sec-title">ASR · Speech-to-Text</h3>
|
<h3 class="llm-sec-title">Local AI Services</h3>
|
||||||
<p class="llm-sec-note">Used for auto-transcribing reference audio. The app calls these when you click <em>Re-recognise text</em>.</p>
|
<p class="llm-sec-note">Run everything on your own hardware — 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>
|
</div>
|
||||||
<span class="llm-free-badge">Free tiers available</span>
|
<span class="llm-free-badge">Free tiers available</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── ASR / STT ─────────────────────────────────────────── -->
|
||||||
|
<h4 class="llm-local-cat" style="margin-bottom:12px">🎤 ASR · Speech-to-Text</h4>
|
||||||
<div class="llm-service-grid">
|
<div class="llm-service-grid">
|
||||||
|
|
||||||
<div class="llm-card llm-card-highlight">
|
<div class="llm-card llm-card-highlight">
|
||||||
@ -107,18 +377,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div><!-- /stt -->
|
|
||||||
|
|
||||||
<!-- ── TTS section ─────────────────────────────────────────── -->
|
|
||||||
<div class="llm-section" id="llm-sec-tts" data-llm-section="tts" hidden>
|
|
||||||
<div class="llm-sec-header">
|
|
||||||
<div>
|
|
||||||
<h3 class="llm-sec-title">Text-to-Speech</h3>
|
|
||||||
<p class="llm-sec-note">Cloud TTS backends you can add as routing targets alongside your local cloned voices.</p>
|
|
||||||
</div>
|
|
||||||
<span class="llm-free-badge">Free tiers available</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<!-- ── TTS ──────────────────────────────────────────────── -->
|
||||||
|
<h4 class="llm-local-cat" style="margin-top:28px;margin-bottom:12px">🔊 Text-to-Speech</h4>
|
||||||
<div class="llm-service-grid">
|
<div class="llm-service-grid">
|
||||||
|
|
||||||
<div class="llm-card llm-card-highlight">
|
<div class="llm-card llm-card-highlight">
|
||||||
@ -193,18 +454,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div><!-- /tts -->
|
|
||||||
|
|
||||||
<!-- ── LLM section ─────────────────────────────────────────── -->
|
|
||||||
<div class="llm-section" id="llm-sec-llm" data-llm-section="llm" hidden>
|
|
||||||
<div class="llm-sec-header">
|
|
||||||
<div>
|
|
||||||
<h3 class="llm-sec-title">Large Language Models</h3>
|
|
||||||
<p class="llm-sec-note">Use LLMs to generate text for TTS, write scripts, clean transcripts, or power agentic voice pipelines.</p>
|
|
||||||
</div>
|
|
||||||
<span class="llm-free-badge">Free tiers available</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<!-- ── LLM ──────────────────────────────────────────────── -->
|
||||||
|
<h4 class="llm-local-cat" style="margin-top:28px;margin-bottom:12px">🤖 Large Language Models</h4>
|
||||||
<div class="llm-service-grid">
|
<div class="llm-service-grid">
|
||||||
|
|
||||||
<div class="llm-card llm-card-highlight">
|
<div class="llm-card llm-card-highlight">
|
||||||
@ -323,312 +575,5 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div><!-- /llm -->
|
</div><!-- /online -->
|
||||||
|
|
||||||
<!-- ── Local section ───────────────────────────────────────── -->
|
|
||||||
<div class="llm-section" id="llm-sec-local" data-llm-section="local" hidden>
|
|
||||||
<div class="llm-sec-header">
|
|
||||||
<div>
|
|
||||||
<h3 class="llm-sec-title">Local AI Services</h3>
|
|
||||||
<p class="llm-sec-note">Run everything on your own hardware — 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="tab-content" id="tab-routing">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>TTS voice routing</h2>
|
<h2>TTS voice routing</h2>
|
||||||
|
<p class="card-subtitle">Map incoming app voice names to real cloned voices and backends. Rules are matched top-to-bottom by app name, voice, and detected language.</p>
|
||||||
<div class="routing-help">
|
<div class="routing-help">
|
||||||
Recommended OpenAI-compatible TTS base URL: <code id="routing-proxy-url"></code>. If an app asks for the full speech endpoint instead of a base URL, use <code>/v1/audio/speech</code>.
|
Recommended OpenAI-compatible TTS base URL: <code id="routing-proxy-url"></code>. If an app asks for the full speech endpoint instead of a base URL, use <code>/v1/audio/speech</code>.
|
||||||
Rules can turn an incoming app voice such as <code>default</code> into a real cloned voice before the request is sent to Qwen3-TTS.
|
Rules can turn an incoming app voice such as <code>default</code> into a real cloned voice before the request is sent to Qwen3-TTS.
|
||||||
@ -50,7 +51,7 @@
|
|||||||
<div class="routing-sound-browser-head">
|
<div class="routing-sound-browser-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>Sound browser</h2>
|
<h2>Sound browser</h2>
|
||||||
<p class="note" id="routing-sound-browser-note">Preview uploaded route sounds, then apply one as a before or after sound.</p>
|
<p class="card-subtitle" id="routing-sound-browser-note">Preview uploaded route sounds, then apply one as a before or after sound.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="routing-sound-browser-actions">
|
<div class="routing-sound-browser-actions">
|
||||||
<button class="btn-secondary" id="routing-sound-refresh-btn" type="button">Refresh sounds</button>
|
<button class="btn-secondary" id="routing-sound-refresh-btn" type="button">Refresh sounds</button>
|
||||||
@ -65,7 +66,7 @@
|
|||||||
<div class="routing-log-head">
|
<div class="routing-log-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>Routing log</h2>
|
<h2>Routing log</h2>
|
||||||
<p class="note">Recent route tests and proxy requests. This log is kept in memory and resets when the server restarts.</p>
|
<p class="card-subtitle">Recent route tests and proxy requests. This log is kept in memory and resets when the server restarts.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="routing-log-actions">
|
<div class="routing-log-actions">
|
||||||
<select id="routing-log-filter" class="routing-log-filter" title="Filter routing log entries">
|
<select id="routing-log-filter" class="routing-log-filter" title="Filter routing log entries">
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
<div class="settings-title-row">
|
<div class="settings-title-row">
|
||||||
<div>
|
<div>
|
||||||
<h2>Settings</h2>
|
<h2>Settings</h2>
|
||||||
<p class="note">Configure the service URLs you actually use first. Advanced payloads, folders, and keys are tucked away below.</p>
|
<p class="card-subtitle">Configure the service URLs you actually use first. Advanced payloads, folders, and keys are tucked away below.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-status-pill">Local stack</div>
|
<div class="settings-status-pill">Local stack</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
<div class="tab-content" id="tab-getvoices">
|
<div class="tab-content" id="tab-getvoices">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Get voices</h2>
|
<h2>Get voices</h2>
|
||||||
|
<p class="card-subtitle">Scrape public voice clip sources, filter by language or gender, then import any direct audio file into your Clone library.</p>
|
||||||
<div class="getvoices-source-editor">
|
<div class="getvoices-source-editor">
|
||||||
<textarea id="getvoices-sources" spellcheck="false" aria-label="Voice source URLs"></textarea>
|
<textarea id="getvoices-sources" spellcheck="false" aria-label="Voice source URLs"></textarea>
|
||||||
<div class="getvoices-source-buttons">
|
<div class="getvoices-source-buttons">
|
||||||
|
|||||||
@ -10,10 +10,11 @@
|
|||||||
<div class="tab-content" id="tab-generation">
|
<div class="tab-content" id="tab-generation">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>TTS generation playground</h2>
|
<h2>TTS generation playground</h2>
|
||||||
<p class="note">Pick any reachable TTS backend, fetch its voices, then synthesize text. WAV/NVIDIA clone backends preserve reference identity; instruction-control backends follow style better.</p>
|
<p class="card-subtitle">Pick any reachable TTS backend, fetch its voices, then synthesize text. WAV/NVIDIA clone backends preserve reference identity; instruction-control backends follow style better.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Generate speech</h2>
|
<h2>Generate speech</h2>
|
||||||
|
<p class="card-subtitle">Select a backend, fetch its voice list, then synthesize any text with optional style instruction.</p>
|
||||||
<div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px">
|
<div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Backend</label>
|
<label>Backend</label>
|
||||||
@ -78,10 +79,11 @@
|
|||||||
<div class="tab-content" id="tab-stt-tts">
|
<div class="tab-content" id="tab-stt-tts">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>STT → TTS workspace</h2>
|
<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>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Source speech</h2>
|
<h2>Source speech</h2>
|
||||||
|
<p class="card-subtitle">Record from your microphone or upload an audio file, then transcribe it.</p>
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
<div class="s-field">
|
<div class="s-field">
|
||||||
<label>Speech recognition</label>
|
<label>Speech recognition</label>
|
||||||
@ -118,6 +120,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Synthesize transcription</h2>
|
<h2>Synthesize transcription</h2>
|
||||||
|
<p class="card-subtitle">Choose a TTS backend and voice, then generate audio from the transcribed text.</p>
|
||||||
<div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px">
|
<div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Backend</label>
|
<label>Backend</label>
|
||||||
@ -155,3 +158,58 @@
|
|||||||
<audio id="stt-tts-output-audio" controls style="display:none"></audio>
|
<audio id="stt-tts-output-audio" controls style="display:none"></audio>
|
||||||
</div>
|
</div>
|
||||||
</div><!-- /tab-stt-tts -->
|
</div><!-- /tab-stt-tts -->
|
||||||
|
|
||||||
|
<!-- Performance benchmarking -->
|
||||||
|
<div class="tab-content" id="tab-performance">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Performance benchmark</h2>
|
||||||
|
<p class="card-subtitle">Measure synthesis latency and real-time factor for any backend and voice.</p>
|
||||||
|
<div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px">
|
||||||
|
<div class="field">
|
||||||
|
<label>Backend</label>
|
||||||
|
<select id="perf-backend-select"><option value="">Checking backends...</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Voice</label>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<button class="btn-secondary" id="perf-fetch-voices-btn">Fetch voices</button>
|
||||||
|
<select id="perf-voice-select"><option value="">— select after fetch —</option></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Runs</label>
|
||||||
|
<select id="perf-runs">
|
||||||
|
<option value="1">1 run</option>
|
||||||
|
<option value="3" selected>3 runs</option>
|
||||||
|
<option value="5">5 runs</option>
|
||||||
|
<option value="10">10 runs</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Sample text</label>
|
||||||
|
<textarea id="perf-text" rows="3">Hello, how are you today? Please read this sample clearly for a fair voice benchmark.</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn-primary" id="perf-run-btn">▶ 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 .optimizer-grid { display: contents; }
|
||||||
.inspector-body .opt-status { display: none !important; }
|
.inspector-body .opt-status { display: none !important; }
|
||||||
|
|
||||||
/* Each opt-group as a clean collapsible card */
|
/* Each opt-group as a collapsible card — same visual language as .card */
|
||||||
.inspector-body .opt-group {
|
.inspector-body .opt-group {
|
||||||
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden;
|
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
/* no overflow:hidden here — it clips title text at rounded corners */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Title row — mirrors .card-collapse-h2 */
|
||||||
.inspector-body .opt-group-title {
|
.inspector-body .opt-group-title {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: 8px;
|
||||||
padding: 10px 10px; font-size: 14px; font-weight: 700; letter-spacing: .05em;
|
padding: 12px 14px; font-size: 11px; font-weight: 700; letter-spacing: .08em;
|
||||||
text-transform: uppercase; color: var(--subtext);
|
text-transform: uppercase; color: var(--subtext);
|
||||||
cursor: pointer; user-select: none;
|
cursor: pointer; user-select: none; transition: background .15s;
|
||||||
transition: background .15s;
|
border-radius: calc(var(--radius) - 1px); /* match inner corner */
|
||||||
}
|
}
|
||||||
.inspector-body .opt-group-title:hover { background: var(--border); }
|
.inspector-body .opt-group-title:hover { background: var(--panel); }
|
||||||
.opt-chevron {
|
.inspector-body .opt-group.open > .opt-group-title {
|
||||||
margin-left: auto; font-size: 14px; line-height: 1; color: var(--subtext);
|
border-bottom: 1px solid var(--border);
|
||||||
transition: transform .2s; display: inline-block; flex-shrink: 0;
|
border-bottom-left-radius: 0; border-bottom-right-radius: 0;
|
||||||
}
|
}
|
||||||
.inspector-body .opt-group.open > .opt-group-title { border-bottom: 1px solid var(--border); }
|
|
||||||
.inspector-body .opt-group.open .opt-chevron { transform: none; }
|
|
||||||
.inspector-body .opt-group:not(.open) .opt-chevron { transform: rotate(-90deg); }
|
|
||||||
|
|
||||||
/* Body: smooth height transition */
|
/* Chevron — identical to .card-chev */
|
||||||
|
.opt-chevron {
|
||||||
|
display: inline-block; flex-shrink: 0; width: 16px; height: 16px;
|
||||||
|
border-radius: 4px; background: var(--panel); border: 1px solid var(--border);
|
||||||
|
color: var(--accent); font-size: 10px; line-height: 16px; text-align: center;
|
||||||
|
transition: transform .2s;
|
||||||
|
}
|
||||||
|
.opt-chevron::before { content: '▾'; }
|
||||||
|
.inspector-body .opt-group:not(.open) .opt-chevron { transform: rotate(-90deg); }
|
||||||
|
.inspector-body .opt-group.open .opt-chevron { transform: none; }
|
||||||
|
|
||||||
|
/* Title text fills the rest of the row */
|
||||||
|
.opt-title-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* Subtitle — visible only when expanded, indented to align with title text */
|
||||||
|
.inspector-body .opt-group-subtitle {
|
||||||
|
font-size: 12px; color: var(--subtext); padding: 0 14px 10px 38px; line-height: 1.5;
|
||||||
|
}
|
||||||
|
.inspector-body .opt-group:not(.open) > .opt-group-subtitle { display: none; }
|
||||||
|
|
||||||
|
/* Body — hidden when collapsed */
|
||||||
.opt-group-body { overflow: hidden; }
|
.opt-group-body { overflow: hidden; }
|
||||||
.inspector-body .opt-group:not(.open) > .opt-group-body { display: none; }
|
.inspector-body .opt-group:not(.open) > .opt-group-body { display: none; }
|
||||||
.opt-group-footer { display: flex; justify-content: flex-end; padding: 8px 14px 12px; }
|
.opt-group-footer { display: flex; justify-content: flex-end; padding: 8px 14px 12px; }
|
||||||
@ -1381,19 +1401,22 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
padding-bottom: 7px; border-bottom: 1px solid var(--border);
|
padding-bottom: 7px; border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.llm-local-grid {
|
.llm-local-grid {
|
||||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); gap: 12px;
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); gap: 12px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
.llm-local-card {
|
.llm-local-card {
|
||||||
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
padding: 14px 16px; display: flex; flex-direction: column; gap: 9px;
|
padding: 14px 16px; display: flex; flex-direction: column; gap: 9px;
|
||||||
transition: box-shadow .2s, border-color .2s;
|
transition: box-shadow .2s, border-color .2s, background .2s;
|
||||||
}
|
}
|
||||||
.llm-local-card:hover { box-shadow: 0 4px 16px rgba(13,148,136,.12); border-color: rgba(13,148,136,.35); }
|
.llm-local-card:hover { box-shadow: 0 4px 16px rgba(13,148,136,.12); border-color: rgba(13,148,136,.35); }
|
||||||
.llm-local-card-running { border-color: var(--teal); background: rgba(13,148,136,.025); }
|
.llm-local-card-running { border-color: var(--teal); background: rgba(13,148,136,.025); }
|
||||||
|
.llm-local-card-online { border-color: rgba(22,163,74,.6); background: rgba(22,163,74,.04); }
|
||||||
|
.llm-local-card-offline { border-color: rgba(220,38,38,.4); background: rgba(220,38,38,.03); }
|
||||||
|
|
||||||
.llm-local-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
.llm-local-head { display: flex; align-items: center; gap: 8px; }
|
||||||
.llm-local-name { font-size: 13.5px; font-weight: 700; color: var(--text); }
|
.llm-local-icon { font-size: 20px; flex-shrink: 0; width: 26px; text-align: center; line-height: 1; }
|
||||||
|
.llm-local-name { font-size: 13.5px; font-weight: 700; color: var(--text); flex: 1; min-width: 0; }
|
||||||
.llm-local-compat {
|
.llm-local-compat {
|
||||||
font-size: 10px; font-weight: 600; color: var(--subtext);
|
font-size: 10px; font-weight: 600; color: var(--subtext);
|
||||||
background: var(--panel); border: 1px solid var(--border);
|
background: var(--panel); border: 1px solid var(--border);
|
||||||
@ -1405,11 +1428,33 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
.llm-local-desc { font-size: 12px; color: var(--subtext); line-height: 1.5; margin: 0; }
|
.llm-local-desc { font-size: 12px; color: var(--subtext); line-height: 1.5; margin: 0; }
|
||||||
|
|
||||||
.llm-local-url {
|
.llm-local-url {
|
||||||
display: flex; align-items: center; gap: 7px; flex-wrap: wrap;
|
display: flex; align-items: center; gap: 7px; flex-wrap: nowrap;
|
||||||
font-size: 14px; font-weight: 600; color: var(--subtext);
|
font-size: 12px; font-weight: 600; color: var(--subtext);
|
||||||
background: var(--panel); padding: 4px 9px; border-radius: 5px; border: 1px solid var(--border);
|
background: var(--panel); padding: 4px 9px; border-radius: 5px; border: 1px solid var(--border);
|
||||||
|
transition: border-color .15s;
|
||||||
}
|
}
|
||||||
|
.llm-local-url:focus-within { border-color: var(--accent); }
|
||||||
.llm-local-url code { font-family: monospace; font-size: 14px; color: var(--teal); }
|
.llm-local-url code { font-family: monospace; font-size: 14px; color: var(--teal); }
|
||||||
|
.llm-local-url-label { white-space: nowrap; flex-shrink: 0; }
|
||||||
|
.llm-local-url-inp {
|
||||||
|
flex: 1; min-width: 0; font-family: monospace; font-size: 13px; color: var(--teal);
|
||||||
|
background: transparent; border: none; outline: none; padding: 0;
|
||||||
|
}
|
||||||
|
.llm-local-url-inp::placeholder { color: var(--teal); opacity: .55; }
|
||||||
|
.llm-local-ping {
|
||||||
|
flex-shrink: 0; font-size: 10px; padding: 2px 9px; border-radius: 4px;
|
||||||
|
border: 1px solid var(--border); background: transparent; cursor: pointer;
|
||||||
|
color: var(--subtext); font-family: inherit; transition: border-color .12s, color .12s, background .12s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.llm-local-ping:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.llm-local-ping:disabled { opacity: .55; cursor: default; }
|
||||||
|
.llm-local-ping.ok {
|
||||||
|
border-color: var(--green); color: var(--green); background: rgba(22,163,74,.07);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.llm-local-ping.ok:hover { background: rgba(220,38,38,.07); border-color: var(--red); color: var(--red); }
|
||||||
|
.llm-local-ping.err { border-color: var(--red); color: var(--red); }
|
||||||
|
|
||||||
/* Code snippet block with dark theme */
|
/* Code snippet block with dark theme */
|
||||||
.llm-local-snippet { background: #1b1f2e; border-radius: 8px; overflow: hidden; }
|
.llm-local-snippet { background: #1b1f2e; border-radius: 8px; overflow: hidden; }
|
||||||
@ -1524,3 +1569,71 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
.el-pager-info { flex: 1; text-align: center; font-size: 12px; color: var(--subtext); }
|
.el-pager-info { flex: 1; text-align: center; font-size: 12px; color: var(--subtext); }
|
||||||
|
|
||||||
/* Bottom action buttons */
|
/* Bottom action buttons */
|
||||||
|
|
||||||
|
|
||||||
|
/* ── Card subtitle & opt-group subtitle ─────────────────────────────────── */
|
||||||
|
.card-subtitle { font-size: 12px; color: var(--subtext); line-height: 1.5; margin: 2px 0 6px; }
|
||||||
|
.opt-group-subtitle { font-size: 11px; color: var(--subtext); padding: 0 14px 6px; line-height: 1.4; }
|
||||||
|
|
||||||
|
/* ── Docker container grid ──────────────────────────────────────────────── */
|
||||||
|
.dc-section-head {
|
||||||
|
display: flex; align-items: flex-start; justify-content: space-between;
|
||||||
|
gap: 12px; margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
.dc-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(310px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.dc-loading {
|
||||||
|
grid-column: 1 / -1; padding: 18px 0; text-align: center;
|
||||||
|
font-size: 13px; color: var(--subtext);
|
||||||
|
}
|
||||||
|
.dc-load-err { color: var(--red) !important; }
|
||||||
|
.dc-card {
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); padding: 14px 16px;
|
||||||
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
.dc-card:hover { box-shadow: var(--shadow); border-color: rgba(37,99,235,.3); }
|
||||||
|
.dc-card-running { border-color: rgba(22,163,74,.45); background: rgba(22,163,74,.025); }
|
||||||
|
.dc-card-head {
|
||||||
|
display: flex; align-items: center; gap: 8px; margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.dc-icon { font-size: 20px; flex-shrink: 0; width: 26px; text-align: center; line-height: 1; }
|
||||||
|
.dc-dot {
|
||||||
|
width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.dc-running { background: var(--green); box-shadow: 0 0 0 3px rgba(22,163,74,.18); }
|
||||||
|
.dc-stopped { background: var(--yellow); }
|
||||||
|
.dc-absent { background: var(--border); }
|
||||||
|
.dc-name { font-size: 13px; font-weight: 700; color: var(--text); flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.dc-badges { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
|
||||||
|
.dc-role {
|
||||||
|
font-size: 10px; font-weight: 700; padding: 1px 5px; border-radius: 3px;
|
||||||
|
background: var(--accent); color: #fff; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.dc-port {
|
||||||
|
font-size: 10px; font-weight: 600; padding: 1px 5px; border-radius: 3px;
|
||||||
|
background: var(--panel); border: 1px solid var(--border); color: var(--subtext);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.dc-status-label { font-size: 11px; color: var(--subtext); margin-bottom: 2px; }
|
||||||
|
.dc-desc { font-size: 12px; color: var(--subtext); line-height: 1.45; margin: 0; flex: 1; }
|
||||||
|
.dc-actions { margin-top: 6px; display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.dc-btn { font-size: 12px; padding: 4px 12px; }
|
||||||
|
|
||||||
|
/* ── Performance benchmark ──────────────────────────────────────────────── */
|
||||||
|
.perf-table-wrap { overflow-x: auto; margin-top: 12px; }
|
||||||
|
.perf-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
.perf-table th, .perf-table td { padding: 7px 10px; border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; }
|
||||||
|
.perf-table th { color: var(--subtext); font-size: 11px; text-transform: uppercase; letter-spacing: .05em; }
|
||||||
|
.perf-row-error td { color: var(--subtext); }
|
||||||
|
.perf-ok { color: var(--green); font-weight: 600; }
|
||||||
|
.perf-err { color: var(--red); }
|
||||||
|
.perf-summary { display: flex; flex-wrap: wrap; gap: 10px 18px; padding: 10px 0 4px; font-size: 13px; }
|
||||||
|
.perf-stat { display: flex; align-items: center; gap: 5px; }
|
||||||
|
.perf-good { color: var(--green); }
|
||||||
|
.perf-slow { color: var(--yellow); }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user