diff --git a/.gitignore b/.gitignore index f3e3a54..1cb9fa2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,11 +3,13 @@ app/models/ app/openvino-models/ app/llm-models/ app/speech-models/ +app/tts-models/ # Downloaded portable runtimes/backends app/backend/ app/llm-backend/ app/speech-backend/ +app/tts-runtime/ app/tools/ # Generated files @@ -15,6 +17,8 @@ app/config/ app/dist/ app/outputs/ app/transcriptions/ +app/tts-outputs/ +app/tts-cache/ # Dependencies and platform-specific copies **/node_modules/ diff --git a/app/frontend/src/App.jsx b/app/frontend/src/App.jsx index 39b6607..2328c5e 100644 --- a/app/frontend/src/App.jsx +++ b/app/frontend/src/App.jsx @@ -6,7 +6,8 @@ import ModelManager from "./components/ModelManager"; import Settings from "./components/Settings"; import TextChat from "./components/TextChat"; import SpeechTranscriber from "./components/SpeechTranscriber"; -import { cleanupCandidates, formatBytes, getCleanupCandidates, getDiagnostics, getHardwareSpecs, getHealth, getTelemetry, getBackendOptions, getBackendStatus, listGeneratedOutputs, listSpeechTranscriptions, deleteSpeechTranscription, stopServer } from "./services/api"; +import TextToSpeech from "./components/TextToSpeech"; +import { cleanupCandidates, formatBytes, getCleanupCandidates, getDiagnostics, getHardwareSpecs, getHealth, getTelemetry, getBackendOptions, getBackendStatus, listGeneratedOutputs, listSpeechTranscriptions, deleteSpeechTranscription, listTtsOutputs, deleteTtsOutput, stopServer } from "./services/api"; import "./App.css"; function App() { @@ -183,6 +184,29 @@ function App() { localStorage.setItem("speechSettings", JSON.stringify(speechSettings)); }, [speechSettings]); + const [ttsSettings, setTtsSettings] = useState(() => { + const saved = localStorage.getItem("ttsSettings"); + if (saved) { + try { + const parsed = JSON.parse(saved); + return { + model: parsed.model || "", + voice: parsed.voice || "af_heart", + speed: Math.max(0.5, Math.min(2, Number(parsed.speed) || 1)), + }; + } catch (_) {} + } + return { + model: "", + voice: "af_heart", + speed: 1, + }; + }); + + useEffect(() => { + localStorage.setItem("ttsSettings", JSON.stringify(ttsSettings)); + }, [ttsSettings]); + // Lifted Chat History States const [conversations, setConversations] = useState([]); const [activeConversationId, setActiveConversationId] = useState(null); @@ -190,6 +214,9 @@ function App() { const [speechTranscriptions, setSpeechTranscriptions] = useState([]); const [selectedSpeechTranscript, setSelectedSpeechTranscript] = useState(null); const [showSpeechHistory, setShowSpeechHistory] = useState(false); + const [ttsOutputs, setTtsOutputs] = useState([]); + const [selectedTtsOutput, setSelectedTtsOutput] = useState(null); + const [showTtsHistory, setShowTtsHistory] = useState(false); useEffect(() => { const saved = localStorage.getItem("chat_conversations"); @@ -315,6 +342,49 @@ function App() { refreshSpeechTranscriptions(); }, [refreshSpeechTranscriptions]); + const refreshTtsOutputs = useCallback(async () => { + try { + const list = await listTtsOutputs(); + setTtsOutputs(list); + } catch (err) { + console.warn("Could not load TTS outputs:", err); + setTtsOutputs([]); + } + }, []); + + const handleDeleteTtsOutput = useCallback(async (item, e) => { + if (e) e.stopPropagation(); + const itemId = item.filename || item.metadata; + if (!itemId) return; + + const ok = await showConfirm({ + title: "Delete TTS Output", + message: `Are you sure you want to delete "${item.displayName || item.audioFile || "this audio"}"? This action cannot be undone.`, + confirmLabel: "Delete", + danger: true + }); + if (!ok) return; + + try { + await deleteTtsOutput(itemId); + setSelectedTtsOutput((prev) => { + if (prev) { + const prevId = prev.filename || prev.metadata; + if (prevId === itemId) return null; + } + return prev; + }); + refreshTtsOutputs(); + } catch (err) { + console.error("Failed to delete TTS output:", err); + showAlert({ title: "Delete Failed", message: err.message || String(err), danger: true }); + } + }, [showConfirm, showAlert, refreshTtsOutputs]); + + useEffect(() => { + refreshTtsOutputs(); + }, [refreshTtsOutputs]); + // Load hardware specifications on mount useEffect(() => { async function loadSpecs() { @@ -565,8 +635,14 @@ function App() { showSpeechHistory={showSpeechHistory} setShowSpeechHistory={setShowSpeechHistory} onDeleteSpeechTranscription={handleDeleteSpeechTranscription} + ttsOutputs={ttsOutputs} + selectedTtsOutput={selectedTtsOutput} + setSelectedTtsOutput={setSelectedTtsOutput} + showTtsHistory={showTtsHistory} + setShowTtsHistory={setShowTtsHistory} + onDeleteTtsOutput={handleDeleteTtsOutput} /> - ), [activeTab, specs, conversations, activeConversationId, showHistory, handleDeleteConversation, speechTranscriptions, selectedSpeechTranscript, showSpeechHistory, handleDeleteSpeechTranscription]); + ), [activeTab, specs, conversations, activeConversationId, showHistory, handleDeleteConversation, speechTranscriptions, selectedSpeechTranscript, showSpeechHistory, handleDeleteSpeechTranscription, ttsOutputs, selectedTtsOutput, showTtsHistory, handleDeleteTtsOutput]); const handleStopServer = useCallback(async () => { if (!serverRunning || isStoppingServer) return; @@ -673,6 +749,17 @@ function App() { /> +
+ +
+
model.filename) : localModels.map(normalizeModel).filter((model) => model.filename); - const displayedLocalModels = activeModelType === "speech" + const displayedLocalModels = activeModelType === "speech" || activeModelType === "tts" ? normalizedLocalModels.filter((model) => model.installed) : normalizedLocalModels.filter((model) => !model.isProjector); - const allModelNames = activeModelType === "speech" + const allModelNames = activeModelType === "speech" || activeModelType === "tts" ? displayedLocalModels.map((model) => model.filename) : normalizedLocalModels.map((model) => model.filename); const modelNames = displayedLocalModels.map((model) => model.filename); @@ -306,7 +347,7 @@ function ModelManager({ group: "Recommended Text Models from Hugging Face", items: hasHuggingFaceResults ? displayedHuggingFaceModels : TEXT_MODEL_LIBRARY[0].items, }]; - } else { + } else if (activeModelType === "speech") { visibleModelLibrary = [{ group: "Whisper Speech Models", items: normalizedLocalModels.filter((model) => model.url).map((model) => ({ @@ -317,6 +358,8 @@ function ModelManager({ recommendedTiers: model.recommended ? ["Low", "Mid", "High"] : [], })), }]; + } else { + visibleModelLibrary = TTS_MODEL_LIBRARY; } useEffect(() => { @@ -393,9 +436,12 @@ function ModelManager({ } else if (activeModelType === "text") { const list = await listLlmModels(); setLocalModels(list.map(normalizeModel).filter((model) => model.filename)); - } else { + } else if (activeModelType === "speech") { const list = await listSpeechModels(); setLocalModels(list.filter((model) => model.filename)); + } else { + const list = await listTtsModels(); + setLocalModels(list.filter((model) => model.filename)); } } catch (e) { console.error(e); @@ -421,10 +467,11 @@ function ModelManager({ let cancelled = false; const updateBackendInfo = async () => { try { - const [sdStatus, llmStatus, speechStatus] = await Promise.all([ + const [sdStatus, llmStatus, speechStatus, ttsStatus] = await Promise.all([ getBackendStatus(), getLlmStatus(), - getSpeechStatus() + getSpeechStatus(), + getTtsStatus() ]); if (cancelled) return; @@ -434,6 +481,7 @@ function ModelManager({ backendDevice: sdStatus.settings?.backendDevice || sdStatus.loading?.device || "", llmBackendMode: llmStatus.settings?.backendMode || llmStatus.settings?.backendBinary || "", speechBackendMode: speechStatus.settings?.backendMode || speechStatus.backendMode || "", + ttsBackendMode: ttsStatus.settings?.backendMode || ttsStatus.backendMode || "", }); if (llmStatus.ready) { @@ -450,6 +498,13 @@ function ModelManager({ setActiveSpeechModel(null); setSpeechRunning(false); } + if (ttsStatus.ready) { + setActiveTtsModel(ttsStatus.settings?.model || null); + setTtsRunning(true); + } else { + setActiveTtsModel(null); + setTtsRunning(false); + } } catch (_) {} }; @@ -500,8 +555,10 @@ function ModelManager({ res = await downloadModel(url); } else if (activeModelType === "text") { res = await downloadLlmModel(url, expectedFilename, companion); - } else { + } else if (activeModelType === "speech") { res = await downloadSpeechModel(url, expectedFilename); + } else { + res = await downloadTtsModel(url, expectedFilename); } if (res && res.ok) { if (!companion && res.projectorUrl && res.projectorFilename) { @@ -563,7 +620,7 @@ function ModelManager({ e.preventDefault(); if (!downloadUrl.trim()) return; - let modelName = activeModelType === "image" ? "model.safetensors" : activeModelType === "text" ? "model.gguf" : "model.bin"; + let modelName = activeModelType === "image" ? "model.safetensors" : activeModelType === "text" ? "model.gguf" : activeModelType === "speech" ? "model.bin" : "model.json"; try { const parsed = new URL(downloadUrl.trim()); modelName = parsed.pathname.split("/").pop() || modelName; @@ -591,6 +648,18 @@ function ModelManager({ return; } + if (activeModelType === "tts") { + const confirmed = await showConfirm({ + title: "Download TTS Model?", + message: `Install "${model.name}"? Kokoro model files are cached on first generation.`, + confirmLabel: "Install", + }); + if (confirmed) { + await downloadByUrl(model.id || model.url, model.filename); + } + return; + } + if (model.backendType === "openvino-npu") { const confirmed = await showConfirm({ title: "Download OpenVINO Model?", @@ -734,7 +803,7 @@ function ModelManager({ setModelLoadProgress(null); setLoadingModelId(null); } - } else { + } else if (activeModelType === "speech") { setLoadingModelId(modelId); setModelLoadProgress({ progress: 30, @@ -763,6 +832,38 @@ function ModelManager({ setModelLoadProgress(null); setLoadingModelId(null); } + } else { + setLoadingModelId(modelId); + setModelLoadProgress({ + progress: 30, + phase: "Starting Kokoro ONNX TTS runtime...", + speed: "", + current: 0, + total: 0, + model: modelId, + backendMode: "", + backendBinary: "", + device: "", + }); + try { + await stopServer(); + await stopLlm(); + await stopSpeech(); + await startTts(modelId); + setActiveTtsModel(modelId); + setTtsRunning(true); + setActiveSpeechModel(null); + setSpeechRunning(false); + setActiveLlmModel(null); + setLlmRunning(false); + setActiveModel(null); + setServerRunning(false); + } catch (err) { + showAlert({ title: "TTS Model Load Failed", message: err.message || String(err), danger: true }); + } finally { + setModelLoadProgress(null); + setLoadingModelId(null); + } } }; @@ -966,7 +1067,7 @@ function ModelManager({ setIsUnloading(false); setUnloadProgress({ progress: 0, phase: "" }); } - } else { + } else if (activeModelType === "speech") { setIsUnloading(true); setUnloadProgress({ progress: 50, phase: "Stopping whisper.cpp speech runtime..." }); try { @@ -979,6 +1080,19 @@ function ModelManager({ setIsUnloading(false); setUnloadProgress({ progress: 0, phase: "" }); } + } else { + setIsUnloading(true); + setUnloadProgress({ progress: 50, phase: "Stopping Kokoro TTS runtime..." }); + try { + await stopTts(); + setActiveTtsModel(null); + setTtsRunning(false); + } catch (e) { + showAlert({ title: "Unload Failed", message: e.message || String(e), danger: true }); + } finally { + setIsUnloading(false); + setUnloadProgress({ progress: 0, phase: "" }); + } } }; @@ -1000,11 +1114,16 @@ function ModelManager({ if (activeLlmModel === filename) { await handleUnloadModel(); } - } else { + } else if (activeModelType === "speech") { await deleteSpeechModel(filename); if (activeSpeechModel === filename) { await handleUnloadModel(); } + } else { + await deleteTtsModel(filename); + if (activeTtsModel === filename) { + await handleUnloadModel(); + } } await fetchModels(); } catch (err) { @@ -1046,7 +1165,7 @@ function ModelManager({ status: progressData.status }); }, controller.signal); - } else { + } else if (activeModelType === "speech") { await importSpeechModel(sourcePath, (progressData) => { setImportProgress(Math.round(progressData.progress)); setImportInfo({ @@ -1056,6 +1175,16 @@ function ModelManager({ status: progressData.status }); }, controller.signal); + } else { + await importTtsModel(sourcePath, (progressData) => { + setImportProgress(Math.round(progressData.progress)); + setImportInfo({ + filename: progressData.filename, + speed: progressData.speed_mb_s.toFixed(1), + eta: Math.round(progressData.eta_secs), + status: progressData.status + }); + }, controller.signal); } await fetchModels(); @@ -1112,6 +1241,13 @@ function ModelManager({ > Speech Models (Whisper) +
@@ -1190,6 +1326,26 @@ function ModelManager({ )} + {activeModelType === "tts" && activeTtsModel && ( +
+
+
+

Active TTS Model: {activeTtsModel}

+

+ The local Kokoro ONNX runtime is ready. Text to Speech can generate WAV narration. +

+ {backendInfo.ttsBackendMode && ( +
+ + {backendInfo.ttsBackendMode} + +
+ )} +
+
+
+ )} + {/* Loading Progress Bar */} {modelLoadProgress && (
@@ -1241,7 +1397,7 @@ function ModelManager({

- Local {activeModelType === "image" ? "Image" : activeModelType === "text" ? "Text" : "Speech"} Models ({displayedLocalModels.length}) + Local {activeModelType === "image" ? "Image" : activeModelType === "text" ? "Text" : activeModelType === "speech" ? "Speech" : "TTS"} Models ({displayedLocalModels.length})

{isLoadingModels ? ( @@ -1255,13 +1411,15 @@ function ModelManager({ ? "No image models detected in app/models/. Download from the library below or import a file." : activeModelType === "text" ? "No text models detected in app/llm-models/. Download from the library below or import a file." - : "No speech models detected in app/speech-models/. Download from the library below or import a .bin file."} + : activeModelType === "speech" + ? "No speech models detected in app/speech-models/. Download from the library below or import a .bin file." + : "No TTS models detected in app/tts-models/. Install Kokoro from the library below or import a .json manifest."}

) : (
{displayedLocalModels.map((model) => { const filename = model.filename; - const isActive = activeModelType === "image" ? activeModel === filename : activeModelType === "text" ? activeLlmModel === filename : activeSpeechModel === filename; + const isActive = activeModelType === "image" ? activeModel === filename : activeModelType === "text" ? activeLlmModel === filename : activeModelType === "speech" ? activeSpeechModel === filename : activeTtsModel === filename; return (
@@ -1332,7 +1492,7 @@ function ModelManager({

- {activeModelType === "text" ? "Hugging Face Model Library" : activeModelType === "speech" ? "Speech Model Library" : "Model Library"} + {activeModelType === "text" ? "Hugging Face Model Library" : activeModelType === "speech" ? "Speech Model Library" : activeModelType === "tts" ? "TTS Model Library" : "Model Library"}

@@ -1541,14 +1701,14 @@ function ModelManager({ Choose weights file - Select {activeModelType === "image" ? "`.safetensors` or `.ckpt` weights." : activeModelType === "text" ? "`.gguf` weights." : "`whisper.cpp .bin` weights."} + Select {activeModelType === "image" ? "`.safetensors` or `.ckpt` weights." : activeModelType === "text" ? "`.gguf` weights." : activeModelType === "speech" ? "`whisper.cpp .bin` weights." : "`Kokoro .json` manifest."} @@ -1558,7 +1718,7 @@ function ModelManager({ Download Model from URL

- Download any {activeModelType === "image" ? "Safetensors" : activeModelType === "text" ? "GGUF" : "Whisper .bin"} model from Hugging Face directly to your models folder. + Download any {activeModelType === "image" ? "Safetensors" : activeModelType === "text" ? "GGUF" : activeModelType === "speech" ? "Whisper .bin" : "Kokoro manifest"} model from Hugging Face directly to your models folder.

{isUrlDownloading ? (
diff --git a/app/frontend/src/components/Settings.jsx b/app/frontend/src/components/Settings.jsx index 0597fac..cecbddf 100644 --- a/app/frontend/src/components/Settings.jsx +++ b/app/frontend/src/components/Settings.jsx @@ -207,6 +207,8 @@ function Settings({ setTextSettings, speechSettings, setSpeechSettings, + ttsSettings, + setTtsSettings, showAlert = async ({ message }) => window.alert(message), showConfirm = async ({ message }) => window.confirm(message), health, @@ -230,6 +232,7 @@ function Settings({ image: localStorage.getItem("settings_section_image") === "true", text: localStorage.getItem("settings_section_text") === "true", speech: localStorage.getItem("settings_section_speech") === "true", + tts: localStorage.getItem("settings_section_tts") === "true", }; }); @@ -297,6 +300,13 @@ function Settings({ })); }; + const updateTtsSetting = (key, value) => { + setTtsSettings((prev) => ({ + ...prev, + [key]: value + })); + }; + const buildTextStartOptions = (settings) => ({ threads: settings?.threads || specs?.cpu_cores_physical || 4, contextSize: settings?.contextSize ?? 0, @@ -1037,6 +1047,93 @@ function Settings({ }; // ─── Appearance Settings ─── + const renderTtsSettings = () => { + const TTS_VOICES = [ + { value: "af_heart", label: "Heart - Female, US English" }, + { value: "af_bella", label: "Bella - Female, US English" }, + { value: "af_nicole", label: "Nicole - Female, US English" }, + { value: "af_sarah", label: "Sarah - Female, US English" }, + { value: "am_michael", label: "Michael - Male, US English" }, + { value: "am_fenrir", label: "Fenrir - Male, US English" }, + { value: "bf_emma", label: "Emma - Female, UK English" }, + { value: "bm_george", label: "George - Male, UK English" }, + ]; + + return ( + <> + toggleSection("tts")} + /> + + {expandedSections.tts && ( +
+
+
+
+
+ + Voice Defaults +
+
+
+ + + + Default Kokoro voice for generated WAV files. + +
+
+
+
+ +
+
+
+ + Speech Timing +
+
+
+ Speed + {(ttsSettings?.speed || 1).toFixed(2)}x +
+ updateTtsSetting("speed", parseFloat(e.target.value))} + min="0.5" + max="2" + step="0.05" + /> + + 1.00x is natural speed. Lower values are slower, higher values are faster. + +
+
+
+
+
+ )} + + ); + }; + const renderAppearanceSettings = () => ( <> ); } diff --git a/app/frontend/src/components/Sidebar.jsx b/app/frontend/src/components/Sidebar.jsx index 5dbf93f..b3b60b3 100644 --- a/app/frontend/src/components/Sidebar.jsx +++ b/app/frontend/src/components/Sidebar.jsx @@ -1,5 +1,5 @@ import React, { memo } from "react"; -import { Image, FolderDown, MessageSquare, Mic, Settings, Shield, Terminal, ChevronDown, ChevronUp, Trash2 } from "lucide-react"; +import { Image, FolderDown, MessageSquare, Mic, Settings, Shield, Terminal, ChevronDown, ChevronUp, Trash2, Volume2 } from "lucide-react"; function formatSidebarDate(value) { const date = new Date(value); @@ -24,7 +24,13 @@ function Sidebar({ setSelectedSpeechTranscript, showSpeechHistory, setShowSpeechHistory, - onDeleteSpeechTranscription + onDeleteSpeechTranscription, + ttsOutputs = [], + selectedTtsOutput, + setSelectedTtsOutput, + showTtsHistory, + setShowTtsHistory, + onDeleteTtsOutput }) { return (
@@ -283,6 +289,127 @@ function Sidebar({ )}
+
+
setActiveTab("tts")} + style={{ display: "flex", justifyContent: "space-between", alignItems: "center", width: "100%", boxSizing: "border-box" }} + > +
+ + Text to Speech +
+ +
+ + {showTtsHistory && ( +
+ {ttsOutputs.length === 0 ? ( +
+ No saved audio +
+ ) : ( + ttsOutputs.map((item) => { + const itemId = item.filename || item.metadata || item.audioFile; + const isActive = selectedTtsOutput && (selectedTtsOutput.filename || selectedTtsOutput.metadata || selectedTtsOutput.audioFile) === itemId; + const title = item.displayName || item.text || item.audioFile || item.filename || "TTS Output"; + const date = formatSidebarDate(item.modifiedAt || item.createdAt); + return ( +
{ + setSelectedTtsOutput(item); + setActiveTab("tts"); + }} + style={{ + display: "flex", + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + padding: "7px 8px 7px 10px", + borderRadius: "var(--md-shape-corner-small)", + fontSize: "0.78rem", + cursor: "pointer", + background: isActive ? "var(--md-sys-color-secondary-container)" : "transparent", + color: isActive ? "var(--md-sys-color-on-secondary-container)" : "var(--md-sys-color-on-surface-variant)", + border: isActive ? "1px solid var(--md-sys-color-outline-variant)" : "1px solid transparent", + transition: "background 0.2s" + }} + className="sidebar-history-item" + title={title} + > +
+ + {title} + + {date && ( + + {date} + + )} +
+ +
+ ); + }) + )} +
+ )} +
+
setActiveTab("models")} diff --git a/app/frontend/src/components/TextToSpeech.jsx b/app/frontend/src/components/TextToSpeech.jsx new file mode 100644 index 0000000..a621eda --- /dev/null +++ b/app/frontend/src/components/TextToSpeech.jsx @@ -0,0 +1,317 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + LoaderCircle, + Play, + RefreshCw, + Save, + Square, + Volume2, +} from "lucide-react"; +import { + getTtsStatus, + listTtsModels, + speakTts, + startTts, + stopTts, +} from "../services/api"; + +const FALLBACK_VOICES = [ + { id: "af_heart", name: "Heart", language: "en-us", gender: "Female" }, + { id: "af_bella", name: "Bella", language: "en-us", gender: "Female" }, + { id: "am_michael", name: "Michael", language: "en-us", gender: "Male" }, + { id: "bf_emma", name: "Emma", language: "en-gb", gender: "Female" }, + { id: "bm_george", name: "George", language: "en-gb", gender: "Male" }, +]; + +function downloadUrl(url, filename) { + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); +} + +export default function TextToSpeech({ + showAlert, + selectedOutput, + onOutputsChanged, + ttsSettings, + setTtsSettings, +}) { + const [status, setStatus] = useState({ ready: false, running: false, runtimeInstalled: false, settings: {}, voices: [] }); + const [models, setModels] = useState([]); + const [selectedModel, setSelectedModel] = useState(""); + const [text, setText] = useState("Hello from Local AI Studio text to speech."); + const [isLoadingModel, setIsLoadingModel] = useState(false); + const [isGenerating, setIsGenerating] = useState(false); + const [output, setOutput] = useState(null); + const abortRef = useRef(null); + + const installedModels = useMemo(() => models.filter((model) => model.installed), [models]); + const voices = status.voices?.length ? status.voices : FALLBACK_VOICES; + const selectedVoice = ttsSettings?.voice || "af_heart"; + const speed = ttsSettings?.speed || 1; + + const updateTtsSetting = (key, value) => { + setTtsSettings((prev) => ({ ...prev, [key]: value })); + }; + + const refresh = useCallback(async () => { + const [nextStatus, nextModels] = await Promise.all([ + getTtsStatus(), + listTtsModels(), + ]); + setStatus(nextStatus); + setModels(nextModels); + setSelectedModel((current) => { + const installed = nextModels.filter((model) => model.installed); + if (current && installed.some((model) => model.filename === current || model.id === current)) return current; + const active = nextStatus.settings?.model; + if (active && installed.some((model) => model.filename === active)) return active; + const preferred = ttsSettings?.model; + if (preferred && installed.some((model) => model.filename === preferred)) return preferred; + return installed.find((model) => model.recommended)?.filename || installed[0]?.filename || ""; + }); + }, [ttsSettings?.model]); + + useEffect(() => { + refresh().catch((err) => showAlert?.({ title: "TTS Status Failed", message: err.message || String(err), danger: true })); + const interval = setInterval(() => { + refresh().catch(() => {}); + }, 2500); + return () => clearInterval(interval); + }, [refresh, showAlert]); + + useEffect(() => { + if (!selectedOutput) return; + setOutput(selectedOutput); + setText(selectedOutput.text || ""); + if (selectedOutput.voice) updateTtsSetting("voice", selectedOutput.voice); + if (selectedOutput.speed) updateTtsSetting("speed", selectedOutput.speed); + if (selectedOutput.model) setSelectedModel(selectedOutput.model); + }, [selectedOutput]); + + useEffect(() => { + return () => abortRef.current?.abort(); + }, []); + + const handleLoadModel = async () => { + if (!selectedModel) return; + setIsLoadingModel(true); + try { + await startTts(selectedModel, { voice: selectedVoice, speed }); + updateTtsSetting("model", selectedModel); + await refresh(); + } catch (err) { + showAlert?.({ title: "TTS Model Load Failed", message: err.message || String(err), danger: true }); + } finally { + setIsLoadingModel(false); + } + }; + + const handleStop = async () => { + try { + await stopTts(); + await refresh(); + } catch (err) { + showAlert?.({ title: "Stop TTS Failed", message: err.message || String(err), danger: true }); + } + }; + + const handleGenerate = async () => { + if (!text.trim() || !selectedModel) return; + setIsGenerating(true); + const controller = new AbortController(); + abortRef.current = controller; + try { + if (!status.ready || status.settings?.model !== selectedModel) { + setIsLoadingModel(true); + try { + await startTts(selectedModel, { voice: selectedVoice, speed }); + updateTtsSetting("model", selectedModel); + await refresh(); + } finally { + setIsLoadingModel(false); + } + } + const generated = await speakTts(text, { + model: selectedModel, + voice: selectedVoice, + speed, + signal: controller.signal, + }); + setOutput(generated); + await refresh(); + await onOutputsChanged?.(); + } catch (err) { + if (err.name !== "AbortError") { + showAlert?.({ title: "TTS Generation Failed", message: err.message || String(err), danger: true }); + } + } finally { + abortRef.current = null; + setIsGenerating(false); + } + }; + + const handleCancel = () => { + abortRef.current?.abort(); + setIsGenerating(false); + }; + + const isLoaded = status.ready && status.settings?.model === selectedModel; + + return ( +
+
+

Text to Speech

+

+ Generate local WAV narration with Kokoro ONNX. +

+
+ +
+
+
+

Runtime

+ + {status.runtimeInstalled ? (status.ready ? "Ready" : "Installed") : "Runtime missing"} + +
+ + {status.error &&
{status.error}
} + + + +
+ + +
+ +
+ + + {status.ready && ( + + )} +
+ + {installedModels.length === 0 && ( +
+ Download a Kokoro model from Model Manager, TTS Models first. +
+ )} +
+ +
+
+

Input

+ {text.trim().length} chars +
+