diff --git a/server.py b/server.py index 965ffe0..96fe546 100644 --- a/server.py +++ b/server.py @@ -52,6 +52,7 @@ _WHISPER_CPP_DEFAULT = os.environ.get("WHISPER_CPP_URL", "http://host.dock _GROQ_STT_ENDPOINT = "https://api.groq.com/openai/v1" _KOKORO_DEFAULT = os.environ.get("KOKORO_URL", "http://host.docker.internal:8880/v1") _VIBEVOICE_DEFAULT = os.environ.get("VIBEVOICE_URL", "http://192.168.178.8:8027") +_XTTS_DEFAULT = os.environ.get("XTTS_URL", "http://host.docker.internal:8024") _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") @@ -315,7 +316,7 @@ _SETTINGS_KEYS = { "output_dir", "voices_scan_dir", "voice_design_url", "customvoice_url", "nvidia_router_url", "nvidia_tts_url", "nvidia_asr_url", "nvidia_clone_url", "nvidia_zeroshot_url", "nvidia_flow_url", - "faster_whisper_url", "whisper_cpp_url", "groq_api_key", "kokoro_url", "vibevoice_url", + "faster_whisper_url", "whisper_cpp_url", "groq_api_key", "kokoro_url", "vibevoice_url", "xtts_url", "whisper_api_key", "tts_api_key", "voice_design_api_key", "elevenlabs_api_key", "tts_stability_enabled", "tts_extra_params", "tts_extra_params_by_backend", # Captures settings @@ -445,9 +446,12 @@ def _clean_preview_backend(value: str) -> str: "vibevoice_service": "vibevoice", "vibe_voice": "vibevoice", "vibetts": "vibevoice", + "xtts_v2": "xtts", + "xtts2": "xtts", + "coqui_xtts": "xtts", } key = aliases.get(key, key) - return key if key in {"voice_clone", "streaming", "customvoice", "voice_design", "nvidia_magpie", "nvidia_zeroshot", "nvidia_flow", "kokoro", "vibevoice"} else "voice_clone" + return key if key in {"voice_clone", "streaming", "customvoice", "voice_design", "nvidia_magpie", "nvidia_zeroshot", "nvidia_flow", "kokoro", "vibevoice", "xtts"} else "voice_clone" def _preview_backend_base_url(settings: dict, backend: str) -> str: @@ -468,6 +472,8 @@ def _preview_backend_base_url(settings: dict, backend: str) -> str: return settings.get("kokoro_url") or _KOKORO_DEFAULT if backend == "vibevoice": return settings.get("vibevoice_url") or _VIBEVOICE_DEFAULT + if backend == "xtts": + return settings.get("xtts_url") or _XTTS_DEFAULT return settings.get("tts_url") or _TTS_DEFAULT @@ -507,6 +513,7 @@ def _load_settings() -> dict: "groq_api_key": "", "kokoro_url": _KOKORO_DEFAULT, "vibevoice_url": _VIBEVOICE_DEFAULT, + "xtts_url": _XTTS_DEFAULT, "whisper_api_key": "", "tts_api_key": "", "voice_design_api_key": "", @@ -3064,7 +3071,7 @@ def _active_library_voice_options(settings: dict) -> list[dict]: return voices -_TTS_VOICE_ENDPOINTS = ("/v1/audio/voices", "/v1/audio/list_voices", "/v1/models", "/speakers") +_TTS_VOICE_ENDPOINTS = ("/v1/audio/voices", "/v1/audio/list_voices", "/v1/models", "/speakers", "/voices") def _voice_ids_from_payload(payload) -> list: @@ -3192,6 +3199,7 @@ def _backend_display_name(backend: str, url: str) -> str: "nvidia_flow": "NVIDIA Magpie Flow Clone", "kokoro": "Kokoro FastAPI (82M)", "vibevoice": "VibeVoice TTS", + "xtts": "XTTS v2", } port = _backend_port_label(url) return f"{port} {names.get(backend, backend)}" if port else names.get(backend, backend) @@ -3271,6 +3279,14 @@ def _backend_capabilities(backend: str) -> dict: "uses_wav": False, "style_aware": False, "true_streaming": False, "speed": "fast", "latency": "0.2–1 s", "quality": "High", "ram": "varies", }, + "xtts": { + "purpose": "XTTS v2 via xtts-api-server. OpenAI-compatible endpoint with speaker selection.", + "identity": "Uses speakers registered in the XTTS server; not WAV voice cloning.", + "style": "Speaker selected by voice ID. Style instruction not supported.", + "best_for": "Local multi-speaker TTS with XTTS v2 model. Coqui/daswer123 docker setup.", + "uses_wav": False, "style_aware": False, "true_streaming": False, + "speed": "~0.3× GPU", "latency": "1–3 s", "quality": "High", "ram": "4–6 GB VRAM", + }, } return caps.get(_clean_preview_backend(backend), {}) @@ -3297,7 +3313,7 @@ def _backend_available(backend: str, voices: list, health: bool) -> bool: async def tts_backends(): settings = _load_settings() items = [] - for backend in ("voice_clone", "voice_design", "customvoice", "streaming", "kokoro", "vibevoice", "nvidia_magpie", "nvidia_zeroshot", "nvidia_flow"): + for backend in ("voice_clone", "voice_design", "customvoice", "streaming", "kokoro", "vibevoice", "xtts", "nvidia_magpie", "nvidia_zeroshot", "nvidia_flow"): url = _validate_http_url(_preview_backend_base_url(settings, backend), allow_private=True).rstrip("/") voices = _fetch_backend_voices(settings, backend) health = _backend_health(url) @@ -3786,6 +3802,14 @@ def _preview_request_audio(text: str, voice: str, settings: dict, instruct: str ) if backend == "vibevoice": return _vibevoice_request_audio(text, settings) + if backend == "xtts": + return _tts_request_audio( + text, voice, settings, instruct, + url_override=_preview_backend_base_url(settings, "xtts"), + api_key_override=settings.get("tts_api_key", ""), + backend_override="openai", + extra_backend="xtts", + ) return _tts_request_audio(text, voice, settings, instruct) @@ -5149,6 +5173,165 @@ async def mcp_sse(request: Request): ) + +# ── Conversation Playground ──────────────────────────────────────────────────── + +@app.get("/api/conversation/llm-models") +async def conversation_llm_models(url: str = ""): + """List models from a local LLM endpoint (Ollama / vLLM / LM Studio).""" + settings = _load_settings() + base = (url or settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/") + try: + base = _validate_http_url(base, allow_private=True) + r = requests.get(f"{base}/models", timeout=5, headers={"Authorization": "Bearer no-key"}) + if r.status_code == 200: + payload = r.json() + data = payload.get("data", []) if isinstance(payload, dict) else [] + models = [ + str(item["id"]) if isinstance(item, dict) and item.get("id") else str(item) + for item in data if item + ] + return {"models": models, "url": base} + except Exception: + pass + return {"models": [], "url": base} + + +@app.post("/api/conversation/turn") +async def conversation_turn( + audio: UploadFile = File(...), + stt_backend: str = Form("configured"), + llm_url: str = Form(""), + llm_model: str = Form(""), + tts_backend: str = Form("voice_clone"), + tts_voice: str = Form(""), + system_prompt: str = Form("You are a helpful voice assistant. Keep replies short and conversational."), + history: str = Form("[]"), +): + """Stream a full conversation turn (STT → LLM → TTS) as Server-Sent Events.""" + settings = _load_settings() + eff_llm_url = (llm_url or settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/") + + suffix = Path(audio.filename or "audio.webm").suffix.lower() or ".webm" + if suffix not in _UPLOAD_EXTS: + suffix = ".webm" + tmp = TEMP_DIR / f"{uuid.uuid4().hex}_conv{suffix}" + try: + with tmp.open("wb") as f: + _copy_limited(audio.file, f, _MAX_UPLOAD_BYTES) + wav_tmp = tmp if suffix == ".wav" else _to_wav_24k(tmp) + except Exception as e: + tmp.unlink(missing_ok=True) + raise HTTPException(400, f"Audio upload failed: {e}") + + try: + hist = json.loads(history) if history else [] + if not isinstance(hist, list): + hist = [] + except Exception: + hist = [] + + stt_be = _clean_stt_backend(stt_backend) + tts_be = _clean_preview_backend(tts_backend) + _tmp, _wav = tmp, wav_tmp + + async def generate(): + t0 = time.monotonic() + stt_ms = llm_ttft_ms = llm_total_ms = tts_ms = None + transcript = llm_text = "" + + def sse(obj: dict) -> str: + return f"data: {json.dumps(obj)}\n\n" + + # 1. STT + try: + t_stt = time.monotonic() + transcript, _ = await asyncio.to_thread(_transcribe_audio, _wav, settings, stt_be) + stt_ms = int((time.monotonic() - t_stt) * 1000) + yield sse({"type": "transcript", "text": transcript, "stt_ms": stt_ms}) + except Exception as e: + yield sse({"type": "error", "stage": "stt", "message": str(e)}) + return + finally: + for p in {_tmp, _wav}: + try: + p.unlink(missing_ok=True) + except Exception: + pass + + if not transcript.strip(): + yield sse({"type": "error", "stage": "stt", "message": "No speech detected."}) + return + + # 2. LLM stream + messages = [{"role": "system", "content": system_prompt}] + messages.extend(hist[-20:]) + messages.append({"role": "user", "content": transcript}) + llm_payload: dict = {"messages": messages, "stream": True, "temperature": 0.7, "max_tokens": 512} + if llm_model: + llm_payload["model"] = llm_model + try: + t_llm = time.monotonic() + llm_resp = await asyncio.to_thread(lambda: requests.post( + f"{eff_llm_url}/chat/completions", json=llm_payload, + headers={"Authorization": "Bearer no-key"}, stream=True, timeout=120, + )) + llm_resp.raise_for_status() + ttft_done = False + for raw_line in llm_resp.iter_lines(): + if not raw_line: + continue + line = raw_line.decode("utf-8") if isinstance(raw_line, bytes) else str(raw_line) + if not line.startswith("data:"): + continue + chunk = line[5:].strip() + if chunk == "[DONE]": + break + try: + obj = json.loads(chunk) + delta = ((obj.get("choices") or [{}])[0].get("delta") or {}).get("content") or "" + if not delta and isinstance(obj.get("message"), dict): + delta = obj["message"].get("content") or "" + if delta: + if not ttft_done: + llm_ttft_ms = int((time.monotonic() - t_llm) * 1000) + ttft_done = True + llm_text += delta + yield sse({"type": "token", "delta": delta}) + except Exception: + continue + llm_total_ms = int((time.monotonic() - t_llm) * 1000) + yield sse({"type": "llm_done", "text": llm_text, + "llm_ttft_ms": llm_ttft_ms, "llm_total_ms": llm_total_ms}) + except Exception as e: + yield sse({"type": "error", "stage": "llm", "message": str(e)}) + return + + if not llm_text.strip(): + yield sse({"type": "error", "stage": "llm", "message": "LLM returned empty response."}) + return + + # 3. TTS + try: + t_tts = time.monotonic() + audio_bytes, mime = await asyncio.to_thread( + _preview_request_audio, llm_text, tts_voice, settings, "", tts_be + ) + tts_ms = int((time.monotonic() - t_tts) * 1000) + total_ms = int((time.monotonic() - t0) * 1000) + yield sse({"type": "audio", "b64": base64.b64encode(audio_bytes).decode(), "mime": mime}) + yield sse({"type": "stats", "stt_ms": stt_ms, "llm_ttft_ms": llm_ttft_ms, + "llm_total_ms": llm_total_ms, "tts_ms": tts_ms, "total_ms": total_ms}) + except Exception as e: + yield sse({"type": "error", "stage": "tts", "message": str(e)}) + return + + yield sse({"type": "done"}) + + return StreamingResponse(generate(), media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) + + # ── Static ──────────────────────────────────────────────────────────────────── app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") diff --git a/static/app.js b/static/app.js index 8f8f000..ca1ba9d 100644 --- a/static/app.js +++ b/static/app.js @@ -1943,6 +1943,12 @@ async function refreshTtsBackendAvailability(selected = '') { perfSel.innerHTML = ttsBackendOptions(prev); perfSel.disabled = !availableTtsBackends().length; } + const batchSel = $('batch-backend-select'); + if (batchSel) { + const prev = batchSel.value; + batchSel.innerHTML = ttsBackendOptions(prev); + batchSel.disabled = !availableTtsBackends().length; + } updateBackendHelp(); updateStyleBackendHelp(); updateBackendDependentTabs(); @@ -1959,6 +1965,7 @@ async function loadSettings() { $('s-groq-api-key').value = s.groq_api_key || ''; $('s-kokoro-url').value = s.kokoro_url || ''; $('s-vibevoice-url').value = s.vibevoice_url || ''; + $('s-xtts-url').value = s.xtts_url || ''; const llmUrlEl = $('s-llm-url'); if (llmUrlEl) llmUrlEl.value = s.llm_url || ''; _appSettings = s; $('s-tts-stream-url').value = s.tts_stream_url || ''; @@ -2066,6 +2073,7 @@ document.addEventListener('click', async e => { if (!e.target.closest('.s-save-b tts_url: $('s-tts-url').value, kokoro_url: $('s-kokoro-url').value, vibevoice_url: $('s-vibevoice-url').value, + xtts_url: $('s-xtts-url')?.value || '', llm_url: $('s-llm-url')?.value || '', tts_stream_url: $('s-tts-stream-url').value, customvoice_url: $('s-customvoice-url').value, @@ -2103,6 +2111,7 @@ document.addEventListener('click', async e => { if (!e.target.closest('.s-save-b _appSettings.nvidia_flow_url = $('s-nvidia-flow-url').value; _appSettings.voice_design_url = $('s-voice-design-url').value; _appSettings.vibevoice_url = $('s-vibevoice-url').value; + _appSettings.xtts_url = $('s-xtts-url')?.value || ''; _appSettings.tts_stream_mode = $('s-tts-stream-mode').value; _ttsStreamHealth = null; await refreshTtsBackendAvailability($('tts-backend-select')?.value || ''); @@ -7378,9 +7387,9 @@ document.querySelectorAll('.dc-refresh-btn').forEach(b => b.addEventListener('cl $('llm-use-xtts-tts')?.addEventListener('click', () => { const url = document.querySelector('[data-llm-local-key="xtts"]')?.value.trim() - || 'http://localhost:8020'; - applyAndSaveSettings({ tts_url: url }); - toast('XTTS v2 URL saved → tts_url in Settings.', 'success'); + || 'http://localhost:8024'; + applyAndSaveSettings({ xtts_url: url }); + toast('XTTS v2 URL saved → xtts_url. It now appears as "XTTS v2" in the TTS backend dropdown.', 'success'); }); const LLM_USE_MAP = { @@ -8119,3 +8128,363 @@ $('s-import-voices-file')?.addEventListener('change', async function () { toast('Import failed: ' + e.message, 'error'); } }); + +// ── Conversation Playground ──────────────────────────────────────────────── + +(function initConversationPlayground() { + const chatWindow = $('conv-chat-window'); + const micBtn = $('conv-mic-btn'); + const micIcon = $('conv-mic-icon'); + const micStatus = $('conv-mic-status'); + const micTimer = $('conv-mic-timer'); + const clearBtn = $('conv-clear-btn'); + const sttSel = $('conv-stt-select'); + const llmUrlInp = $('conv-llm-url'); + const llmFetchBtn = $('conv-llm-fetch-btn'); + const llmModelSel = $('conv-llm-model-select'); + const ttsBkSel = $('conv-tts-backend-select'); + const ttsFetchBtn = $('conv-tts-fetch-btn'); + const ttsVoiceSel = $('conv-tts-voice-select'); + const systemPrompt = $('conv-system-prompt'); + const turnHistory = $('conv-turn-history'); + if (!chatWindow || !micBtn) return; + + let mediaRecorder = null; + let recChunks = []; + let recTimerInterval = null; + let recStart = 0; + let conversationHistory = []; + let turnCount = 0; + let isProcessing = false; + + // ── Populate STT backends ──────────────────────────────────────────────── + async function loadConvSttBackends() { + if (!sttSel) return; + try { + const d = await fetch('/api/stt-backends').then(r => r.json()); + const avail = (d.backends || []).filter(b => b.available); + sttSel.innerHTML = avail.length + ? avail.map(b => ``).join('') + : ''; + } catch(_) { + sttSel.innerHTML = ''; + } + } + + // ── Populate TTS backends (reuse global _ttsBackends) ─────────────────── + function populateConvTtsBackends() { + if (!ttsBkSel) return; + const avail = availableTtsBackends(); + ttsBkSel.innerHTML = avail.length + ? avail.map(b => ``).join('') + : ''; + } + + // ── Fetch LLM models ───────────────────────────────────────────────────── + async function fetchLlmModels() { + if (!llmModelSel) return; + const url = llmUrlInp?.value.trim() || ''; + llmFetchBtn.disabled = true; + try { + const d = await fetch('/api/conversation/llm-models' + (url ? '?url=' + encodeURIComponent(url) : '')).then(r => r.json()); + const models = d.models || []; + llmModelSel.innerHTML = models.length + ? models.map(m => ``).join('') + : ''; + } catch(e) { + llmModelSel.innerHTML = ''; + } finally { + llmFetchBtn.disabled = false; + } + } + + // ── Fetch TTS voices ───────────────────────────────────────────────────── + async function fetchConvTtsVoices() { + if (!ttsVoiceSel || !ttsBkSel) return; + const backend = ttsBkSel.value; + if (!backend) return; + ttsFetchBtn.disabled = true; + try { + const rawVoices = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json()); + ttsVoiceSel.innerHTML = rawVoices.length + ? rawVoices.map(v => { const id = backendVoiceId(v); return ``; }).join('') + : ''; + } catch(e) { + ttsVoiceSel.innerHTML = ''; + } finally { + ttsFetchBtn.disabled = false; + } + } + + // ── Chat bubble helpers ────────────────────────────────────────────────── + function timeStr() { + const now = new Date(); + return now.getHours().toString().padStart(2,'0') + ':' + now.getMinutes().toString().padStart(2,'0'); + } + + function removeWelcome() { + const w = chatWindow.querySelector('.conv-chat-welcome'); + if (w) w.remove(); + } + + function addBubble(role, text) { + removeWelcome(); + const wrap = document.createElement('div'); + wrap.className = `conv-bubble-wrap conv-bubble-wrap--${role}`; + const bubble = document.createElement('div'); + bubble.className = `conv-bubble conv-bubble--${role}`; + bubble.textContent = text || ''; + const meta = document.createElement('div'); + meta.className = 'conv-bubble-meta'; + meta.textContent = timeStr(); + wrap.appendChild(bubble); + wrap.appendChild(meta); + chatWindow.appendChild(wrap); + chatWindow.scrollTop = chatWindow.scrollHeight; + return bubble; + } + + function addTypingBubble() { + removeWelcome(); + const wrap = document.createElement('div'); + wrap.className = 'conv-bubble-wrap conv-bubble-wrap--assistant'; + wrap.id = 'conv-typing-wrap'; + const bubble = document.createElement('div'); + bubble.className = 'conv-bubble conv-bubble--assistant'; + bubble.innerHTML = ''; + wrap.appendChild(bubble); + chatWindow.appendChild(wrap); + chatWindow.scrollTop = chatWindow.scrollHeight; + return bubble; + } + + function addErrorBubble(msg) { + removeWelcome(); + const wrap = document.createElement('div'); + wrap.className = 'conv-bubble-wrap conv-bubble-wrap--assistant'; + const bubble = document.createElement('div'); + bubble.className = 'conv-bubble conv-bubble--error'; + bubble.innerHTML = ` ${escHtml(msg)}`; + wrap.appendChild(bubble); + chatWindow.appendChild(wrap); + chatWindow.scrollTop = chatWindow.scrollHeight; + } + + // ── Stats panel ────────────────────────────────────────────────────────── + function fmtMs(ms) { return ms == null ? '—' : ms >= 1000 ? (ms/1000).toFixed(2)+'s' : ms+'ms'; } + + function updateStatBar(id, val, maxVal) { + const fill = $(id); + if (fill) fill.style.width = maxVal > 0 ? Math.min(100, (val / maxVal) * 100) + '%' : '0%'; + } + + function updateStats(stats) { + const { stt_ms, llm_ttft_ms, llm_total_ms, tts_ms, total_ms } = stats; + const max = total_ms || 1; + const set = (valId, fillId, ms) => { + const el = $(valId); if (el) el.textContent = fmtMs(ms); + updateStatBar(fillId, ms || 0, max); + }; + set('cpv-stt', 'cpf-stt', stt_ms); + set('cpv-ttft', 'cpf-ttft', llm_ttft_ms); + set('cpv-llm', 'cpf-llm', llm_total_ms); + set('cpv-tts', 'cpf-tts', tts_ms); + set('cpv-total', 'cpf-total', total_ms); + } + + function addHistoryItem(n, totalMs, ok) { + const empty = turnHistory?.querySelector('.conv-history-empty'); + if (empty) empty.remove(); + const item = document.createElement('div'); + item.className = 'conv-hist-item'; + const cls = ok ? 'conv-hist-ok' : 'conv-hist-err'; + const icon = ok ? 'mdi-check-circle-outline' : 'mdi-alert-outline'; + item.innerHTML = `#${n} + + ${fmtMs(totalMs)}`; + turnHistory.insertBefore(item, turnHistory.firstChild); + } + + // ── Recording ──────────────────────────────────────────────────────────── + function startRecTimer() { + recStart = Date.now(); + recTimerInterval = setInterval(() => { + const s = Math.floor((Date.now() - recStart) / 1000); + if (micTimer) micTimer.textContent = s + 's'; + }, 500); + } + + function stopRecTimer() { + clearInterval(recTimerInterval); + if (micTimer) micTimer.textContent = ''; + } + + async function startRecording() { + if (isProcessing) return; + let stream; + try { + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + } catch(e) { + toast('Microphone access denied: ' + e.message, 'error'); + return; + } + recChunks = []; + mediaRecorder = new MediaRecorder(stream); + mediaRecorder.ondataavailable = e => { if (e.data.size > 0) recChunks.push(e.data); }; + mediaRecorder.onstop = () => { + stream.getTracks().forEach(t => t.stop()); + const blob = new Blob(recChunks, { type: mediaRecorder.mimeType || 'audio/webm' }); + processBlob(blob); + }; + mediaRecorder.start(); + micBtn.classList.add('recording'); + micIcon.className = 'mdi mdi-stop'; + if (micStatus) micStatus.textContent = 'Recording… click to stop'; + startRecTimer(); + } + + function stopRecording() { + if (!mediaRecorder || mediaRecorder.state === 'inactive') return; + mediaRecorder.stop(); + stopRecTimer(); + micBtn.classList.remove('recording'); + micBtn.classList.add('processing'); + micIcon.className = 'mdi mdi-dots-horizontal'; + if (micStatus) micStatus.textContent = 'Processing…'; + isProcessing = true; + } + + // ── Send turn via SSE ───────────────────────────────────────────────────── + async function processBlob(blob) { + turnCount++; + const turnN = turnCount; + const t0 = Date.now(); + + // Show user bubble with placeholder + const userBubble = addBubble('user', '…'); + const assistantBubble = addTypingBubble(); + let assistantText = ''; + let lastStats = null; + + const form = new FormData(); + form.append('audio', blob, 'audio.webm'); + form.append('stt_backend', sttSel?.value || 'configured'); + form.append('llm_url', llmUrlInp?.value.trim() || ''); + form.append('llm_model', llmModelSel?.value || ''); + form.append('tts_backend', ttsBkSel?.value || 'voice_clone'); + form.append('tts_voice', ttsVoiceSel?.value || ''); + form.append('system_prompt', systemPrompt?.value.trim() || 'You are a helpful voice assistant.'); + form.append('history', JSON.stringify(conversationHistory.slice(-20))); + + try { + const resp = await fetch('/api/conversation/turn', { method: 'POST', body: form }); + if (!resp.ok) throw new Error('Server error ' + resp.status); + const reader = resp.body.getReader(); + const dec = new TextDecoder(); + let buf = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += dec.decode(value, { stream: true }); + const lines = buf.split('\n'); + buf = lines.pop(); + for (const line of lines) { + if (!line.startsWith('data:')) continue; + let evt; + try { evt = JSON.parse(line.slice(5).trim()); } catch(_) { continue; } + + if (evt.type === 'transcript') { + userBubble.textContent = evt.text || '(empty)'; + if (micStatus) micStatus.textContent = 'Generating reply…'; + } else if (evt.type === 'token') { + if (assistantBubble.querySelector('.conv-typing')) { + assistantBubble.innerHTML = ''; + } + assistantText += evt.delta; + assistantBubble.textContent = assistantText; + chatWindow.scrollTop = chatWindow.scrollHeight; + } else if (evt.type === 'llm_done') { + assistantText = evt.text || assistantText; + assistantBubble.textContent = assistantText; + if (micStatus) micStatus.textContent = 'Synthesising speech…'; + } else if (evt.type === 'audio') { + const mime = evt.mime || 'audio/wav'; + const binStr = atob(evt.b64); + const arr = new Uint8Array(binStr.length); + for (let i = 0; i < binStr.length; i++) arr[i] = binStr.charCodeAt(i); + const audioBlob = new Blob([arr], { type: mime }); + const url = URL.createObjectURL(audioBlob); + const audio = new Audio(url); + audio.onended = () => URL.revokeObjectURL(url); + audio.play().catch(() => {}); + if (micStatus) micStatus.textContent = 'Speaking…'; + } else if (evt.type === 'stats') { + lastStats = evt; + updateStats(evt); + } else if (evt.type === 'done') { + conversationHistory.push({ role: 'user', content: userBubble.textContent }); + conversationHistory.push({ role: 'assistant', content: assistantText }); + addHistoryItem(turnN, lastStats?.total_ms ?? (Date.now() - t0), true); + if (micStatus) micStatus.textContent = 'Ready'; + } else if (evt.type === 'error') { + const wrap = assistantBubble.closest('.conv-bubble-wrap'); + if (wrap) wrap.remove(); + addErrorBubble(`[${evt.stage?.toUpperCase() || 'ERR'}] ${evt.message}`); + addHistoryItem(turnN, Date.now() - t0, false); + if (micStatus) micStatus.textContent = 'Error — ready'; + } + } + } + } catch(e) { + const wrap = assistantBubble.closest('.conv-bubble-wrap'); + if (wrap) wrap.remove(); + addErrorBubble(e.message); + addHistoryItem(turnN, Date.now() - t0, false); + if (micStatus) micStatus.textContent = 'Error — ready'; + } finally { + isProcessing = false; + micBtn.classList.remove('processing'); + micIcon.className = 'mdi mdi-microphone'; + } + } + + // ── Wire up events ─────────────────────────────────────────────────────── + micBtn.addEventListener('click', () => { + if (isProcessing) return; + if (mediaRecorder && mediaRecorder.state === 'recording') { + stopRecording(); + } else { + startRecording(); + } + }); + + clearBtn?.addEventListener('click', () => { + conversationHistory = []; + turnCount = 0; + chatWindow.innerHTML = '

Press the microphone button below and start talking.

'; + if (turnHistory) turnHistory.innerHTML = '
No turns yet.
'; + ['cpv-stt','cpv-ttft','cpv-llm','cpv-tts','cpv-total'].forEach(id => { const el = $(id); if(el) el.textContent='—'; }); + ['cpf-stt','cpf-ttft','cpf-llm','cpf-tts','cpf-total'].forEach(id => { const el = $(id); if(el) el.style.width='0%'; }); + }); + + llmFetchBtn?.addEventListener('click', fetchLlmModels); + ttsFetchBtn?.addEventListener('click', fetchConvTtsVoices); + + // Re-populate TTS when backend changes + ttsBkSel?.addEventListener('change', () => { ttsVoiceSel.innerHTML = ''; }); + + // ── Init ───────────────────────────────────────────────────────────────── + loadConvSttBackends(); + populateConvTtsBackends(); + + // Keep TTS backend select in sync after global backend refresh + const origRefresh = window.refreshTtsBackendAvailability; + if (typeof origRefresh === 'function') { + window.refreshTtsBackendAvailability = async function(...args) { + const result = await origRefresh.apply(this, args); + populateConvTtsBackends(); + return result; + }; + } +})(); diff --git a/static/index.html b/static/index.html index a81cabe..0f3086c 100644 --- a/static/index.html +++ b/static/index.html @@ -65,6 +65,9 @@ + @@ -122,6 +125,7 @@
+
diff --git a/static/loader.js b/static/loader.js index 7382d18..5fd5512 100644 --- a/static/loader.js +++ b/static/loader.js @@ -1,7 +1,7 @@ (async function () { 'use strict'; - const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms']; + const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms', 's-conversation']; function loadScript(src) { return new Promise(function (resolve, reject) { diff --git a/static/nav.js b/static/nav.js index faadad3..5de071b 100644 --- a/static/nav.js +++ b/static/nav.js @@ -18,7 +18,7 @@ llms: 's-llms' }; - const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms']; + const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms', 's-conversation']; function runSideEffects(name) { if (name === 'library' && typeof loadVoiceLibrary === 'function') loadVoiceLibrary(); diff --git a/static/sections/s-conversation.html b/static/sections/s-conversation.html new file mode 100644 index 0000000..e333979 --- /dev/null +++ b/static/sections/s-conversation.html @@ -0,0 +1,101 @@ +
+ +
+

Conversation Playground

+

Talk to an AI voice agent. Full STT → LLM → TTS pipeline with real-time streaming and latency stats.

+
+
+ + +
+
+
+ + +
+
+ +
+ + + +
+
+
+ +
+ + + +
+
+
+ +
+
+
+ + +
+
+ + +
+ + +
+
+
+ +

Press the microphone button below and start talking.

+
+
+ + +
+
Ready
+ +
+
+
+ + +
+
Latency
+ +
+
+
STT
+
+
+
+
+
LLM first token
+
+
+
+
+
LLM total
+
+
+
+
+
TTS
+
+
+
+
+
Total
+
+
+
+
+ +
Turn history
+
+
No turns yet.
+
+
+
diff --git a/static/sections/s-settings.html b/static/sections/s-settings.html index 8b407bb..dc02e1c 100644 --- a/static/sections/s-settings.html +++ b/static/sections/s-settings.html @@ -77,6 +77,11 @@ Simple text-in audio-out TTS. POST /tts with {"text":"..."}. +
+ + + XTTS v2 via daswer123/xtts-api-server. Supports GET /speakers and POST /v1/audio/speech. +
diff --git a/static/style.css b/static/style.css index 1b4f702..b4a3a12 100644 --- a/static/style.css +++ b/static/style.css @@ -1911,3 +1911,70 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami } .preview-persona-btn:hover { background: rgba(13,148,136,.12); } .preview-persona-btn:disabled { opacity: .5; cursor: default; } + +/* ── Conversation Playground ─────────────────────────────────────────────── */ +.conv-config-bar { padding: 14px 18px 10px; margin-bottom: 0; } +.conv-config-row { display: flex; gap: 14px; flex-wrap: wrap; align-items: flex-end; } +.conv-config-group { display: flex; flex-direction: column; gap: 4px; } +.conv-config-group--actions { margin-left: auto; } +.conv-cfg-label { font-size: 11px; font-weight: 700; color: var(--subtext); text-transform: uppercase; letter-spacing: .06em; } +.conv-url-inp { width: 220px; padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--radius); font-size: 13px; background: var(--surface); color: var(--text); } +.conv-prompt-row { display: flex; align-items: flex-start; gap: 10px; margin-top: 10px; border-top: 1px solid var(--border); padding-top: 10px; } +.conv-prompt-row .conv-cfg-label { padding-top: 7px; white-space: nowrap; } +.conv-system-textarea { flex: 1; resize: vertical; min-height: 34px; max-height: 120px; padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--radius); font-size: 13px; font-family: var(--font); background: var(--surface); color: var(--text); } + +/* Layout */ +.conv-main { display: flex; gap: 14px; margin-top: 14px; min-height: 520px; } +.conv-chat-panel { flex: 1; display: flex; flex-direction: column; gap: 0; min-width: 0; } +.conv-stats-panel { width: 280px; flex-shrink: 0; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; display: flex; flex-direction: column; gap: 8px; } + +/* Chat window */ +.conv-chat-window { flex: 1; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius) var(--radius) 0 0; padding: 16px; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; min-height: 400px; } +.conv-chat-welcome { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; flex: 1; color: var(--subtext); text-align: center; } + +/* Bubbles */ +.conv-bubble-wrap { display: flex; flex-direction: column; max-width: 72%; } +.conv-bubble-wrap--user { align-self: flex-end; align-items: flex-end; } +.conv-bubble-wrap--assistant { align-self: flex-start; align-items: flex-start; } +.conv-bubble { padding: 10px 14px; border-radius: 16px; font-size: 14.5px; line-height: 1.55; word-break: break-word; } +.conv-bubble--user { background: var(--accent); color: #fff; border-bottom-right-radius: 4px; } +.conv-bubble--assistant { background: var(--panel); color: var(--text); border: 1px solid var(--border); border-bottom-left-radius: 4px; } +.conv-bubble--error { background: rgba(220,38,38,.08); color: var(--red); border: 1px solid rgba(220,38,38,.2); border-radius: 10px; padding: 8px 12px; font-size: 13px; } +.conv-bubble-meta { font-size: 11px; color: var(--subtext); margin-top: 3px; padding: 0 4px; } + +/* Typing dots */ +.conv-typing { display: inline-flex; gap: 4px; align-items: center; padding: 4px 2px; } +.conv-typing span { width: 7px; height: 7px; border-radius: 50%; background: var(--subtext); opacity: .4; animation: convDot 1.2s infinite; } +.conv-typing span:nth-child(2) { animation-delay: .2s; } +.conv-typing span:nth-child(3) { animation-delay: .4s; } +@keyframes convDot { 0%,80%,100% { transform: scale(.7); opacity:.3; } 40% { transform: scale(1); opacity:.9; } } + +/* Mic bar */ +.conv-mic-bar { background: var(--surface); border: 1px solid var(--border); border-top: none; border-radius: 0 0 var(--radius) var(--radius); padding: 12px 16px; display: flex; align-items: center; gap: 14px; } +.conv-mic-btn { width: 52px; height: 52px; border-radius: 50%; border: 2px solid var(--accent); background: var(--accent); color: #fff; font-size: 22px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background .15s, transform .1s, box-shadow .15s; flex-shrink: 0; } +.conv-mic-btn:hover { background: #1d4ed8; } +.conv-mic-btn.recording { background: var(--red); border-color: var(--red); animation: convPulse 1s infinite; } +.conv-mic-btn.processing { background: var(--yellow); border-color: var(--yellow); cursor: default; } +@keyframes convPulse { 0%,100% { box-shadow: 0 0 0 0 rgba(220,38,38,.4); } 50% { box-shadow: 0 0 0 8px rgba(220,38,38,0); } } +.conv-mic-status { flex: 1; font-size: 13px; color: var(--subtext); } +.conv-mic-timer { font-size: 13px; font-variant-numeric: tabular-nums; color: var(--red); min-width: 36px; text-align: right; } + +/* Stats panel */ +.conv-stats-head { font-size: 10px; font-weight: 800; color: var(--subtext); text-transform: uppercase; letter-spacing: .08em; } +.conv-pipeline { display: flex; flex-direction: column; gap: 8px; } +.conv-pipe-step { display: grid; grid-template-columns: 1fr auto; grid-template-rows: auto auto; gap: 2px 8px; } +.conv-pipe-label { grid-column: 1; font-size: 12px; color: var(--subtext); } +.conv-pipe-val { grid-column: 2; grid-row: 1 / 3; font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; color: var(--text); align-self: center; text-align: right; min-width: 52px; } +.conv-pipe-bar { grid-column: 1; height: 4px; background: var(--panel); border-radius: 2px; overflow: hidden; } +.conv-pipe-fill { height: 100%; background: var(--teal); border-radius: 2px; width: 0%; transition: width .4s ease; } +.conv-pipe-total .conv-pipe-label { font-weight: 700; color: var(--text); } +.conv-pipe-total .conv-pipe-val { color: var(--accent); font-size: 15px; } + +/* Turn history */ +.conv-turn-history { display: flex; flex-direction: column; gap: 5px; overflow-y: auto; max-height: 220px; } +.conv-history-empty { font-size: 12px; color: var(--subtext); font-style: italic; } +.conv-hist-item { display: flex; align-items: center; gap: 6px; font-size: 12px; padding: 4px 6px; border-radius: 5px; background: var(--panel); } +.conv-hist-num { font-weight: 700; color: var(--subtext); min-width: 20px; } +.conv-hist-time { font-variant-numeric: tabular-nums; font-weight: 700; margin-left: auto; } +.conv-hist-ok { color: var(--green); } +.conv-hist-err { color: var(--red); }