: implement modular model management system with support for LLM and Kokoro text-to-speech integration across platforms
This commit is contained in:
1 parent
d5d0c0ac19
commit
bd89699994
18 files changed
+1693
-41
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} • {output.modelName || output.model} • {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>
|
||||
);
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
Reference in new issue
Block a user