Add Kokoro TTS + faster-whisper/whisper.cpp/Groq STT backends with metrics

New TTS backend: Kokoro FastAPI (82M) — OpenAI-compatible, 11 built-in
voices, only shows when server is reachable (~300 MB CPU, ~0.1× RTF).

New STT backends in Transcribe dropdown: faster-whisper (CTranslate2 GPU,
~70× RT, 1.5 GB VRAM), whisper.cpp (CPU/CUDA, ~8–15× RT, ~1 GB RAM),
Groq Whisper (fastest cloud, free 2 000 req/day, key shared with Groq LLM).

Backend help panels now show  speed ·  latency ·  quality · 💾 RAM
metric chips for all TTS and STT backends.

Active Docker Stack cards also get per-container metric chips.

AI Backends section: "Use as STT" / "Use as TTS" one-click buttons on
faster-whisper, whisper.cpp, and Kokoro cards apply URLs to Settings
without leaving the page. Groq Whisper card notes the shared key path.

Settings: Kokoro URL in TTS cluster; faster-whisper URL, whisper.cpp URL,
Groq API key in STT cluster; quick-fill buttons for all local STT engines.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-05-27 00:16:43 +02:00
parent 90cc7b7eb1
commit 08a63ef2d9
6 changed files with 329 additions and 57 deletions

153
server.py
View File

@ -46,6 +46,10 @@ _NVIDIA_ASR_DEFAULT = os.environ.get("NVIDIA_PARAKEET_ASR_URL", "http://host.doc
_NVIDIA_CLONE_DEFAULT = os.environ.get("NVIDIA_TTS_CLONE_URL", "http://host.docker.internal:8093")
_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")
_FASTER_WHISPER_DEFAULT = os.environ.get("FASTER_WHISPER_URL", "http://host.docker.internal:8000")
_WHISPER_CPP_DEFAULT = os.environ.get("WHISPER_CPP_URL", "http://host.docker.internal:8080")
_GROQ_STT_ENDPOINT = "https://api.groq.com/openai/v1"
_KOKORO_DEFAULT = os.environ.get("KOKORO_URL", "http://host.docker.internal:8880/v1")
_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")
@ -286,6 +290,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",
"whisper_api_key", "tts_api_key", "voice_design_api_key", "elevenlabs_api_key",
"tts_stability_enabled", "tts_extra_params", "tts_extra_params_by_backend",
}
@ -301,6 +306,7 @@ _TTS_STABILITY_BY_BACKEND_DEFAULT = {
"nvidia_magpie": {},
"nvidia_zeroshot": {},
"nvidia_flow": {},
"kokoro": {},
}
_TTS_PAYLOAD_CORE_KEYS = {"model", "input", "voice", "response_format", "instruct", "language"}
@ -400,9 +406,12 @@ def _clean_preview_backend(value: str) -> str:
"nvidia_flow_tts": "nvidia_flow",
"magpie_flow": "nvidia_flow",
"flow": "nvidia_flow",
"kokoro_fastapi": "kokoro",
"kokoro_tts": "kokoro",
"kokoro_local": "kokoro",
}
key = aliases.get(key, key)
return key if key in {"voice_clone", "streaming", "customvoice", "voice_design", "nvidia_magpie", "nvidia_zeroshot", "nvidia_flow"} else "voice_clone"
return key if key in {"voice_clone", "streaming", "customvoice", "voice_design", "nvidia_magpie", "nvidia_zeroshot", "nvidia_flow", "kokoro"} else "voice_clone"
def _preview_backend_base_url(settings: dict, backend: str) -> str:
@ -419,6 +428,8 @@ def _preview_backend_base_url(settings: dict, backend: str) -> str:
return settings.get("nvidia_zeroshot_url") or settings.get("nvidia_clone_url") or settings.get("nvidia_router_url") or _NVIDIA_ZEROSHOT_DEFAULT
if backend == "nvidia_flow":
return settings.get("nvidia_flow_url") or settings.get("nvidia_clone_url") or settings.get("nvidia_router_url") or _NVIDIA_FLOW_DEFAULT
if backend == "kokoro":
return settings.get("kokoro_url") or _KOKORO_DEFAULT
return settings.get("tts_url") or _TTS_DEFAULT
@ -453,6 +464,10 @@ def _load_settings() -> dict:
"nvidia_clone_url": _NVIDIA_CLONE_DEFAULT,
"nvidia_zeroshot_url": _NVIDIA_ZEROSHOT_DEFAULT,
"nvidia_flow_url": _NVIDIA_FLOW_DEFAULT,
"faster_whisper_url": _FASTER_WHISPER_DEFAULT,
"whisper_cpp_url": _WHISPER_CPP_DEFAULT,
"groq_api_key": "",
"kokoro_url": _KOKORO_DEFAULT,
"whisper_api_key": "",
"tts_api_key": "",
"voice_design_api_key": "",
@ -1718,13 +1733,34 @@ _STT_BACKEND_ALIASES = {
"router": "nvidia_router",
"speech_router": "nvidia_router",
"nvidia_speech_router": "nvidia_router",
"faster_whisper_server": "faster_whisper",
"faster-whisper": "faster_whisper",
"ctranslate2": "faster_whisper",
"faster_w": "faster_whisper",
"whisper-cpp": "whisper_cpp",
"whisper_cpp_server": "whisper_cpp",
"cpp": "whisper_cpp",
"groq": "groq_whisper",
"groq_stt": "groq_whisper",
"groq-whisper": "groq_whisper",
}
_STT_BACKEND_METRICS: dict[str, dict] = {
"configured": {"speed": "GPU / CPU", "latency": "15 s", "quality": "large-v3", "ram": "3 GB VRAM"},
"faster_whisper": {"speed": "~70× RT · GPU", "latency": "0.52 s", "quality": "large-v3", "ram": "1.5 GB VRAM"},
"whisper_cpp": {"speed": "~815× RT · CPU", "latency": "15 s", "quality": "large-v3 Q5", "ram": "~1 GB RAM"},
"groq_whisper": {"speed": "fastest cloud", "latency": "0.51 s", "quality": "Whisper Turbo", "ram": "cloud · 0"},
"nvidia_parakeet":{"speed": "~200× RT · GPU", "latency": "<0.3 s", "quality": "Parakeet-TDT", "ram": "2 GB VRAM"},
"nvidia_router": {"speed": "GPU routed", "latency": "~0.5 s", "quality": "varies", "ram": "varies"},
}
_STT_VALID_BACKENDS = {"configured", "nvidia_parakeet", "nvidia_router", "faster_whisper", "whisper_cpp", "groq_whisper"}
def _clean_stt_backend(value: str) -> str:
key = re.sub(r"[^a-z0-9]+", "_", str(value or "configured").lower()).strip("_")
key = _STT_BACKEND_ALIASES.get(key, key)
return key if key in {"configured", "nvidia_parakeet", "nvidia_router"} else "configured"
return key if key in _STT_VALID_BACKENDS else "configured"
def _stt_backend_url(settings: dict, backend: str) -> str:
@ -1733,12 +1769,24 @@ def _stt_backend_url(settings: dict, backend: str) -> str:
return settings.get("nvidia_asr_url") or _NVIDIA_ASR_DEFAULT
if backend == "nvidia_router":
return settings.get("nvidia_router_url") or _NVIDIA_ROUTER_DEFAULT
if backend == "faster_whisper":
return settings.get("faster_whisper_url") or _FASTER_WHISPER_DEFAULT
if backend == "whisper_cpp":
return settings.get("whisper_cpp_url") or _WHISPER_CPP_DEFAULT
if backend == "groq_whisper":
return _GROQ_STT_ENDPOINT
return settings.get("whisper_url") or _WHISPER_DEFAULT
def _stt_backend_model(backend: str) -> str:
backend = _clean_stt_backend(backend)
return "whisper-1" if backend in {"nvidia_parakeet", "nvidia_router"} else "large-v3"
if backend in {"nvidia_parakeet", "nvidia_router"}:
return "whisper-1"
if backend == "whisper_cpp":
return "whisper-1"
if backend == "groq_whisper":
return "whisper-large-v3-turbo"
return "large-v3"
def _stt_backend_label(backend: str, url: str) -> str:
@ -1746,9 +1794,19 @@ def _stt_backend_label(backend: str, url: str) -> str:
"configured": "Configured Whisper/STT",
"nvidia_parakeet": "NVIDIA Parakeet ASR",
"nvidia_router": "NVIDIA Speech Router",
"faster_whisper": "faster-whisper (CTranslate2 GPU)",
"whisper_cpp": "whisper.cpp (CPU/CUDA)",
"groq_whisper": "Groq Whisper (cloud · free)",
}
port = _backend_port_label(url)
return f"{port} {labels.get(backend, backend)}" if port else labels.get(backend, backend)
label = labels.get(backend, backend)
return f"{port} {label}" if port else label
def _stt_backend_api_key(settings: dict, backend: str) -> str:
if backend == "groq_whisper":
return settings.get("groq_api_key", "").strip()
return settings.get("whisper_api_key", "").strip()
def _stt_backend_health(url: str) -> tuple[bool, list[str]]:
@ -1781,12 +1839,19 @@ async def stt_backends():
settings = _load_settings()
items = []
seen_urls: set[tuple[str, str]] = set()
for backend in ("configured", "nvidia_parakeet", "nvidia_router"):
url = _validate_http_url(_stt_backend_url(settings, backend), allow_private=True).rstrip("/")
ordered = ("configured", "faster_whisper", "whisper_cpp", "groq_whisper", "nvidia_parakeet", "nvidia_router")
for backend in ordered:
raw_url = _stt_backend_url(settings, backend)
url = _validate_http_url(raw_url, allow_private=True).rstrip("/")
key = (backend, url)
if key in seen_urls:
continue
seen_urls.add(key)
api_key = _stt_backend_api_key(settings, backend)
if backend == "groq_whisper":
ok = bool(api_key)
models: list[str] = ["whisper-large-v3-turbo", "whisper-large-v3", "distil-whisper-large-v3-en"]
else:
ok, models = _stt_backend_health(url)
items.append({
"id": backend,
@ -1796,6 +1861,7 @@ async def stt_backends():
"available": ok,
"model": _stt_backend_model(backend),
"models": models,
"metrics": _STT_BACKEND_METRICS.get(backend, {}),
})
return {"backends": items}
@ -1817,7 +1883,7 @@ def _transcription_text_from_response(resp: requests.Response) -> str:
def _transcribe_audio(src: Path, settings: dict, backend: str = "configured") -> tuple[str, str]:
backend = _clean_stt_backend(backend)
stt_url = _validate_http_url(_stt_backend_url(settings, backend), allow_private=True).rstrip("/")
stt_key = settings.get("whisper_api_key", "").strip()
stt_key = _stt_backend_api_key(settings, backend)
hdrs = {"Authorization": f"Bearer {stt_key}"} if stt_key else {}
model = _stt_backend_model(backend)
with src.open("rb") as f:
@ -2928,6 +2994,21 @@ def _voice_ids_from_payload(payload) -> list:
return grouped
_KOKORO_BUILTIN_VOICES = [
"af", # Default American Female
"af_bella", # Bella — American Female (warm)
"af_nicole", # Nicole — American Female (clear)
"af_sarah", # Sarah — American Female (expressive)
"af_sky", # Sky — American Female (bright)
"bf_emma", # Emma — British Female (refined)
"bf_isabella",# Isabella — British Female (elegant)
"am_adam", # Adam — American Male (deep)
"am_michael", # Michael — American Male (smooth)
"bm_george", # George — British Male (authoritative)
"bm_lewis", # Lewis — British Male (natural)
]
def _fetch_backend_voices(settings: dict, backend: str) -> list:
backend = _clean_preview_backend(backend)
if backend in {"nvidia_zeroshot", "nvidia_flow"}:
@ -2944,6 +3025,8 @@ def _fetch_backend_voices(settings: dict, backend: str) -> list:
return voices
except Exception:
continue
if backend == "kokoro":
return _KOKORO_BUILTIN_VOICES
return []
@ -3012,6 +3095,7 @@ def _backend_display_name(backend: str, url: str) -> str:
"nvidia_magpie": "NVIDIA Magpie TTS",
"nvidia_zeroshot":"NVIDIA Magpie Zeroshot Clone",
"nvidia_flow": "NVIDIA Magpie Flow Clone",
"kokoro": "Kokoro FastAPI (82M)",
}
port = _backend_port_label(url)
return f"{port} {names.get(backend, backend)}" if port else names.get(backend, backend)
@ -3024,63 +3108,64 @@ def _backend_capabilities(backend: str) -> dict:
"identity": "Strongest match to saved WAV voices.",
"style": "Weak per-request style; instruct may be ignored.",
"best_for": "Known voices, multilingual cloning, benchmarks, and reliable speaker identity.",
"uses_wav": True,
"style_aware": False,
"true_streaming": False,
"uses_wav": True, "style_aware": False, "true_streaming": False,
"speed": "~0.3× GPU", "latency": "13 s", "quality": "Premium clone", "ram": "68 GB VRAM",
},
"voice_design": {
"purpose": "Create or reuse prompt-designed voices from natural-language descriptions.",
"identity": "Prompt persona, not the selected WAV speaker unless you first export/clone it.",
"style": "Strong style and emotion control through instruct text.",
"best_for": "New characters, personas, dialogue, and designing reference WAVs to clone later.",
"uses_wav": False,
"style_aware": True,
"true_streaming": False,
"uses_wav": False, "style_aware": True, "true_streaming": False,
"speed": "~0.4× GPU", "latency": "13 s", "quality": "Premium", "ram": "68 GB VRAM",
},
"customvoice": {
"purpose": "Generate speech with the CustomVoice model voices.",
"identity": "Uses CustomVoice speakers, not arbitrary active WAV voices unless trained/configured there.",
"style": "Good per-request style and emotion control.",
"best_for": "Controlled style with configured CustomVoice speakers.",
"uses_wav": False,
"style_aware": True,
"true_streaming": False,
"uses_wav": False, "style_aware": True, "true_streaming": False,
"speed": "~0.3× GPU", "latency": "13 s", "quality": "Premium", "ram": "68 GB VRAM",
},
"streaming": {
"purpose": "Low-latency playback from saved WAV/reference voices.",
"identity": "Same WAV voice identity path as Base.",
"style": "Weak per-request style in the current streaming server.",
"best_for": "Long text, assistants, Open WebUI/SillyTavern playback that can start before completion.",
"uses_wav": True,
"style_aware": False,
"true_streaming": True,
"uses_wav": True, "style_aware": False, "true_streaming": True,
"speed": "~0.1× GPU", "latency": "0.51 s", "quality": "Premium", "ram": "68 GB VRAM",
},
"nvidia_magpie": {
"purpose": "Generate speech with NVIDIA Magpie fixed speaker voices.",
"identity": "Uses Magpie speaker aliases such as sofia, aria, jason, leo, and john; it is not a WAV voice-cloning model.",
"style": "Language and speaker are controlled by the backend voice config; per-request style text is usually ignored.",
"best_for": "Fast local NVIDIA TTS voices and OpenAI-compatible assistant playback.",
"uses_wav": False,
"style_aware": False,
"true_streaming": False,
"uses_wav": False, "style_aware": False, "true_streaming": False,
"speed": "~0.05× GPU", "latency": "0.30.8 s", "quality": "High", "ram": "46 GB VRAM",
},
"nvidia_zeroshot": {
"purpose": "Clone a saved library voice through NVIDIA Magpie TTS Zeroshot NIM.",
"identity": "Sends the selected WAV as audio_prompt; no prompt transcript is required.",
"style": "Best with a clear 3-10 second prompt. Optional quality params can be configured in Settings.",
"best_for": "Fast NVIDIA reference-audio cloning, streaming-class use cases, live agents, and games.",
"uses_wav": True,
"style_aware": False,
"true_streaming": False,
"uses_wav": True, "style_aware": False, "true_streaming": False,
"speed": "~0.1× GPU", "latency": "0.51 s", "quality": "High clone", "ram": "46 GB VRAM",
},
"nvidia_flow": {
"purpose": "Clone a saved library voice through NVIDIA Magpie TTS Flow NIM.",
"identity": "Sends the selected WAV plus its exact saved reference transcript.",
"style": "Offline high-fidelity clone path; prompt transcript must match the reference audio.",
"best_for": "Studio-style dubbing, narration, and podcast-quality offline generation.",
"uses_wav": True,
"style_aware": False,
"true_streaming": False,
"uses_wav": True, "style_aware": False, "true_streaming": False,
"speed": "~0.2× GPU", "latency": "12 s", "quality": "Studio", "ram": "46 GB VRAM",
},
"kokoro": {
"purpose": "High-quality English TTS with Kokoro 82M model. OpenAI-compatible endpoint.",
"identity": "Uses Kokoro built-in voices (af_bella, bf_emma, am_adam, …); no WAV cloning.",
"style": "Voice selection via voice ID. Style instruction is not supported.",
"best_for": "Fast, high-quality CPU TTS. Low RAM footprint. Easy local Docker setup.",
"uses_wav": False, "style_aware": False, "true_streaming": False,
"speed": "~0.1× CPU", "latency": "0.20.5 s", "quality": "High (82M)", "ram": "300 MB CPU",
},
}
return caps.get(_clean_preview_backend(backend), {})
@ -3099,7 +3184,7 @@ def _backend_health(url: str) -> bool:
def _backend_available(backend: str, voices: list, health: bool) -> bool:
if _clean_preview_backend(backend) in {"nvidia_zeroshot", "nvidia_flow"}:
if _clean_preview_backend(backend) in {"nvidia_zeroshot", "nvidia_flow", "kokoro"}:
return health
return bool(voices) or health
@ -3108,7 +3193,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", "nvidia_magpie", "nvidia_zeroshot", "nvidia_flow"):
for backend in ("voice_clone", "voice_design", "customvoice", "streaming", "kokoro", "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)
@ -3576,6 +3661,14 @@ def _preview_request_audio(text: str, voice: str, settings: dict, instruct: str
return _nvidia_clone_request_audio(text, voice, settings, "zeroshot")
if backend == "nvidia_flow":
return _nvidia_clone_request_audio(text, voice, settings, "flow")
if backend == "kokoro":
return _tts_request_audio(
text, voice, settings, instruct,
url_override=_preview_backend_base_url(settings, "kokoro"),
api_key_override=settings.get("tts_api_key", ""),
backend_override="openai",
extra_backend="kokoro",
)
return _tts_request_audio(text, voice, settings, instruct)

View File

@ -1757,8 +1757,28 @@ function backendHelpHtml(b, compact = false) {
b.style_aware ? ['good', 'style-aware'] : ['warn', 'weak style'],
b.true_streaming ? ['good', 'true streaming'] : ['', 'buffered/normal'],
].map(([cls, text]) => `<span class="backend-tag ${cls}">${escHtml(text)}</span>`).join('');
const metricParts = [];
if (b.speed) metricParts.push(`<span class="backend-metric-tag">&#9889; ${escHtml(b.speed)}</span>`);
if (b.latency) metricParts.push(`<span class="backend-metric-tag">&#9200; ${escHtml(b.latency)}</span>`);
if (b.quality) metricParts.push(`<span class="backend-metric-tag">&#11088; ${escHtml(b.quality)}</span>`);
if (b.ram) metricParts.push(`<span class="backend-metric-tag">&#128190; ${escHtml(b.ram)}</span>`);
const metrics = metricParts.length ? `<div class="backend-metrics-row">${metricParts.join('')}</div>` : '';
const detail = compact ? escHtml(b.best_for || '') : `${escHtml(b.purpose || '')}<br><strong>Identity:</strong> ${escHtml(b.identity || '')}<br><strong>Style:</strong> ${escHtml(b.style || '')}<br><strong>Best for:</strong> ${escHtml(b.best_for || '')}`;
return `<strong>${escHtml(b.label)}</strong><div class="backend-help-tags">${tags}</div><div>${detail}</div>`;
return `<strong>${escHtml(b.label)}</strong><div class="backend-help-tags">${tags}</div>${metrics}<div>${detail}</div>`;
}
function sttBackendHelpHtml(b) {
if (!b) return 'No STT engine selected.';
const m = b.metrics || {};
const metricParts = [];
if (m.speed) metricParts.push(`<span class="backend-metric-tag">&#9889; ${escHtml(m.speed)}</span>`);
if (m.latency) metricParts.push(`<span class="backend-metric-tag">&#9200; ${escHtml(m.latency)}</span>`);
if (m.quality) metricParts.push(`<span class="backend-metric-tag">&#11088; ${escHtml(m.quality)}</span>`);
if (m.ram) metricParts.push(`<span class="backend-metric-tag">&#128190; ${escHtml(m.ram)}</span>`);
const metrics = metricParts.length ? `<div class="backend-metrics-row">${metricParts.join('')}</div>` : '';
const modelList = Array.isArray(b.models) && b.models.length ? ' Models: ' + b.models.slice(0, 4).join(', ') + '.' : '';
const avail = b.available ? `<span class="backend-tag good">ready</span>` : `<span class="backend-tag warn">unavailable</span>`;
return `<strong>${escHtml(b.label)}</strong> ${avail}${metrics}<div style="margin-top:4px;font-size:0.85em;opacity:.8">${escHtml(b.url)}${escHtml(modelList)}</div>`;
}
function updateBackendHelp() {
@ -1864,6 +1884,10 @@ async function loadSettings() {
$('s-whisper-url').value = s.whisper_url || '';
$('s-whisper-key').value = s.whisper_api_key || '';
$('s-tts-url').value = s.tts_url || '';
$('s-faster-whisper-url').value = s.faster_whisper_url || '';
$('s-whisper-cpp-url').value = s.whisper_cpp_url || '';
$('s-groq-api-key').value = s.groq_api_key || '';
$('s-kokoro-url').value = s.kokoro_url || '';
_appSettings = s;
$('s-tts-stream-url').value = s.tts_stream_url || '';
$('s-customvoice-url').value = s.customvoice_url || 'http://host.docker.internal:8022';
@ -1884,6 +1908,7 @@ async function loadSettings() {
$('s-tts-extra-nvidia-magpie').value = JSON.stringify(byBackend.nvidia_magpie || {}, null, 2);
$('s-tts-extra-nvidia-zeroshot').value = JSON.stringify(byBackend.nvidia_zeroshot || {}, null, 2);
$('s-tts-extra-nvidia-flow').value = JSON.stringify(byBackend.nvidia_flow || {}, null, 2);
$('s-tts-extra-kokoro').value = JSON.stringify(byBackend.kokoro || {}, null, 2);
$('s-voice-design-url').value = s.voice_design_url || 'http://host.docker.internal:8021';
$('s-vd-key').value = s.voice_design_api_key || '';
$('s-voices-scan-dir').value = s.voices_scan_dir || '';
@ -1913,6 +1938,8 @@ $('settings-btn').addEventListener('click', async () => { await loadSettings();
$('s-close-btn').addEventListener('click', async () => { await loadSettings(); toast('Settings reloaded', 'success'); });
$('s-use-parakeet-asr')?.addEventListener('click', () => { $('s-whisper-url').value = $('s-nvidia-asr-url').value || 'http://host.docker.internal:8092'; });
$('s-use-nvidia-router')?.addEventListener('click', () => { const url = $('s-nvidia-router-url').value || 'http://host.docker.internal:8090'; $('s-whisper-url').value = url; $('s-nvidia-tts-url').value = url; });
$('s-use-faster-whisper')?.addEventListener('click', () => { $('s-whisper-url').value = $('s-faster-whisper-url').value || 'http://host.docker.internal:8000'; });
$('s-use-whisper-cpp')?.addEventListener('click', () => { $('s-whisper-url').value = $('s-whisper-cpp-url').value || 'http://host.docker.internal:8080'; });
$('s-save-btn').addEventListener('click', async () => {
let ttsExtraParamsByBackend = {};
const paramFields = [
@ -1923,6 +1950,7 @@ $('s-save-btn').addEventListener('click', async () => {
['nvidia_magpie', 's-tts-extra-nvidia-magpie', 'NVIDIA Magpie'],
['nvidia_zeroshot', 's-tts-extra-nvidia-zeroshot', 'NVIDIA Zeroshot'],
['nvidia_flow', 's-tts-extra-nvidia-flow', 'NVIDIA Flow'],
['kokoro', 's-tts-extra-kokoro', 'Kokoro'],
];
try {
for (const [key, id, label] of paramFields) {
@ -1936,7 +1964,11 @@ $('s-save-btn').addEventListener('click', async () => {
body: JSON.stringify({
whisper_url: $('s-whisper-url').value,
whisper_api_key: $('s-whisper-key').value,
faster_whisper_url: $('s-faster-whisper-url').value,
whisper_cpp_url: $('s-whisper-cpp-url').value,
groq_api_key: $('s-groq-api-key').value,
tts_url: $('s-tts-url').value,
kokoro_url: $('s-kokoro-url').value,
tts_stream_url: $('s-tts-stream-url').value,
customvoice_url: $('s-customvoice-url').value,
nvidia_router_url: $('s-nvidia-router-url').value,
@ -5805,8 +5837,7 @@ function updateSttBackendHelp() {
const help = $('stt-tts-stt-help');
if (!help) return;
if (!b) { help.textContent = 'No STT engine status loaded yet.'; return; }
const models = Array.isArray(b.models) && b.models.length ? ' Models: ' + b.models.slice(0, 4).join(', ') + '.' : '';
help.textContent = `${b.available ? 'Ready' : 'Unavailable'} at ${b.url}.${models}`;
help.innerHTML = sttBackendHelpHtml(b);
}
async function refreshSttBackends(selected = '') {
@ -6346,6 +6377,15 @@ function renderLocalContainers(containers) {
'magpie-tts': '🐦',
'parakeet-rnnt-nim': '🦜',
};
const DC_METRICS = {
'faster-qwen3-tts-voiceclone': [['⚡','~0.3× GPU'],['⏰','13 s'],['⭐','Premium clone'],['💾','68 GB VRAM']],
'faster-qwen3-tts-voicedesign': [['⚡','~0.4× GPU'],['⏰','13 s'],['⭐','Premium'], ['💾','68 GB VRAM']],
'faster-qwen3-tts-customvoice': [['⚡','~0.3× GPU'],['⏰','13 s'],['⭐','Premium'], ['💾','68 GB VRAM']],
'faster-qwen3-tts-streaming': [['⚡','~0.1× GPU'],['⏰','0.51 s'],['⭐','Premium'], ['💾','68 GB VRAM']],
'magpie-tts': [['⚡','~0.05× GPU'],['⏰','0.30.8 s'],['⭐','High'], ['💾','46 GB VRAM']],
'parakeet-asr': [['⚡','~200× RT GPU'],['⏰','<0.3 s'],['⭐','Parakeet-TDT'],['💾','2 GB VRAM']],
'parakeet-rnnt-nim': [['⚡','~200× RT GPU'],['⏰','<0.3 s'],['⭐','Parakeet-1B'], ['💾','2 GB VRAM']],
};
const roleIcon = { tts: '🔊', stt: '🎙️', 'stt+tts': '🔄', llm: '🤖' };
grid.innerHTML = containers.map(c => {
@ -6362,6 +6402,9 @@ function renderLocalContainers(containers) {
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 metricChips = (DC_METRICS[c.name] || [])
.map(([em, txt]) => `<span class="dc-metric-chip">${em} ${escHtml(txt)}</span>`).join('');
const metricsHtml = metricChips ? `<div class="dc-metrics">${metricChips}</div>` : '';
const n = escHtml(c.name);
const actions = installed
@ -6385,6 +6428,7 @@ function renderLocalContainers(containers) {
</span>
</div>
<div class="dc-status-label">${escHtml(stLabel)}</div>
${metricsHtml}
${c.description ? `<p class="dc-desc">${escHtml(c.description)}</p>` : ''}
<div class="dc-actions">${actions}</div>
</div>`;
@ -6524,6 +6568,44 @@ $('dc-refresh-btn')?.addEventListener('click', loadLocalContainers);
});
});
// ── "Use as STT / TTS" quick-apply buttons in AI Backends section ──────────
async function applyAndSaveSettings(patch) {
try {
const resp = await fetch('/api/settings').then(r => r.json());
const updated = { ...resp, ...patch };
await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) });
Object.assign(_appSettings || {}, patch);
// refresh visible inputs in Settings if open
for (const [id, val] of Object.entries(patch)) {
const inp = $('s-' + id.replace(/_/g, '-'));
if (inp && inp.value !== undefined) inp.value = val;
}
await refreshTtsBackendAvailability();
await refreshSttBackends();
} catch (e) { toast('Apply failed: ' + e.message, 'error'); }
}
$('llm-use-faster-whisper-stt')?.addEventListener('click', () => {
const url = document.querySelector('[data-llm-local-key="faster-whisper"]')?.value.trim()
|| 'http://host.docker.internal:8000';
applyAndSaveSettings({ faster_whisper_url: url });
toast('faster-whisper-server URL saved → Settings. Use the "faster-whisper" engine in the STT dropdown.', 'success');
});
$('llm-use-whisper-cpp-stt')?.addEventListener('click', () => {
const url = document.querySelector('[data-llm-local-key="whisper-cpp"]')?.value.trim()
|| 'http://host.docker.internal:8080';
applyAndSaveSettings({ whisper_cpp_url: url });
toast('whisper.cpp URL saved → Settings. Use the "whisper.cpp" engine in the STT dropdown.', 'success');
});
$('llm-use-kokoro-tts')?.addEventListener('click', () => {
const url = document.querySelector('[data-llm-local-key="kokoro"]')?.value.trim()
|| 'http://host.docker.internal:8880/v1';
applyAndSaveSettings({ kokoro_url: url });
toast('Kokoro FastAPI URL saved → Settings. It now appears as "Kokoro FastAPI (82M)" in the TTS backend dropdown.', 'success');
});
})();
// ── Collapsible cards ──────────────────────────────────────────────────────

View File

@ -140,12 +140,21 @@ Check "Enable CORS" for browser access</pre>
<span class="llm-local-name">faster-whisper-server</span>
<span class="llm-local-compat">OpenAI-compat</span>
</div>
<div class="llm-local-metrics">
<span class="llm-metric-chip">&#9889; ~70&times; RT GPU</span>
<span class="llm-metric-chip">&#9200; 0.52 s</span>
<span class="llm-metric-chip">&#11088; large-v3</span>
<span class="llm-metric-chip">&#128190; 1.5 GB VRAM</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">
<input class="llm-local-url-inp" type="text" placeholder="http://localhost:8000" data-llm-local-key="faster-whisper" data-llm-local-default="http://localhost:8000" spellcheck="false">
<button class="llm-local-ping" data-ping-key="faster-whisper" title="Test connection">Connect</button>
</div>
<div class="llm-local-actions">
<button class="llm-use-btn" id="llm-use-faster-whisper-stt" type="button" title="Copy URL to Settings → faster-whisper-server URL and set as active STT">&#128203; Use as STT</button>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar">
<span>docker-compose snippet</span>
@ -171,12 +180,21 @@ Check "Enable CORS" for browser access</pre>
<span class="llm-local-name">whisper.cpp</span>
<span class="llm-local-compat">HTTP server</span>
</div>
<div class="llm-local-metrics">
<span class="llm-metric-chip">&#9889; ~815&times; RT CPU</span>
<span class="llm-metric-chip">&#9200; 15 s</span>
<span class="llm-metric-chip">&#11088; large-v3 Q5</span>
<span class="llm-metric-chip">&#128190; ~1 GB RAM</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-actions">
<button class="llm-use-btn" id="llm-use-whisper-cpp-stt" type="button" title="Copy URL to Settings → whisper.cpp URL and set as active STT">&#128203; Use as STT</button>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar">
<span>Build &amp; run</span>
@ -202,7 +220,13 @@ cd whisper.cpp && cmake -B build && cmake --build build -j
<span class="llm-local-name">Piper TTS</span>
<span class="llm-local-compat">Fast &middot; offline</span>
</div>
<p class="llm-local-desc">Lightning-fast offline TTS. Runs on CPU in real time. 50+ language voices available. Ideal for low-latency pipelines.</p>
<div class="llm-local-metrics">
<span class="llm-metric-chip">&#9889; ~1× CPU realtime</span>
<span class="llm-metric-chip">&#9200; &lt;50 ms</span>
<span class="llm-metric-chip">&#11088; Good (VITS)</span>
<span class="llm-metric-chip">&#128190; ~50 MB RAM</span>
</div>
<p class="llm-local-desc">Lightning-fast offline TTS. Runs on CPU in real time. 50+ language voices available. Uses Wyoming protocol (port 10200) &mdash; not directly OpenAI-compatible.</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">
@ -226,12 +250,21 @@ cd whisper.cpp && cmake -B build && cmake --build build -j
<span class="llm-local-name">Kokoro FastAPI</span>
<span class="llm-local-compat">OpenAI-compat TTS</span>
</div>
<p class="llm-local-desc">Kokoro-82M running behind an OpenAI-compatible TTS endpoint. Drop-in replacement for OpenAI&rsquo;s TTS API.</p>
<div class="llm-local-metrics">
<span class="llm-metric-chip">&#9889; ~0.1&times; CPU RTF</span>
<span class="llm-metric-chip">&#9200; ~200 ms</span>
<span class="llm-metric-chip">&#11088; High (82M)</span>
<span class="llm-metric-chip">&#128190; 300 MB CPU</span>
</div>
<p class="llm-local-desc">Kokoro-82M running behind an OpenAI-compatible TTS endpoint. 11 built-in voices (af_bella, bf_emma, am_adam&hellip;). Drop-in replacement for OpenAI&rsquo;s TTS API.</p>
<div class="llm-local-url">
<span class="llm-local-url-label">URL</span>
<input class="llm-local-url-inp" type="text" placeholder="http://localhost:8880/v1" data-llm-local-key="kokoro" data-llm-local-default="http://localhost:8880/v1" spellcheck="false">
<button class="llm-local-ping" data-ping-key="kokoro" title="Test connection">Connect</button>
</div>
<div class="llm-local-actions">
<button class="llm-use-btn" id="llm-use-kokoro-tts" type="button" title="Copy URL to Settings → Kokoro FastAPI URL and enable Kokoro in Try It Out">&#128203; Use as TTS</button>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar">
<span>Docker</span>
@ -252,6 +285,12 @@ docker run -p 8880:8880 --gpus all \
<span class="llm-local-name">XTTS v2</span>
<span class="llm-local-compat">Voice cloning</span>
</div>
<div class="llm-local-metrics">
<span class="llm-metric-chip">&#9889; ~0.5× GPU RTF</span>
<span class="llm-metric-chip">&#9200; 13 s</span>
<span class="llm-metric-chip">&#11088; High (WAV clone)</span>
<span class="llm-metric-chip">&#128190; 34 GB VRAM</span>
</div>
<p class="llm-local-desc">Coqui XTTS &mdash; multilingual voice cloning from a 6-second sample. 17 languages. Compatible with this app&rsquo;s voice library.</p>
<div class="llm-local-url">
<span class="llm-local-url-label">URL</span>
@ -307,8 +346,8 @@ docker run -p 8880:8880 --gpus all \
</div>
<div class="llm-card-stats">
<span>2 000 req / day</span>
<span>Fastest inference</span>
<span>OpenAI API format</span>
<span>&#9889; Fastest cloud STT</span>
<span>&#128190; Cloud &middot; 0 VRAM</span>
</div>
<div class="llm-field-row">
<label class="llm-label">API key</label>
@ -324,6 +363,7 @@ docker run -p 8880:8880 --gpus all \
<span class="llm-model-tag">whisper-large-v3</span>
<span class="llm-model-tag">distil-whisper-large-v3-en</span>
</div>
<div class="llm-info-note">Save key in <strong>Settings &rarr; Groq API key</strong> to enable <em>Groq Whisper</em> in the STT dropdown &rarr; <em>Try It Out</em> and <em>Clone a Voice</em>.</div>
</div>
<div class="llm-card">

View File

@ -34,7 +34,7 @@
<div class="settings-cluster">
<div class="settings-cluster-head">
<strong>TTS Text to Speech</strong>
<span>Qwen3 engines: clone, design, custom, streaming</span>
<span>Qwen3 engines: clone, design, custom, streaming &middot; Kokoro FastAPI</span>
</div>
<div class="settings-grid compact">
<div class="s-field">
@ -57,6 +57,11 @@
<input type="text" id="s-tts-stream-url" placeholder="http://host.docker.internal:8023">
<span class="s-hint">Progressive low-latency WAV playback.</span>
</div>
<div class="s-field">
<label>Kokoro FastAPI URL <span style="font-weight:400">(82M &middot; CPU-friendly)</span></label>
<input type="text" id="s-kokoro-url" placeholder="http://host.docker.internal:8880/v1">
<span class="s-hint">OpenAI-compatible TTS. Built-in voices: af_bella, bf_emma, am_adam&hellip; ~300 MB RAM &middot; ~0.1&times; CPU RTF.</span>
</div>
</div>
</div>
@ -97,18 +102,38 @@
<div class="settings-cluster settings-cluster-stt">
<div class="settings-cluster-head">
<strong>STT Speech to Text</strong>
<span>Whisper, Parakeet, or NVIDIA router</span>
<span>Whisper, faster-whisper, whisper.cpp, Groq, Parakeet, NVIDIA router</span>
</div>
<div class="settings-grid compact stt-settings-grid">
<div class="s-field stt-url-field">
<label>Whisper/STT URL</label>
<label>Configured Whisper/STT URL</label>
<input type="text" id="s-whisper-url" placeholder="http://host.docker.internal:8010">
<span class="s-hint">Reference text recognition. Expected: <code>POST /v1/audio/transcriptions</code>.</span>
<span class="s-hint">Default recognition endpoint. Expected: <code>POST /v1/audio/transcriptions</code>.</span>
<div class="btn-row settings-mini-actions">
<button class="btn-secondary" id="s-use-parakeet-asr" type="button">Use Parakeet</button>
<button class="btn-secondary" id="s-use-nvidia-router" type="button">Use router</button>
<button class="btn-secondary" id="s-use-faster-whisper" type="button">Use faster-whisper</button>
<button class="btn-secondary" id="s-use-whisper-cpp" type="button">Use whisper.cpp</button>
</div>
</div>
<div class="s-field">
<label>faster-whisper-server URL <span style="font-weight:400">(CTranslate2 GPU)</span></label>
<input type="text" id="s-faster-whisper-url" placeholder="http://host.docker.internal:8000">
<span class="s-hint">GPU-accelerated Whisper via CTranslate2. ~70&times; RT &middot; 1.5 GB VRAM &middot; <code>POST /v1/audio/transcriptions</code>.</span>
</div>
<div class="s-field">
<label>whisper.cpp URL <span style="font-weight:400">(CPU/CUDA)</span></label>
<input type="text" id="s-whisper-cpp-url" placeholder="http://host.docker.internal:8080">
<span class="s-hint">Lightweight C++ Whisper server. ~8&ndash;15&times; RT CPU &middot; ~1 GB RAM &middot; <code>POST /v1/audio/transcriptions</code>.</span>
</div>
<div class="s-field">
<label>Groq API key <span style="font-weight:400">(Groq Whisper &amp; LLM)</span></label>
<div class="s-key-row">
<input type="password" id="s-groq-api-key" placeholder="gsk_&hellip;" autocomplete="off">
<button type="button" class="s-eye-btn" data-target="s-groq-api-key">&#128065;</button>
</div>
<span class="s-hint">Used for Groq Whisper STT (whisper-large-v3-turbo) and Groq LLM. Free: 2 000 req/day. Fastest cloud transcription.</span>
</div>
</div>
</div>
</div>
@ -185,6 +210,11 @@
<textarea id="s-tts-extra-nvidia-flow" spellcheck="false" placeholder="{}"></textarea>
<span class="s-hint">Optional multipart fields. The app also sends the saved reference transcript.</span>
</div>
<div class="s-field">
<label>Kokoro FastAPI params</label>
<textarea id="s-tts-extra-kokoro" spellcheck="false" placeholder="{}"></textarea>
<span class="s-hint">Extra JSON fields for the Kokoro backend. Usually empty &mdash; voice is selected from the voice dropdown.</span>
</div>
</div>
</details>

View File

@ -91,7 +91,7 @@
<select id="stt-tts-stt-backend"><option value="configured">Configured Whisper/STT</option></select>
<button class="btn-secondary" id="stt-tts-refresh-stt-btn" type="button">Refresh</button>
</div>
<span class="s-hint" id="stt-tts-stt-help">Uses Settings &rarr; Whisper/STT URL by default.</span>
<div class="s-hint" id="stt-tts-stt-help">Uses Settings &rarr; Whisper/STT URL by default.</div>
</div>
<div class="s-field">
<label>Speech audio</label>

View File

@ -542,6 +542,8 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.backend-tag { border: 1px solid var(--border); border-radius: 4px; padding: 2px 6px; background: var(--panel); color: var(--subtext); font-size: 12px; }
.backend-tag.good { color: var(--green); border-color: rgba(22,163,74,.35); }
.backend-tag.warn { color: var(--yellow); border-color: rgba(217,119,6,.35); }
.backend-metrics-row { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 2px; }
.backend-metric-tag { border: 1px solid rgba(99,102,241,.3); border-radius: 4px; padding: 1px 6px; background: rgba(99,102,241,.07); color: var(--accent); font-size: 11px; white-space: nowrap; }
.style-backend-warn {
margin-top: 6px; padding: 7px 10px; border-radius: 6px;
background: rgba(220,38,38,.07); border: 1px solid rgba(220,38,38,.25);
@ -882,7 +884,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.vl-avatar-img { width: 100%; height: 100%; object-fit: cover; border-radius: var(--radius); }
.vl-avatar-flag { background: transparent; }
.vl-avatar-flag .fi { display: block; width: 100%; height: 100%; background-size: cover; background-position: center; border-radius: var(--radius); }
.vl-avatar-initial { }
.vl-avatar-initial { background: transparent; }
.vl-avatar-letter { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; font-size: 17px; font-weight: 800; border-radius: var(--radius); }
/* Voice info block (name + type badge + gender chip) */
@ -1479,6 +1481,25 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
}
.llm-local-link:hover { text-decoration: underline; }
/* Metric chips inside AI Backend cards */
.llm-local-metrics { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 4px; }
.llm-metric-chip {
font-size: 10px; padding: 1px 6px; border-radius: 4px;
border: 1px solid rgba(99,102,241,.3); background: rgba(99,102,241,.07);
color: var(--accent); white-space: nowrap;
}
/* Use-as-STT/TTS action button inside AI Backend cards */
.llm-local-actions { display: flex; gap: 6px; flex-wrap: wrap; }
.llm-use-btn {
font-size: 11px; padding: 3px 9px; border-radius: 5px;
border: 1px solid rgba(13,148,136,.4); background: rgba(13,148,136,.07);
color: var(--teal); cursor: pointer; font-family: inherit; font-weight: 600;
transition: background .15s, border-color .15s;
}
.llm-use-btn:hover { background: rgba(13,148,136,.15); border-color: var(--teal); }
.llm-use-btn:active { background: rgba(13,148,136,.25); }
/* Password show/hide toggle */
.llm-eye-btn {
background: none; border: 1px solid var(--border); border-radius: 5px;
@ -1622,6 +1643,12 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
}
.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-metrics { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 4px; }
.dc-metric-chip {
font-size: 10px; padding: 1px 6px; border-radius: 4px;
border: 1px solid rgba(99,102,241,.3); background: rgba(99,102,241,.07);
color: var(--accent); white-space: nowrap;
}
.dc-actions { margin-top: 6px; display: flex; gap: 6px; flex-wrap: wrap; }
.dc-btn { font-size: 12px; padding: 4px 12px; }