: implement modular model management system with support for LLM and Kokoro text-to-speech integration across platforms

This commit is contained in:
techjarves committed 2026-06-21 21:43:22 +05:30
1 parent d5d0c0ac19
commit bd89699994
18 files changed
+1693 -41

No files matched your search

+91 -2
View File
@@ -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() {
/>
</div>
<div style={{ display: activeTab === "tts" ? "flex" : "none", flex: 1, flexDirection: "column", overflow: "hidden" }}>
<TextToSpeech
showAlert={showAlert}
showConfirm={showConfirm}
selectedOutput={selectedTtsOutput}
onOutputsChanged={refreshTtsOutputs}
ttsSettings={ttsSettings}
setTtsSettings={setTtsSettings}
/>
</div>
<div style={{ display: activeTab === "settings" ? "flex" : "none", flex: 1, flexDirection: "column", overflow: "hidden" }}>
<Settings
constraints={constraints}
@@ -689,6 +776,8 @@ function App() {
setTextSettings={setTextSettings}
speechSettings={speechSettings}
setSpeechSettings={setSpeechSettings}
ttsSettings={ttsSettings}
setTtsSettings={setTtsSettings}
health={health}
cleanupItems={cleanupItems}
isReadinessBusy={isReadinessBusy}
+183 -23
View File
@@ -28,7 +28,14 @@ import {
downloadSpeechModel,
deleteSpeechModel,
importSpeechModel,
getSpeechStatus
getSpeechStatus,
listTtsModels,
startTts,
stopTts,
downloadTtsModel,
deleteTtsModel,
importTtsModel,
getTtsStatus
} from "../services/api";
const MODEL_FILTERS = [
@@ -181,6 +188,38 @@ const OPENVINO_MODEL_LIBRARY = [
},
];
const TTS_MODEL_LIBRARY = [
{
group: "Kokoro Text-to-Speech Models",
items: [
{
id: "kokoro-onnx-q8",
name: "Kokoro 82M ONNX Q8",
filename: "kokoro-onnx-q8.json",
format: "Kokoro ONNX",
approxSize: "Model cache on first use",
resolution: "24 kHz WAV",
notes: "Recommended local TTS model. Good quality and faster startup than full precision on most PCs.",
url: "kokoro://install/kokoro-onnx-q8",
pageUrl: "https://huggingface.co/onnx-community/Kokoro-82M-v1.0-ONNX",
recommendedTiers: ["Low", "Mid", "High"],
},
{
id: "kokoro-onnx-fp32",
name: "Kokoro 82M ONNX FP32",
filename: "kokoro-onnx-fp32.json",
format: "Kokoro ONNX",
approxSize: "Model cache on first use",
resolution: "24 kHz WAV",
notes: "Full precision Kokoro ONNX path for quality testing. Uses more memory and disk cache.",
url: "kokoro://install/kokoro-onnx-fp32",
pageUrl: "https://huggingface.co/onnx-community/Kokoro-82M-v1.0-ONNX",
recommendedTiers: ["Mid", "High"],
},
],
},
];
const COREML_MODEL_LIBRARY = [
{
group: "Apple Silicon NPU - CoreML Test",
@@ -227,7 +266,7 @@ function ModelManager({
specs,
textSettings,
}) {
const [activeModelType, setActiveModelType] = useState("image"); // "image", "text", or "speech"
const [activeModelType, setActiveModelType] = useState("image"); // "image", "text", "speech", or "tts"
const [localModels, setLocalModels] = useState([]);
const [isLoadingModels, setIsLoadingModels] = useState(true);
const [downloadingModelId, setDownloadingModelId] = useState(null);
@@ -248,6 +287,8 @@ function ModelManager({
const [llmRunning, setLlmRunning] = useState(false);
const [activeSpeechModel, setActiveSpeechModel] = useState(null);
const [speechRunning, setSpeechRunning] = useState(false);
const [activeTtsModel, setActiveTtsModel] = useState(null);
const [ttsRunning, setTtsRunning] = useState(false);
const [modelSearch, setModelSearch] = useState("");
const [selectedFilters, setSelectedFilters] = useState([]);
const [huggingFaceModels, setHuggingFaceModels] = useState([]);
@@ -271,13 +312,13 @@ function ModelManager({
}
};
const normalizedLocalModels = activeModelType === "speech"
const normalizedLocalModels = activeModelType === "speech" || activeModelType === "tts"
? localModels.filter((model) => 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)
</button>
<button
className={`m3-btn ${activeModelType === "tts" ? "m3-btn-filled" : "m3-btn-outlined"}`}
onClick={() => setActiveModelType("tts")}
style={{ height: "40px", padding: "0 20px" }}
>
TTS Models (Kokoro)
</button>
</div>
@@ -1190,6 +1326,26 @@ function ModelManager({
</div>
)}
{activeModelType === "tts" && activeTtsModel && (
<div className="m3-card" style={{ borderLeft: "4px solid var(--md-sys-color-primary)", background: "var(--md-sys-color-primary-container)", color: "var(--md-sys-color-on-primary-container)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div>
<h4 style={{ fontWeight: 700 }}>Active TTS Model: {activeTtsModel}</h4>
<p style={{ fontSize: "0.85rem", marginTop: "2px", opacity: 0.9 }}>
The local Kokoro ONNX runtime is ready. Text to Speech can generate WAV narration.
</p>
{backendInfo.ttsBackendMode && (
<div style={{ display: "flex", gap: "8px", marginTop: "8px", flexWrap: "wrap" }}>
<span className="status-chip" style={{ cursor: "default", background: "rgba(255,255,255,0.15)", color: "inherit", border: "1px solid rgba(255,255,255,0.2)" }}>
<span>{backendInfo.ttsBackendMode}</span>
</span>
</div>
)}
</div>
</div>
</div>
)}
{/* Loading Progress Bar */}
{modelLoadProgress && (
<div className="m3-card" style={{ borderLeft: "4px solid var(--md-sys-color-primary)", marginTop: "24px" }}>
@@ -1241,7 +1397,7 @@ function ModelManager({
<div className="m3-card" style={{ marginTop: "24px" }}>
<h3 className="m3-card-title">
<Database size={18} style={{ color: "var(--md-sys-color-primary)" }} />
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})
</h3>
{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."}
</p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
{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 (
<div
@@ -1284,7 +1442,9 @@ function ModelManager({
? (model.backendType === "openvino-npu" ? "OpenVINO NPU Model" : model.format || "Local Weights File")
: activeModelType === "text"
? "llama.cpp GGUF Model"
: `${model.language || "Whisper"} Model`
: activeModelType === "speech"
? `${model.language || "Whisper"} Model`
: "Kokoro ONNX Model"
} • {model.size || formatBytes(model.sizeBytes)}
</span>
</div>
@@ -1332,7 +1492,7 @@ function ModelManager({
<div className="workspace-title-section" style={{ marginTop: "32px", marginBottom: "16px" }}>
<h3 className="m3-card-title">
<Library size={20} style={{ color: "var(--md-sys-color-primary)" }} />
{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"}
</h3>
</div>
@@ -1541,14 +1701,14 @@ function ModelManager({
<input
type="file"
style={{ display: "none" }}
accept={activeModelType === "image" ? ".safetensors,.ckpt" : activeModelType === "text" ? ".gguf" : ".bin"}
accept={activeModelType === "image" ? ".safetensors,.ckpt" : activeModelType === "text" ? ".gguf" : activeModelType === "speech" ? ".bin" : ".json"}
onChange={handleImportFile}
disabled={importProgress !== null}
/>
<FolderOpen className="import-icon" />
<span style={{ fontWeight: 600 }}>Choose weights file</span>
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", textAlign: "center" }}>
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."}
</span>
</label>
@@ -1558,7 +1718,7 @@ function ModelManager({
Download Model from URL
</h4>
<p style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", marginBottom: "12px" }}>
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.
</p>
{isUrlDownloading ? (
<div className="model-progress-section" style={{ marginTop: "0px" }}>
+100
View File
@@ -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 (
<>
<SectionHeader
icon={Volume2}
title="Text to Speech"
count={2}
color="#8b5cf6"
isExpanded={expandedSections.tts}
onToggle={() => toggleSection("tts")}
/>
{expandedSections.tts && (
<div className="settings-expanded-content">
<div className="settings-two-column">
<div className="settings-column">
<div className="settings-subsection">
<div className="settings-subsection-title">
<Volume2 size={16} />
Voice Defaults
</div>
<div className="m3-field-group">
<div className="m3-text-field">
<label className="m3-text-field-label">Default Voice</label>
<select
className="m3-input"
value={ttsSettings?.voice || "af_heart"}
onChange={(e) => updateTtsSetting("voice", e.target.value)}
>
{TTS_VOICES.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
<span className="settings-option-desc" style={{ marginTop: "4px", display: "block" }}>
Default Kokoro voice for generated WAV files.
</span>
</div>
</div>
</div>
</div>
<div className="settings-column">
<div className="settings-subsection">
<div className="settings-subsection-title">
<Gauge size={16} />
Speech Timing
</div>
<div className="m3-slider-group">
<div className="m3-slider-header">
<span className="m3-slider-label">Speed</span>
<span className="settings-value-badge">{(ttsSettings?.speed || 1).toFixed(2)}x</span>
</div>
<input
type="range"
className="m3-slider"
value={ttsSettings?.speed || 1}
onChange={(e) => updateTtsSetting("speed", parseFloat(e.target.value))}
min="0.5"
max="2"
step="0.05"
/>
<span className="settings-option-desc">
1.00x is natural speed. Lower values are slower, higher values are faster.
</span>
</div>
</div>
</div>
</div>
</div>
)}
</>
);
};
const renderAppearanceSettings = () => (
<>
<SectionHeader
@@ -1144,6 +1241,9 @@ function Settings({
{/* Speech Settings Section */}
{renderSpeechSettings()}
{/* Text to Speech Settings Section */}
{renderTtsSettings()}
</div>
);
}
+129 -2
View File
@@ -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 (
<div className="sidebar">
@@ -283,6 +289,127 @@ function Sidebar({
)}
</div>
<div className="nav-item-wrapper" style={{ display: "flex", flexDirection: "column" }}>
<div
className={`nav-item ${activeTab === "tts" ? "active" : ""}`}
onClick={() => setActiveTab("tts")}
style={{ display: "flex", justifyContent: "space-between", alignItems: "center", width: "100%", boxSizing: "border-box" }}
>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<Volume2 size={20} />
<span>Text to Speech</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
setShowTtsHistory(!showTtsHistory);
}}
style={{
background: "none",
border: "none",
color: "inherit",
cursor: "pointer",
padding: "4px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "50%",
transition: "background-color 0.2s"
}}
className="history-toggle-arrow"
title={showTtsHistory ? "Hide TTS Outputs" : "Show TTS Outputs"}
>
{showTtsHistory ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
</div>
{showTtsHistory && (
<div
className="sidebar-history-list"
style={{
paddingLeft: "14px",
marginTop: "6px",
marginBottom: "6px",
display: "flex",
flexDirection: "column",
gap: "4px",
maxHeight: "220px",
overflowY: "auto",
borderLeft: "2px solid var(--border-color)"
}}
>
{ttsOutputs.length === 0 ? (
<div style={{ padding: "8px 12px", fontSize: "0.78rem", color: "var(--md-sys-color-outline)", opacity: 0.8 }}>
No saved audio
</div>
) : (
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 (
<div
key={itemId}
onClick={() => {
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}
>
<div style={{ display: "flex", flexDirection: "column", gap: "2px", flex: 1, minWidth: 0 }}>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: isActive ? 600 : 400 }}>
{title}
</span>
{date && (
<span style={{ fontSize: "0.68rem", opacity: 0.72, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{date}
</span>
)}
</div>
<button
onClick={(e) => {
e.stopPropagation();
onDeleteTtsOutput?.(item, e);
}}
style={{
background: "none",
border: "none",
color: "var(--md-sys-color-outline)",
cursor: "pointer",
padding: "2px",
marginLeft: "6px",
display: "flex",
alignItems: "center"
}}
className="sidebar-history-delete"
title="Delete TTS Output"
>
<Trash2 size={12} />
</button>
</div>
);
})
)}
</div>
)}
</div>
<div
className={`nav-item ${activeTab === "models" ? "active" : ""}`}
onClick={() => setActiveTab("models")}
@@ -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 (
<div className="workspace-area speech-workspace">
<div className="workspace-title-section">
<h2 className="workspace-title">Text to Speech</h2>
<p className="workspace-subtitle">
Generate local WAV narration with Kokoro ONNX.
</p>
</div>
<div className="speech-grid">
<section className="m3-card speech-panel">
<div className="speech-panel-header">
<h3>Runtime</h3>
<span className={`status-chip ${status.runtimeInstalled ? "" : "offline"}`}>
{status.runtimeInstalled ? (status.ready ? "Ready" : "Installed") : "Runtime missing"}
</span>
</div>
{status.error && <div className="text-progress error">{status.error}</div>}
<label className="speech-label">
Kokoro model
<select className="m3-input" value={selectedModel} onChange={(event) => setSelectedModel(event.target.value)}>
{installedModels.length === 0 && <option value="">No downloaded TTS models</option>}
{installedModels.map((model) => (
<option key={model.id || model.filename} value={model.filename}>
{model.name || model.filename}
</option>
))}
</select>
</label>
<div className="speech-controls-row">
<label className="speech-label">
Voice
<select className="m3-input" value={selectedVoice} onChange={(event) => updateTtsSetting("voice", event.target.value)}>
{voices.map((voice) => (
<option key={voice.id} value={voice.id}>
{voice.name} ({voice.gender}, {voice.language})
</option>
))}
</select>
</label>
<label className="speech-label">
Speed
<input
className="m3-input"
type="number"
min="0.5"
max="2"
step="0.05"
value={speed}
onChange={(event) => updateTtsSetting("speed", Math.max(0.5, Math.min(2, Number(event.target.value) || 1)))}
/>
</label>
</div>
<div className="speech-button-row">
<button
className="m3-btn m3-btn-filled"
onClick={handleLoadModel}
disabled={!selectedModel || isLoadingModel || isGenerating || isLoaded}
>
{isLoadingModel ? <LoaderCircle className="progress-spinner" size={14} /> : <Play size={14} />}
<span>{isLoaded ? "Loaded" : isLoadingModel ? "Loading" : "Load"}</span>
</button>
<button className="m3-btn m3-btn-outlined" onClick={refresh}>
<RefreshCw size={14} />
<span>Refresh</span>
</button>
{status.ready && (
<button className="m3-btn m3-btn-error" onClick={handleStop}>
<Square size={14} />
<span>Stop Runtime</span>
</button>
)}
</div>
{installedModels.length === 0 && (
<div className="text-progress">
Download a Kokoro model from Model Manager, TTS Models first.
</div>
)}
</section>
<section className="m3-card speech-panel">
<div className="speech-panel-header">
<h3>Input</h3>
<span className="status-chip">{text.trim().length} chars</span>
</div>
<textarea
className="m3-input"
value={text}
onChange={(event) => setText(event.target.value)}
rows={10}
maxLength={5000}
style={{ width: "100%", resize: "vertical", minHeight: "220px", lineHeight: 1.45 }}
placeholder="Type text to turn into speech..."
/>
<div className="speech-button-row">
{isGenerating ? (
<button className="m3-btn m3-btn-error" onClick={handleCancel}>
<Square size={14} />
<span>Cancel</span>
</button>
) : (
<button
className="m3-btn m3-btn-filled"
onClick={handleGenerate}
disabled={!text.trim() || !selectedModel || !status.runtimeInstalled}
>
<Volume2 size={14} />
<span>Generate WAV</span>
</button>
)}
{isGenerating && <span className="text-progress"><LoaderCircle className="progress-spinner" size={14} /> Generating locally...</span>}
</div>
</section>
</div>
<section className="m3-card speech-result-panel">
<div className="speech-panel-header">
<h3>Output</h3>
<div className="speech-button-row">
<button className="m3-btn m3-btn-outlined" onClick={() => output?.url && downloadUrl(output.url, output.audioFile || "tts.wav")} disabled={!output?.url}>
<Save size={14} />
<span>WAV</span>
</button>
</div>
</div>
{output?.url ? (
<div style={{ display: "flex", flexDirection: "column", gap: "14px" }}>
<audio controls src={output.url} style={{ width: "100%" }} />
<div className="text-progress">
{output.voiceName || output.voice} &bull; {output.modelName || output.model} &bull; {new Date(output.createdAt).toLocaleString()}
</div>
<textarea
className="m3-input"
value={output.text || text}
onChange={(event) => setOutput((prev) => ({ ...(prev || {}), text: event.target.value }))}
rows={5}
style={{ width: "100%", resize: "vertical", minHeight: "120px", lineHeight: 1.45 }}
/>
</div>
) : (
<div className="empty-state" style={{ padding: "32px 0" }}>
<Volume2 size={42} />
<p>Generated speech will appear here.</p>
</div>
)}
</section>
</div>
);
}
+89
View File
@@ -663,6 +663,95 @@ export async function deleteSpeechTranscription(filename) {
return await readJsonResponse(res, "The local server returned an invalid speech transcription delete response.");
}
export async function getTtsStatus() {
try {
const res = await fetch("/api/tts/status");
return await readJsonResponse(res, "The local server returned invalid TTS runtime status.");
} catch (err) {
return { ready: false, running: false, runtimeInstalled: false, error: err.message, settings: {}, voices: [] };
}
}
export async function listTtsModels() {
const res = await fetch("/api/tts/models");
const data = await readJsonResponse(res, "The local server returned invalid TTS model data.");
return data.models || [];
}
export async function startTts(model, options = {}) {
const res = await fetch("/api/tts/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model,
voice: options.voice,
speed: options.speed,
}),
});
return await readJsonResponse(res, "The local server returned an invalid TTS start response.");
}
export async function stopTts() {
const res = await fetch("/api/tts/stop", { method: "POST" });
return await readJsonResponse(res, "The local server returned an invalid TTS stop response.");
}
export async function speakTts(text, options = {}) {
const res = await fetch("/api/tts/speak", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text,
model: options.model,
voice: options.voice,
speed: options.speed,
}),
signal: options.signal,
});
const data = await readJsonResponse(res, "The local server returned an invalid TTS response.");
return data.output;
}
export async function downloadTtsModel(modelIdOrUrl, filename = null) {
const res = await fetch("/api/tts/download-model", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: modelIdOrUrl,
filename,
}),
});
return await readJsonResponse(res, "The local server returned an invalid TTS download response.");
}
export async function importTtsModel(file, onProgress, signal) {
return await uploadModelFileToEndpoint(file, "/api/tts/import-model", onProgress, signal);
}
export async function deleteTtsModel(filename) {
const res = await fetch("/api/tts/delete-model", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename }),
});
return await readJsonResponse(res, "The local server returned an invalid TTS model delete response.");
}
export async function listTtsOutputs() {
const res = await fetch("/api/tts/outputs");
const data = await readJsonResponse(res, "The local server returned invalid TTS output history.");
return data.outputs || [];
}
export async function deleteTtsOutput(filename) {
const res = await fetch("/api/tts/delete-output", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename }),
});
return await readJsonResponse(res, "The local server returned an invalid TTS output delete response.");
}
export async function getLlmRecommendations(useCase = "chat", limit = 10) {
try {
const res = await fetch(`/api/llm/recommend?useCase=${encodeURIComponent(useCase)}&limit=${limit}`);