diff --git a/docker-compose.yml b/docker-compose.yml index 7b2192d..de3aaef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,15 +10,23 @@ services: ports: - "7890:7890" + # Give the app user access to the Docker socket (GID of docker group on host) + group_add: + - "${DOCKER_GID:-988}" + volumes: - ${VOICE_HOST_DIR:-./voices}:/voices:rw - tts-voice-creator-clone-and-design-2:/home/app/.config/tts-voice-creator + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./static:/app/static:ro + - ./server.py:/app/server.py:ro environment: - PYTHONUNBUFFERED=1 - VOICES_DIR=/voices - OUTPUT_DIR=/voices/active_voices - - TTS_CONTAINER_NAME=faster-qwen3-tts + # Containers that load WAV files and need a restart after voice changes + - TTS_CONTAINER_NAMES=faster-qwen3-tts-voiceclone,faster-qwen3-tts-streaming - VOICE_DESIGN_MODEL=Qwen3-TTS-12Hz-1.7B-VoiceDesign extra_hosts: diff --git a/server.py b/server.py index b83ae96..1300d54 100644 --- a/server.py +++ b/server.py @@ -47,6 +47,7 @@ _NVIDIA_CLONE_DEFAULT = os.environ.get("NVIDIA_TTS_CLONE_URL", "http://host.dock _NVIDIA_ZEROSHOT_DEFAULT = os.environ.get("NVIDIA_ZEROSHOT_TTS_URL", _NVIDIA_CLONE_DEFAULT) _NVIDIA_FLOW_DEFAULT = os.environ.get("NVIDIA_FLOW_TTS_URL", "http://host.docker.internal:8094") _TTS_CONTAINER = os.environ.get("TTS_CONTAINER_NAME", "faster-qwen3-tts") +_TTS_CONTAINERS_RAW = os.environ.get("TTS_CONTAINER_NAMES", "") # comma-separated override _VOICE_DESIGN_MODEL = os.environ.get("VOICE_DESIGN_MODEL", "Qwen3-TTS-12Hz-1.7B-VoiceDesign") _VOICE_TARGET_DBFS = float(os.environ.get("VOICE_TARGET_DBFS", "-20.0")) _VOICE_PEAK_DBFS = float(os.environ.get("VOICE_PEAK_DBFS", "-1.0")) @@ -192,6 +193,62 @@ def _copy_limited(src, dest, limit: int) -> int: return total +def _decode_chunked_bytes(data: bytes) -> bytes: + result = bytearray() + pos = 0 + while pos < len(data): + end = data.find(b"\r\n", pos) + if end < 0: + break + try: + size = int(data[pos:end].split(b";")[0].strip(), 16) + except ValueError: + break + if size == 0: + break + pos = end + 2 + result.extend(data[pos:pos + size]) + pos += size + 2 + return bytes(result) + + +def _docker_get_json(path: str) -> tuple[int, dict | list | None]: + sock_path = os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock") + if not Path(sock_path).exists(): + raise RuntimeError(f"Docker socket not found: {sock_path}") + request = ( + f"GET {path} HTTP/1.1\r\n" + "Host: docker\r\n" + "Connection: close\r\n\r\n" + ).encode("utf-8") + chunks: list[bytes] = [] + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: + sock.settimeout(10) + sock.connect(sock_path) + sock.sendall(request) + while True: + data = sock.recv(65536) + if not data: + break + chunks.append(data) + raw = b"".join(chunks) + header_end = raw.find(b"\r\n\r\n") + if header_end < 0: + raise RuntimeError("Invalid Docker HTTP response") + header_str = raw[:header_end].decode("utf-8", errors="replace") + body_bytes = raw[header_end + 4:] + status_line = header_str.splitlines()[0] + m = re.match(r"HTTP/\S+\s+(\d+)", status_line) + if not m: + raise RuntimeError(f"Invalid Docker status line: {status_line!r}") + code = int(m.group(1)) + if "transfer-encoding: chunked" in header_str.lower(): + body_bytes = _decode_chunked_bytes(body_bytes) + if not body_bytes.strip(): + return code, None + return code, json.loads(body_bytes) + + def _docker_post(path: str) -> tuple[int, str]: sock_path = os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock") if not Path(sock_path).exists(): @@ -1456,6 +1513,102 @@ async def import_source_audio(request: Request): return {"id": fid, "duration": _duration(wav), "filename": filename or dest.name, "audio_url": audio_url} +# ── Audio proxy (CORS bypass for external sources) ──────────────────────────── + +_PROXY_AUDIO_DOMAINS: set[str] = { + "drive.usercontent.google.com", + "drive.google.com", + "aiartes.com", + "freesound.org", + "lanceblairvo.com", + "raw.githubusercontent.com", + "sample-files.com", +} + + +@app.get("/api/proxy-audio") +async def proxy_audio(url: str): + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + raise HTTPException(400, "Only http/https URLs are supported") + domain = parsed.netloc.lower().lstrip("www.") + if not any(domain == d or domain.endswith("." + d) for d in _PROXY_AUDIO_DOMAINS): + raise HTTPException(403, f"Domain not in audio proxy allowlist: {parsed.netloc}") + try: + resp = requests.get(url, headers=_VOICE_SOURCE_HEADERS, timeout=30, stream=True) + resp.raise_for_status() + except Exception as e: + raise HTTPException(502, f"Proxy fetch failed: {e}") + content_type = resp.headers.get("content-type", "audio/mpeg") + + def _stream(): + for chunk in resp.iter_content(65536): + if chunk: + yield chunk + + return StreamingResponse( + _stream(), media_type=content_type, + headers={"Cache-Control": "public, max-age=3600", "Access-Control-Allow-Origin": "*"}, + ) + + +# ── Quick voice import (download + save directly to library) ────────────────── + +@app.post("/api/quick-import-voice") +async def quick_import_voice(request: Request): + data = await request.json() + audio_url = _source_import_url(str(data.get("audio_url") or "")) + voice_id = re.sub(r"[^A-Za-z0-9_\-\.]", "_", str(data.get("voice_id") or "").strip())[:80] + if not voice_id: + raise HTTPException(400, "voice_id is required") + transcript = str(data.get("transcript") or "").strip() + + suffix = Path(urlparse(audio_url).path).suffix.lower() + if suffix not in _AUDIO_EXTS: + suffix = ".bin" + dest = TEMP_DIR / f"{uuid.uuid4().hex}{suffix}" + try: + with requests.get(audio_url, headers=_VOICE_SOURCE_HEADERS, timeout=30, stream=True) as r: + r.raise_for_status() + total = 0 + with dest.open("wb") as f: + for chunk in r.iter_content(1024 * 1024): + if not chunk: + continue + total += len(chunk) + if total > _source_download_limit("_MAX_UPLOAD_BYTES", 1024): + raise HTTPException(413, "Downloaded audio is too large") + f.write(chunk) + wav = _to_wav_24k(dest) + except HTTPException: + raise + except Exception as e: + raise HTTPException(400, f"Audio download failed: {e}") + + settings = _load_settings() + out_dir = _active_voices_dir(settings) + out_dir.mkdir(parents=True, exist_ok=True) + + final_id = voice_id + if (out_dir / f"{final_id}.wav").exists(): + for i in range(2, 1000): + candidate = f"{voice_id}_{i}" + if not (out_dir / f"{candidate}.wav").exists(): + final_id = candidate + break + + wav_dest = out_dir / f"{final_id}.wav" + _remove_audio_variants(out_dir, final_id) + loudness = _export_normalized_wav(wav, wav_dest) + if transcript: + (out_dir / f"{final_id}.reference.txt").write_text(transcript, encoding="utf-8") + meta = _load_meta(wav_dest) + meta["enabled"] = True + meta["loudness"] = loudness + _save_meta(wav_dest, meta) + return {"voice_id": final_id, "loudness": loudness} + + # ── YouTube download (SSE) ──────────────────────────────────────────────────── @app.get("/api/download-yt") @@ -2599,6 +2752,77 @@ def _normalize_voice_source_urls(urls: list[str] | None = None) -> list[str]: return normalized[:24] +_GDRIVE_MIME_EXT = { + "audio/mpeg": "mp3", "audio/mp3": "mp3", + "audio/wav": "wav", "audio/x-wav": "wav", + "audio/ogg": "ogg", "audio/vorbis": "ogg", + "audio/flac": "flac", "audio/x-flac": "flac", + "audio/aac": "aac", "audio/x-aac": "aac", + "audio/x-m4a": "m4a", "audio/m4a": "m4a", "audio/mp4": "m4a", +} + + +def _scrape_google_drive_folder(url: str) -> dict: + m = re.search(r"/folders/([A-Za-z0-9_-]+)", url) + if not m: + return _generic_audio_page_source(url) + folder_id = m.group(1) + source_id = _voice_source_id(url) + source_name = "Google Drive" + embed_url = f"https://drive.google.com/embeddedfolderview?id={folder_id}#list" + try: + body = _source_get(embed_url).text + except Exception: + return _generic_audio_page_source(url) + + items = [] + seen: set[str] = set() + for chunk in re.split(r"(?=
(.*?)
', chunk) + mime_m = re.search(r"type/([a-zA-Z0-9/+\-]+)", chunk) + if not eid or not title_m: + continue + file_id = eid.group(1) + if file_id in seen: + continue + seen.add(file_id) + filename = title_m.group(1).strip() + mime = mime_m.group(1).lower() if mime_m else "" + if not mime.startswith("audio"): + continue + ext = _GDRIVE_MIME_EXT.get(mime, Path(filename).suffix.lstrip(".").lower() or "mp3") + gdrive_dl = f"https://drive.usercontent.google.com/download?id={file_id}&export=download&authuser=0" + proxy_url = f"/api/proxy-audio?url={quote(gdrive_dl)}" + stem = Path(filename).stem.replace("-", " ").replace("_", " ").strip() + lang = _guess_language(filename) + gender = _guess_gender(filename) + item = _source_item( + source_id, source_name, + stem, "Google Drive audio", + url, + audio_url=proxy_url, + file_type=ext, + category="Google Drive", + description=f"Audio file from Google Drive folder.", + language=lang, + gender=gender, + ) + item["import_url"] = gdrive_dl + items.append(item) + + if not items: + items.append(_source_item( + source_id, source_name, + "Google Drive folder", "Source page", + url, + category="Google Drive", + description="No audio files found in this Google Drive folder. It may be private or empty.", + )) + + return _source_result(source_id, source_name, url, f"{len(items)} audio files from Google Drive folder.", items) + + def _scrape_voice_source_url(url: str) -> dict: lower = url.lower() if "aiartes.com/voiceai" in lower: @@ -2613,6 +2837,8 @@ def _scrape_voice_source_url(url: str) -> dict: return _scrape_freesound_scott_simpson() if "lanceblairvo.com/raw-voiceover-samples" in lower: return _scrape_lanceblair_raw_samples() + if "drive.google.com/drive/folders/" in lower or "drive.google.com/open?id=" in lower: + return _scrape_google_drive_folder(url) if _DIRECT_AUDIO_RE.search(lower): return _direct_audio_source(url) return _generic_audio_page_source(url) @@ -2916,30 +3142,159 @@ def _clear_tts_restart_flags(settings: dict | None = None) -> int: cleared += 1 return cleared +def _tts_container_names() -> list[str]: + """Return the list of TTS container names to restart, from env vars.""" + multi = os.environ.get("TTS_CONTAINER_NAMES", _TTS_CONTAINERS_RAW).strip() + if multi: + return [c.strip() for c in multi.split(",") if c.strip()] + single = os.environ.get("TTS_CONTAINER_NAME", _TTS_CONTAINER).strip() + return [single] if single else [] + + @app.post("/api/tts/restart") async def restart_tts_container(): - container = os.environ.get("TTS_CONTAINER_NAME", _TTS_CONTAINER).strip() - if not container: - raise HTTPException(400, "TTS_CONTAINER_NAME is not configured") + containers = _tts_container_names() + if not containers: + raise HTTPException(400, "No TTS container names configured (set TTS_CONTAINER_NAMES in docker-compose.yml)") - path = f"/containers/{quote(container, safe='')}/restart?t=10" - try: - code, raw = _docker_post(path) - except PermissionError: - raise HTTPException(502, "No permission to access /var/run/docker.sock from this container") - except Exception as e: - raise HTTPException(502, f"Docker restart failed: {e}") - - if code not in (204, 304): - detail = raw.split("\r\n\r\n", 1)[-1].strip() or f"Docker API returned HTTP {code}" - raise HTTPException(502, detail) + results = [] + errors = [] + for container in containers: + path = f"/containers/{quote(container, safe='')}/restart?t=10" + try: + code, raw = _docker_post(path) + if code not in (204, 304): + detail = raw.split("\r\n\r\n", 1)[-1].strip() or f"HTTP {code}" + errors.append(f"{container}: {detail}") + else: + results.append(container) + except PermissionError: + raise HTTPException(502, "No permission to access /var/run/docker.sock — is the socket mounted in docker-compose.yml?") + except Exception as e: + errors.append(f"{container}: {e}") cleared = 0 try: cleared = _clear_tts_restart_flags() except Exception as e: logger.warning("Could not clear TTS restart flags: %s", e) - return {"ok": True, "container": container, "cleared_restart_flags": cleared} + + if errors and not results: + raise HTTPException(502, "; ".join(errors)) + + return { + "ok": True, + "restarted": results, + "errors": errors, + "cleared_restart_flags": cleared, + } + + +@app.get("/api/tts/restart-info") +async def tts_restart_info(): + containers = _tts_container_names() + sock_ok = Path(os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")).exists() + return {"containers": containers, "socket_available": sock_ok} + + +# ── Local Docker container management ──────────────────────────────────────── + +_LOCAL_CONTAINER_DEFS: list[dict] = [ + {"name": "faster-qwen3-tts-voiceclone", "label": "Qwen3 TTS · Voice Clone", "role": "tts", "port": 8020, "stack": "faster-qwen3-tts-dgx-spark-voice-design", "image": "martinb78/faster-qwen3-tts-dgx-spark:v4", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "WAV voice cloning. Scans active_voices at startup — restart after adding or editing voices."}, + {"name": "faster-qwen3-tts-voicedesign", "label": "Qwen3 TTS · Voice Design", "role": "tts", "port": 8021, "stack": "faster-qwen3-tts-dgx-spark-voice-design", "image": "martinb78/faster-qwen3-tts-dgx-spark:v4", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "Instruction-based voice design. Describe a voice in words — no WAV needed."}, + {"name": "faster-qwen3-tts-customvoice", "label": "Qwen3 TTS · Custom Voice", "role": "tts", "port": 8022, "stack": "faster-qwen3-tts-dgx-spark-voice-design", "image": "martinb78/faster-qwen3-tts-dgx-spark:v4", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "Style control over configured premium speakers such as Ryan, Vivian, and Serena."}, + {"name": "faster-qwen3-tts-streaming", "label": "Qwen3 TTS · Streaming", "role": "tts", "port": 8023, "stack": "faster-qwen3-tts-dgx-spark-voice-design", "image": "martinb78/qwen3-tts-streaming-dgx-spark:latest", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "Low-latency progressive WAV streaming for voice clone voices."}, + {"name": "parakeet-asr", "label": "NVIDIA Parakeet ASR", "role": "stt", "port": 8090, "stack": "nvidia-speech-gateway", "image": "parakeet-tdt-v3-spark:latest", "repo": "https://github.com/mARTin-B78/dgx-spark-parakeet-asr", "description": "NVIDIA Parakeet GPU-accelerated speech recognition on port 8090."}, + {"name": "magpie-tts", "label": "NVIDIA Magpie TTS", "role": "tts", "port": 8091, "stack": "nvidia-speech-gateway", "image": "nvcr.io/nim/nvidia/magpie-tts-multilingual:latest","repo": "https://github.com/mARTin-B78/dgx-spark-parakeet-asr", "description": "NVIDIA neural TTS. GPU-accelerated, high-quality multilingual synthesis."}, + {"name": "parakeet-rnnt-nim", "label": "NVIDIA Parakeet RNNT NIM", "role": "stt", "port": 8092, "stack": "nvidia-speech-gateway", "image": "nvcr.io/nim/nvidia/parakeet-1b-rnnt-multilingual:latest","repo": "https://github.com/mARTin-B78/dgx-spark-parakeet-asr", "description": "NVIDIA Parakeet RNNT NIM multilingual ASR on port 8092."}, +] + + +def _container_status(name: str) -> dict: + try: + code, data = _docker_get_json(f"/containers/{quote(name, safe='')}/json") + if code == 404: + return {"status": "not_found"} + if code == 200 and isinstance(data, dict): + state = data.get("State", {}) + return { + "status": state.get("Status", "unknown"), + "running": state.get("Running", False), + "paused": state.get("Paused", False), + "started_at": state.get("StartedAt", ""), + "image": data.get("Config", {}).get("Image", ""), + } + return {"status": "error", "detail": f"HTTP {code}"} + except Exception as e: + return {"status": "error", "detail": str(e)} + + +@app.get("/api/local-containers") +async def get_local_containers(): + sock_ok = Path(os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")).exists() + results = [] + for defn in _LOCAL_CONTAINER_DEFS: + entry = {k: v for k, v in defn.items()} + if sock_ok: + entry.update(_container_status(defn["name"])) + else: + entry["status"] = "no_socket" + results.append(entry) + return {"containers": results, "socket_available": sock_ok} + + +@app.post("/api/local-containers/{name}/start") +async def start_local_container(name: str): + if not any(c["name"] == name for c in _LOCAL_CONTAINER_DEFS): + raise HTTPException(404, f"Unknown container: {name}") + try: + code, _ = _docker_post(f"/containers/{quote(name, safe='')}/start") + except Exception as e: + raise HTTPException(502, f"Docker start failed: {e}") + if code not in (204, 304): + raise HTTPException(502, f"Docker API returned HTTP {code}") + return {"ok": True, "name": name, **_container_status(name)} + + +@app.post("/api/local-containers/{name}/stop") +async def stop_local_container(name: str): + if not any(c["name"] == name for c in _LOCAL_CONTAINER_DEFS): + raise HTTPException(404, f"Unknown container: {name}") + try: + code, _ = _docker_post(f"/containers/{quote(name, safe='')}/stop?t=10") + except Exception as e: + raise HTTPException(502, f"Docker stop failed: {e}") + if code not in (204, 304): + raise HTTPException(502, f"Docker API returned HTTP {code}") + return {"ok": True, "name": name, **_container_status(name)} + + +@app.post("/api/local-containers/{name}/restart") +async def restart_local_container(name: str): + if not any(c["name"] == name for c in _LOCAL_CONTAINER_DEFS): + raise HTTPException(404, f"Unknown container: {name}") + try: + code, _ = _docker_post(f"/containers/{quote(name, safe='')}/restart?t=10") + except Exception as e: + raise HTTPException(502, f"Docker restart failed: {e}") + if code not in (204, 304): + raise HTTPException(502, f"Docker API returned HTTP {code}") + return {"ok": True, "name": name, **_container_status(name)} + + +@app.get("/api/probe-url") +async def probe_url(url: str): + """Server-side reachability check — avoids browser CORS restrictions.""" + try: + r = requests.get(url, timeout=5, allow_redirects=True, + headers={"User-Agent": "TTS-Voice-Creator/probe"}) + return {"ok": True, "status": r.status_code} + except requests.exceptions.ConnectionError: + return {"ok": False, "error": "Connection refused"} + except requests.exceptions.Timeout: + return {"ok": False, "error": "Timeout"} + except Exception as e: + return {"ok": False, "error": str(e)} @app.post("/api/tts/restart-flags/clear") diff --git a/static/app.js b/static/app.js index c6168dd..9cb9efb 100644 --- a/static/app.js +++ b/static/app.js @@ -104,10 +104,10 @@ function selectVoice(wrap) {
- + ${flagIconHtml} - + ${escHtml(langCode)} ${escHtml(genderLabelHtml)} @@ -202,7 +202,7 @@ function selectVoice(wrap) { if (flagIconEl) flagIconEl.innerHTML = fi; await saveMeta(voiceId, { flag: cc }); }; - flagSpan.addEventListener('dblclick', () => { + flagSpan.addEventListener('click', () => { const items = (FLAG_OPTIONS[langCode.toUpperCase()] || ALL_FLAGS).map(([cc, name]) => [cc.toLowerCase(), name]); createSearchablePicker(flagSpan, items, applyFlag, { placeholder: 'Country or accent…', @@ -211,7 +211,7 @@ function selectVoice(wrap) { }); }); - // ── Language — double-click lang code to change ─────────────────────────── + // ── Language — click lang code to change ───────────────────────────────── const langCodeEl = inspector.querySelector('.insp-lang-code'); const langWrap = inspector.querySelector('.insp-lang-wrap'); @@ -220,7 +220,7 @@ function selectVoice(wrap) { if (langCodeEl) langCodeEl.textContent = newLang; await saveMeta(voiceId, { lang: newLang }); }; - langWrap?.addEventListener('dblclick', () => { + langWrap?.addEventListener('click', () => { const items = LANGS.map(l => [l, LANGUAGE_LABELS[l] ? `${LANGUAGE_LABELS[l]} (${l})` : l]); createSearchablePicker(langWrap, items, applyLang, { placeholder: 'Language…', @@ -357,14 +357,16 @@ function selectVoice(wrap) { const maintTitle = body.querySelector('.opt-maintenance .opt-group-title'); if (maintTitle) { - maintTitle.innerHTML = `Loudness Current ${escHtml(dbfs)} dBFS `; + maintTitle.innerHTML = `Loudness Current ${escHtml(dbfs)} dBFS`; } // ── Collapsible opt-groups ──────────────────────────────────────────────── body.querySelectorAll('.opt-group').forEach(group => { const title = group.querySelector(':scope > .opt-group-title'); if (!title) return; - title.addEventListener('click', () => group.classList.toggle('open')); + title.addEventListener('click', () => { + group.classList.toggle('open'); + }); }); // Track extracted elements for restoreToRow @@ -837,8 +839,9 @@ function renderGetVoices() { const thumb = item.image_url ? `` : `
`; const audio = item.audio_url ? `` : ''; const audioLink = item.audio_url ? `Open audio` : ''; - const canGetVoice = !!item.audio_url; - const getVoice = canGetVoice ? `` : ''; + const canGetVoice = !!(item.import_url || item.audio_url); + const importUrl = item.import_url || item.audio_url; + const getVoice = canGetVoice ? `` : ''; const type = item.file_type ? `${escHtml(String(item.file_type).toUpperCase())}` : ''; const language = item.language ? `${escHtml(item.language)}` : ''; const gender = item.gender ? `${escHtml(item.gender)}` : ''; @@ -963,32 +966,24 @@ function setLibAddSourcePreview(meta = {}) { async function getSourceVoiceInLibrary(meta) { if (!meta.url) { toast('This source has no direct audio URL', 'error'); return; } - switchTab('library'); - const panel = $('lib-add-panel'); - if (panel && !panel.classList.contains('open')) panel.classList.add('open'); const lang = sourceLanguageCode(meta.language); const gender = sourceGenderCode(meta.gender, meta.name); - if ($('lib-add-url')) $('lib-add-url').value = meta.url; - if ($('lib-add-lang')) $('lib-add-lang').value = lang; - if ($('lib-add-gender')) $('lib-add-gender').value = gender; - if ($('lib-add-voice-id')) $('lib-add-voice-id').value = suggestedVoiceIdFromSourceName(meta.name, lang, gender); - if ($('lib-add-transcript')) $('lib-add-transcript').value = ''; - if (window.libAddState) window.libAddState.pendingSource = {...meta, language: lang, gender}; - setLibAddSourcePreview({...meta, language: lang, gender}); - setLibAddStatus(`Importing source audio: ${meta.name || 'external voice'}...`); + const voiceId = suggestedVoiceIdFromSourceName(meta.name, lang, gender); + toast(`Importing ${meta.name || 'voice'}…`, 'info'); try { - const r = await fetch('/api/import-source-audio', { + const r = await fetch('/api/quick-import-voice', { method: 'POST', headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({audio_url: meta.url, name: meta.name || 'Source voice'}) + body: JSON.stringify({audio_url: meta.url, voice_id: voiceId, transcript: ''}) }); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); - loadLibAddAudio(d.id, d.duration, meta.name || 'Source voice'); - setLibAddStatus(`Source voice loaded: ${meta.name || 'external voice'}${meta.imageUrl ? ' (image will attach on save)' : ''}`); - toast('Voice imported into Voice Clone', 'success'); + _pendingSelectId = d.voice_id; + navTo('s-voices'); + _libraryLoadPromise = null; + await loadVoiceLibrary(); + toast(`Saved as ${d.voice_id}`, 'success'); } catch(e) { - setLibAddStatus('Source import failed'); - toast('Source import failed: ' + e.message, 'error'); + toast('Import failed: ' + e.message, 'error'); } } @@ -1852,6 +1847,12 @@ async function refreshTtsBackendAvailability(selected = '') { sel.innerHTML = styleBackendOptions(prev); sel.disabled = !availableTtsBackends().length; }); + const perfSel = $('perf-backend-select'); + if (perfSel) { + const prev = perfSel.value; + perfSel.innerHTML = ttsBackendOptions(prev); + perfSel.disabled = !availableTtsBackends().length; + } updateBackendHelp(); updateStyleBackendHelp(); updateBackendDependentTabs(); @@ -2797,6 +2798,7 @@ $('save-btn').addEventListener('click', async () => { // ══════════════════════════════════════════════════════════════════════════ let _voices = []; +let _pendingSelectId = null; let _sortField = 'id'; let _sortDir = 1; // 1 = asc, -1 = desc let _libraryIssueFilter = ''; @@ -4251,6 +4253,10 @@ function renderVoiceList() { return; } filtered.forEach(v => list.appendChild(makeVoiceRow(v))); + if (_pendingSelectId) { + const wrap = list.querySelector(`[data-id="${CSS.escape(_pendingSelectId)}"]`); + if (wrap) { _pendingSelectId = null; selectVoice(wrap); } + } syncSortHeaders(); } @@ -4506,7 +4512,8 @@ function makeVoiceRow(v) {
-
Reference audio · crop
+
Reference audio · crop
+
Crop the saved WAV to 3–20 s of clean speech.
@@ -4522,7 +4529,8 @@ function makeVoiceRow(v) {
-
Reference transcript
+
Reference transcript
+
The spoken text that matches this voice recording.
@@ -4534,7 +4542,8 @@ function makeVoiceRow(v) {
-
Voice match
+
Voice match
+
Compare the saved WAV with a fresh TTS synthesis.
@@ -4554,15 +4563,16 @@ function makeVoiceRow(v) {
-
-
Style variation
+ ${!isClone ? `
+
Style variation
+
Create a styled variant and save it as a new voice (CustomVoice only).
-

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 instruct; Base/Streaming are fastest but often ignore style.

+

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 instruct; CustomVoice is style-aware; Base/Streaming are fastest but often ignore style.

@@ -4574,16 +4584,17 @@ function makeVoiceRow(v) {
-
+
` : ''}
-
Loudness
+
Loudness
+
Normalize the volume of the reference audio file.
- +
@@ -4596,7 +4607,8 @@ function makeVoiceRow(v) {
`; - hydrateVoiceDuration(v, wrap.querySelector('.vr-length')); + const vrLengthEl = wrap.querySelector('.vr-length'); + hydrateVoiceDuration(v, vrLengthEl); // Photo upload const photoCell = wrap.querySelector('.vr-photo'); @@ -4640,7 +4652,7 @@ function makeVoiceRow(v) { markVoiceAudioChanged(v); dbValue.textContent = fmtDbfs(v); dbCell.title = v.loudness ? `avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : ''; - wrap.querySelector('.vr-length').textContent = fmtDuration(v.duration); + if (vrLengthEl) vrLengthEl.textContent = fmtDuration(v.duration); toast('Normalized: ' + v.id, 'success'); status(`Normalized ${v.id} to ${target} dBFS. Restart TTS before rebenchmarking.`); } catch(e) { @@ -4732,6 +4744,7 @@ function makeVoiceRow(v) { // Inline optimizer in Library const editAudioBtn = wrap.querySelector('.edit-audio-btn'); const optPanel = wrap.querySelector('.vr-optimizer'); + const vrTypeEl = wrap.querySelector('.vr-type'); const optCanvas = wrap.querySelector('.opt-wave'); const optStart = wrap.querySelector('.opt-start'); const optEnd = wrap.querySelector('.opt-end'); @@ -4771,8 +4784,8 @@ function makeVoiceRow(v) { optState.buffer = null; optState.id = null; await loadOptimizer(); - wrap.querySelector('.vr-length').textContent = fmtDuration(v.duration); - wrap.querySelector('.vr-length').title = String(v.duration ?? ''); + if (vrLengthEl) vrLengthEl.textContent = fmtDuration(v.duration); + if (vrLengthEl) vrLengthEl.title = String(v.duration ?? ''); dbValue.textContent = fmtDbfs(v); dbCell.title = v.loudness ? `avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : ''; }; @@ -4790,8 +4803,12 @@ function makeVoiceRow(v) { }; const redrawOpt = () => { - if (optState.buffer) drawOptimizerWave(optCanvas, optState.buffer, parseFloat(optStart.value)||0, parseFloat(optEnd.value)||optState.duration); + if (!optState.buffer || optCanvas.clientWidth < 4) return; + drawOptimizerWave(optCanvas, optState.buffer, parseFloat(optStart.value)||0, parseFloat(optEnd.value)||optState.duration); }; + // Redraw whenever the canvas is resized (handles display:none → visible transition) + const _waveRO = new ResizeObserver(() => redrawOpt()); + _waveRO.observe(optCanvas); const syncCompareReferenceAudio = () => { if (!optState.id || !optCompareRefAudio) return; const src = '/api/audio/' + optState.id; @@ -4897,6 +4914,7 @@ function makeVoiceRow(v) { setOptStatus(v.needs_tts_restart ? 'Optimizer ready. Restart TTS before benchmarking this edit.' : 'Optimizer ready'); }; wrap._loadOptimizer = loadOptimizer; + wrap._redrawOpt = redrawOpt; editAudioBtn.addEventListener('click', async () => { editAudioBtn.disabled = true; @@ -4916,16 +4934,16 @@ function makeVoiceRow(v) { } }); [optStart, optEnd].forEach(inp => inp.addEventListener('input', () => { redrawOpt(); syncCompareReferenceAudio(); })); - optStyleInstruct.addEventListener('input', () => { + optStyleInstruct?.addEventListener('input', () => { if (!optStyleVoiceId.value.trim()) optStyleVoiceId.value = suggestedStyleVoiceId(v.id, optStyleInstruct.value); }); - optStyleBackend.addEventListener('change', () => updateStyleBackendHelp(wrap)); + optStyleBackend?.addEventListener('change', () => updateStyleBackendHelp(wrap)); optCompareBackend.addEventListener('change', () => setOptStatus(`Comparison backend: ${optCompareBackend.options[optCompareBackend.selectedIndex]?.textContent || optCompareBackend.value}`)); if (optCompareBackend.value === '') { optCompareBackend.innerHTML = styleBackendOptions('voice_clone'); optCompareBackend.disabled = !availableTtsBackends().length; } - updateStyleBackendHelp(wrap); + if (optStyleBackend) updateStyleBackendHelp(wrap); wrap.querySelector('.opt-db-minus').addEventListener('click', () => { optTargetDb.value = (Number(optTargetDb.value || -20) - 1).toFixed(1); }); wrap.querySelector('.opt-db-plus').addEventListener('click', () => { optTargetDb.value = (Number(optTargetDb.value || -20) + 1).toFixed(1); }); wrap.querySelector('.opt-db-auto').addEventListener('click', () => { optTargetDb.value = '-20.0'; }); @@ -5048,64 +5066,63 @@ function makeVoiceRow(v) { markVoiceAudioChanged(v); refInput.value = v.transcript; refInput.title = v.transcript; refTranscribeBtn.style.display = v.transcript ? 'none' : ''; - wrap.querySelector('.vr-type').textContent = voiceFileType(v).toUpperCase(); - wrap.querySelector('.vr-type').title = voiceFileType(v); + if (vrTypeEl) { vrTypeEl.textContent = voiceFileType(v).toUpperCase(); vrTypeEl.title = voiceFileType(v); } await refreshOptimizerFromVoice(); toast('Voice crop saved: ' + v.id, 'success'); markTtsRestartRequired(saved.backup ? 'Crop saved and loaded. Restart TTS before rebenchmarking; undo is available.' : 'Crop saved and loaded. Restart TTS before rebenchmarking.'); } catch(e) { toast('Save crop failed: ' + e.message, 'error'); setOptStatus('Save crop failed'); } }); - const styleVariationInput = () => { - const style = optStyleInstruct.value.trim(); - const text = optTranscript.value.trim() || getBenchmarkSampleText(); - const newId = optStyleVoiceId.value.trim() || suggestedStyleVoiceId(v.id, style); - if (!style) { toast('Enter a style instruction first', 'error'); optStyleInstruct.focus(); return null; } - if (!text) { toast('Enter reference text first', 'error'); optTranscript.focus(); return null; } - if (!/^[A-Za-z0-9_\-.]+$/.test(newId)) { toast('Invalid characters in new voice ID', 'error'); optStyleVoiceId.focus(); return null; } - return {style, text, newId, backend: optStyleBackend.value}; - }; - - optPreviewStyleBtn.addEventListener('click', async () => { - const input = styleVariationInput(); - if (!input) return; - optPreviewStyleBtn.disabled = true; - try { - setOptStatus('Synthesizing style preview...'); - const blob = await fetchTtsPreviewBlob(v.id, input.text, 'wav', input.style, input.backend); - if (optStyleAudio.src) URL.revokeObjectURL(optStyleAudio.src); - optStyleAudio.src = URL.createObjectURL(blob); - optStyleAudio.style.display = ''; - await optStyleAudio.play().catch(()=>{}); - setOptStatus('Style preview ready. If it sounds right, save it as a new voice.'); - } catch(e) { - toast('Style preview failed: ' + e.message, 'error'); - setOptStatus('Style preview failed'); - } finally { - optPreviewStyleBtn.disabled = false; - } - }); - - optSaveStyleBtn.addEventListener('click', async () => { - const input = styleVariationInput(); - if (!input) return; - optSaveStyleBtn.disabled = true; - try { - setOptStatus(`Synthesizing style variation ${input.newId}...`); - const r = await fetch('/api/tts-style-variation', {method:'POST',headers:{'Content-Type':'application/json'}, - body:JSON.stringify({source_voice:v.id, voice_id:input.newId, text:input.text, instruct:input.style, backend:input.backend})}); - if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } - const d = await r.json(); - toast('Style variation saved: ' + d.voice_id, 'success'); - setOptStatus('Style variation saved. Restart TTS so the backend scans the new voice.'); - await loadVoiceLibrary(); - renderIntegrationSnippets(); - } catch(e) { - toast('Style variation failed: ' + e.message, 'error'); - setOptStatus('Style variation failed'); - } finally { - optSaveStyleBtn.disabled = false; - } - }); + if (optStyleInstruct) { + const styleVariationInput = () => { + const style = optStyleInstruct.value.trim(); + const text = optTranscript.value.trim() || benchmarkSampleText(); + const newId = optStyleVoiceId.value.trim() || suggestedStyleVoiceId(v.id, style); + if (!style) { toast('Enter a style instruction first', 'error'); optStyleInstruct.focus(); return null; } + if (!text) { toast('Enter reference text first', 'error'); optTranscript.focus(); return null; } + if (!/^[A-Za-z0-9_\-.]+$/.test(newId)) { toast('Invalid characters in new voice ID', 'error'); optStyleVoiceId.focus(); return null; } + return {style, text, newId, backend: optStyleBackend.value}; + }; + optPreviewStyleBtn.addEventListener('click', async () => { + const input = styleVariationInput(); + if (!input) return; + optPreviewStyleBtn.disabled = true; + try { + setOptStatus('Synthesizing style preview...'); + const blob = await fetchTtsPreviewBlob(v.id, input.text, 'wav', input.style, input.backend); + if (optStyleAudio.src) URL.revokeObjectURL(optStyleAudio.src); + optStyleAudio.src = URL.createObjectURL(blob); + optStyleAudio.style.display = ''; + await optStyleAudio.play().catch(()=>{}); + setOptStatus('Style preview ready. If it sounds right, save it as a new voice.'); + } catch(e) { + toast('Style preview failed: ' + e.message, 'error'); + setOptStatus('Style preview failed'); + } finally { + optPreviewStyleBtn.disabled = false; + } + }); + optSaveStyleBtn.addEventListener('click', async () => { + const input = styleVariationInput(); + if (!input) return; + optSaveStyleBtn.disabled = true; + try { + setOptStatus(`Synthesizing style variation ${input.newId}...`); + const r = await fetch('/api/tts-style-variation', {method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({source_voice:v.id, voice_id:input.newId, text:input.text, instruct:input.style, backend:input.backend})}); + if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } + const d = await r.json(); + toast('Style variation saved: ' + d.voice_id, 'success'); + setOptStatus('Style variation saved. Restart TTS so the backend scans the new voice.'); + await loadVoiceLibrary(); + renderIntegrationSnippets(); + } catch(e) { + toast('Style variation failed: ' + e.message, 'error'); + setOptStatus('Style variation failed'); + } finally { + optSaveStyleBtn.disabled = false; + } + }); + } wrap.querySelector('.opt-undo').addEventListener('click', async () => { if (!confirm(`Restore the original backup for "${v.id}"?`)) return; @@ -5120,8 +5137,7 @@ function makeVoiceRow(v) { v.path = d.path || v.path; v.file_type = d.file_type || v.file_type; markVoiceAudioChanged(v); - wrap.querySelector('.vr-type').textContent = voiceFileType(v).toUpperCase(); - wrap.querySelector('.vr-type').title = voiceFileType(v); + if (vrTypeEl) { vrTypeEl.textContent = voiceFileType(v).toUpperCase(); vrTypeEl.title = voiceFileType(v); } await refreshOptimizerFromVoice(); toast('Original restored: ' + v.id, 'success'); markTtsRestartRequired('Original restored. Restart TTS before rebenchmarking.'); @@ -5149,17 +5165,19 @@ function makeVoiceRow(v) { optRestartTtsBtn.addEventListener('click', async () => { optRestartTtsBtn.disabled = true; try { - setOptStatus('Restarting TTS so edited voices are rescanned...'); + setOptStatus('Restarting WAV backends (Voice Clone + Streaming)…'); const r = await fetch('/api/tts/restart', { method:'POST' }); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); _voices.forEach(voice => { voice.needs_tts_restart = false; }); updateLibraryInsights(); - setVoiceRestartState(false, `TTS restarted (${d.container || 'container'}). Rebenchmark now uses the edited voice.`); - toast('TTS restarted. Voices rescanned.', 'success'); + const names = (d.restarted || []).join(', ') || 'containers'; + const errTxt = (d.errors || []).length ? ` (errors: ${d.errors.join('; ')})` : ''; + setVoiceRestartState(false, `Restarted: ${names}${errTxt}. Rebenchmark now uses the edited voice.`); + toast(`TTS restarted: ${names}`, 'success'); } catch(e) { toast('Restart TTS failed: ' + e.message, 'error'); - setOptStatus('Restart TTS failed'); + setOptStatus('Restart TTS failed: ' + e.message); } finally { optRestartTtsBtn.disabled = false; } @@ -5643,6 +5661,119 @@ $('save-preview-btn').addEventListener('click', () => { }); +// ── Performance benchmark ───────────────────────────────────────────────── + +(function initPerfBenchmark() { + const perfBackendSel = $('perf-backend-select'); + const perfVoiceSel = $('perf-voice-select'); + const perfFetchBtn = $('perf-fetch-voices-btn'); + const perfRunBtn = $('perf-run-btn'); + const perfClearBtn = $('perf-clear-btn'); + const perfProgress = $('perf-progress'); + const perfResultsCard = $('perf-results-card'); + const perfSummary = $('perf-summary'); + const perfTbody = $('perf-tbody'); + const perfText = $('perf-text'); + const perfRunsSel = $('perf-runs'); + if (!perfRunBtn) return; + + let perfRows = []; + + function populatePerfBackends() { + if (!perfBackendSel) return; + const cur = perfBackendSel.value; + perfBackendSel.innerHTML = availableTtsBackends().map(b => + `` + ).join('') || ''; + } + 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 ``; + }).join('') || ''; + } 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 ` + ${i+1} + ${escHtml(r.backend)} + ${escHtml(r.voice)} + ${ok ? r.latencyMs : '—'} + ${ok && r.audioDuration > 0 ? r.audioDuration.toFixed(2) : '—'} + ${ok ? rtf : '—'} + ${ok ? 'OK' : `${escHtml(r.error||'Error')}`} + `; + }).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 = ` + ${Math.round(avg)} ms avg latency + ${min} ms best + ${max} ms worst + ${avgRtf.toFixed(2)} avg RTF + ${avgRtf < 1 ? '✅ Real-time capable' : '⚠ Slower than real-time'} + `; + } else { perfSummary.innerHTML = 'All runs failed'; } + } + + perfClearBtn.addEventListener('click', () => { + perfRows = []; + renderPerfTable(); + perfProgress.style.display = 'none'; + }); + + perfRunBtn.addEventListener('click', async () => { + const backend = perfBackendSel.value; + const voice = perfVoiceSel.value; + const text = perfText.value.trim(); + const runs = parseInt(perfRunsSel.value) || 3; + if (!backend) { toast('Select a backend first', 'error'); return; } + if (!voice) { toast('Fetch and select a voice first', 'error'); return; } + if (!text) { toast('Enter sample text', 'error'); return; } + perfRunBtn.disabled = true; + perfProgress.style.display = ''; + for (let i = 0; i < runs; i++) { + perfProgress.textContent = `Run ${i+1} / ${runs}…`; + const row = { backend, voice, ok: false, latencyMs: 0, audioDuration: 0, error: '' }; + try { + const t0 = performance.now(); + const blob = await fetchTtsPreviewBlob(voice, text, 'wav', '', backend); + row.latencyMs = Math.round(performance.now() - t0); + row.ok = true; + try { + const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + const buf = await audioCtx.decodeAudioData(await blob.arrayBuffer()); + row.audioDuration = buf.duration; + audioCtx.close(); + } catch(_) {} + } catch(e) { row.error = e.message; } + perfRows.push(row); + renderPerfTable(); + } + perfProgress.textContent = `Done — ${runs} run${runs>1?'s':''} completed.`; + perfRunBtn.disabled = false; + }); +})(); + // ── STT -> TTS ─────────────────────────────────────────────────────────── let sttTtsSourceId = null; @@ -6164,6 +6295,237 @@ loadSettings().then(() => { })(); loadVoiceLibrary().then(renderIntegrationSnippets).catch(e => status('Voice library load failed: ' + e.message)); +// ── AI Backends section: category tabs ──────────────────────────────────── + +(function initLlmCatTabs() { + const tabs = document.querySelectorAll('.llm-cat-tab[data-llm-cat]'); + const secs = document.querySelectorAll('.llm-section[data-llm-section]'); + if (!tabs.length) return; + + function showCat(cat) { + tabs.forEach(t => t.classList.toggle('active', t.dataset.llmCat === cat)); + secs.forEach(s => { s.hidden = s.dataset.llmSection !== cat; }); + if (cat === 'local') loadLocalContainers(); + } + + tabs.forEach(t => t.addEventListener('click', () => showCat(t.dataset.llmCat))); + // Load containers immediately since Local is the default active tab + loadLocalContainers(); +})(); + +// ── Local Docker container management ───────────────────────────────────── + +async function loadLocalContainers() { + const grid = $('dc-grid'); + if (!grid) return; + grid.innerHTML = '
Checking container status…
'; + try { + const r = await fetch('/api/local-containers'); + const d = await r.json(); + renderLocalContainers(d.containers || []); + } catch (e) { + grid.innerHTML = `
Could not reach server: ${escHtml(e.message)}
`; + } +} + +function renderLocalContainers(containers) { + const grid = $('dc-grid'); + if (!grid) return; + if (!containers.length) { + grid.innerHTML = '
No containers defined.
'; + 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 ? `:${c.port}` : ''; + const installed = st !== 'not_found'; + const icon = DC_ICONS[c.name] || roleIcon[c.role] || '📦'; + + const n = escHtml(c.name); + const actions = installed + ? (st === 'running' + ? ` + ` + : ` + `) + : (c.repo + ? `View on GitHub ↗` + : ''); + + return `
+
+ ${icon} + + ${escHtml(c.label || c.name)} + + ${roleBadge ? `${escHtml(roleBadge)}` : ''} + ${portBadge} + +
+
${escHtml(stLabel)}
+ ${c.description ? `

${escHtml(c.description)}

` : ''} +
${actions}
+
`; + }).join(''); + + grid.querySelectorAll('.dc-btn[data-dc-action]').forEach(btn => { + btn.addEventListener('click', async () => { + const action = btn.dataset.dcAction; + const name = btn.dataset.dcName; + btn.disabled = true; + btn.textContent = action === 'start' ? 'Starting…' : 'Stopping…'; + try { + const r = await fetch(`/api/local-containers/${encodeURIComponent(name)}/${action}`, { method: 'POST' }); + const d = await r.json(); + if (!d.ok) toast(d.error || `${action} failed`, 'error'); + } catch (e) { + toast(`${action} failed: ${e.message}`, 'error'); + } + await loadLocalContainers(); + }); + }); +} + +$('dc-refresh-btn')?.addEventListener('click', loadLocalContainers); + +// ── AI Backends section: copy, API keys, local service connect ───────────── + +(function initLlmsSection() { + + // Copy buttons + document.querySelectorAll('.llm-copy-btn').forEach(btn => { + btn.addEventListener('click', () => { + const text = btn.dataset.copy || ''; + const orig = btn.textContent; + const done = () => { btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = orig; }, 1500); }; + if (navigator.clipboard) { + navigator.clipboard.writeText(text).then(done).catch(done); + } else { + const ta = document.createElement('textarea'); + ta.value = text; ta.style.cssText = 'position:fixed;opacity:0'; + document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); + done(); + } + }); + }); + + // API key inputs — persist to localStorage, eye toggle, saved badge + document.querySelectorAll('.llm-input[data-llm-key]').forEach(inp => { + const key = inp.dataset.llmKey; + const saved = localStorage.getItem('llm-key-' + key); + if (saved) inp.value = saved; + + const eye = document.createElement('button'); + eye.type = 'button'; eye.className = 'llm-eye-btn'; eye.title = 'Show / hide'; + eye.textContent = '👁'; + inp.insertAdjacentElement('afterend', eye); + + const badge = document.createElement('span'); + badge.className = 'llm-saved-badge'; badge.textContent = 'Saved'; badge.hidden = true; + eye.insertAdjacentElement('afterend', badge); + + eye.addEventListener('click', () => { + inp.type = inp.type === 'password' ? 'text' : 'password'; + eye.classList.toggle('active', inp.type === 'text'); + }); + + let t; + inp.addEventListener('input', () => { + clearTimeout(t); + t = setTimeout(() => { + if (inp.value) localStorage.setItem('llm-key-' + key, inp.value); + else localStorage.removeItem('llm-key-' + key); + badge.hidden = false; + setTimeout(() => { badge.hidden = true; }, 1800); + }, 600); + }); + }); + + // Local service URL inputs + Connect / Disconnect + function normalizeProbeUrl(raw) { + // 0.0.0.0 is a bind address, not routable; from inside Docker use host.docker.internal + return raw.replace(/^(https?:\/\/)0\.0\.0\.0([\/:])/, '$1host.docker.internal$2'); + } + + async function probeUrl(rawUrl) { + const url = normalizeProbeUrl(rawUrl); + const r = await fetch('/api/probe-url?' + new URLSearchParams({ url })); + return r.json(); + } + + function applyCardState(card, key, connected, failed) { + card.classList.toggle('llm-local-card-online', connected); + card.classList.toggle('llm-local-card-offline', !connected && !!failed); + localStorage.setItem('llm-local-con-' + key, connected ? '1' : '0'); + const btn = card.querySelector('.llm-local-ping'); + if (!btn) return; + if (connected) { + btn.textContent = '✓ Disconnect'; btn.dataset.action = 'disconnect'; + btn.className = 'llm-local-ping ok'; + } else { + btn.textContent = 'Connect'; btn.dataset.action = 'connect'; + btn.className = 'llm-local-ping'; + } + } + + document.querySelectorAll('[data-llm-local-key]').forEach(inp => { + const key = inp.dataset.llmLocalKey; + const card = inp.closest('.llm-local-card'); + if (!card) return; + const btn = card.querySelector('.llm-local-ping'); + + const savedUrl = localStorage.getItem('llm-local-url-' + key); + if (savedUrl) inp.value = savedUrl; + inp.addEventListener('input', () => { localStorage.setItem('llm-local-url-' + key, inp.value); }); + + if (localStorage.getItem('llm-local-con-' + key) === '1') applyCardState(card, key, true, false); + + if (!btn) return; + btn.addEventListener('click', async () => { + const action = btn.dataset.action || 'connect'; + if (action === 'disconnect') { applyCardState(card, key, false, false); return; } + + const rawUrl = inp.value.trim() || inp.placeholder; + if (!rawUrl) return; + btn.disabled = true; + btn.textContent = 'Connecting…'; + try { + const d = await probeUrl(rawUrl); + applyCardState(card, key, d.ok, !d.ok); + if (!d.ok) toast('Cannot reach ' + normalizeProbeUrl(rawUrl) + ': ' + (d.error || 'No response'), 'error'); + } catch (e) { + applyCardState(card, key, false, true); + toast('Probe failed: ' + e.message, 'error'); + } finally { + btn.disabled = false; + } + }); + }); + +})(); + // ── Collapsible cards ────────────────────────────────────────────────────── (function initCollapsibleCards() { @@ -6199,10 +6561,11 @@ loadVoiceLibrary().then(renderIntegrationSnippets).catch(e => status('Voice libr h2.prepend(chev); h2.classList.add('card-collapse-h2'); - // Wrap every element after h2 in a single body div + // Wrap every element after h2 (skipping .card-subtitle which stays visible) in body const body = document.createElement('div'); body.className = 'card-col-body'; let sib = h2.nextElementSibling; + while (sib && sib.classList.contains('card-subtitle')) sib = sib.nextElementSibling; while (sib) { const nx = sib.nextElementSibling; body.appendChild(sib); sib = nx; } card.appendChild(body); diff --git a/static/index.html b/static/index.html index 6c7b321..58d2dec 100644 --- a/static/index.html +++ b/static/index.html @@ -19,6 +19,7 @@
Voice Custom
TTS Generation
STT-TTS
+
Performance
Routing
How to
Get Voices
diff --git a/static/sections/s-clone.html b/static/sections/s-clone.html index 6d533a7..c387390 100644 --- a/static/sections/s-clone.html +++ b/static/sections/s-clone.html @@ -10,7 +10,7 @@

Step 1 — Load audio

-

Provide the audio you want to clone. Drop a file, paste a YouTube link, or record your microphone.

+

Drop a file, paste a URL, or record directly from your microphone.

Drop an audio / video file here WAV · MP3 · OGG · FLAC · M4A · MP4 · MKV · WEBM @@ -20,6 +20,7 @@

YouTube / URL

+

Download audio from a direct link or YouTube video.

@@ -28,6 +29,7 @@

Microphone

+

Record a fresh sample with live input level monitoring.

@@ -35,7 +37,8 @@