: 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
@@ -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/
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -134,6 +134,7 @@ LLM_SYCL_PATH="$APP_DIR/llm-backend/linux/sycl/llama-server"
|
||||
LLM_VULKAN_PATH="$APP_DIR/llm-backend/linux/vulkan/llama-server"
|
||||
LLM_CPU_PATH="$APP_DIR/llm-backend/linux/cpu/llama-server"
|
||||
SPEECH_BACKEND_PATH="$APP_DIR/speech-backend/linux/whisper-cli"
|
||||
TTS_RUNTIME_PATH="$APP_DIR/tts-runtime/node_modules/kokoro-js"
|
||||
if [[ ! -x "$CPU_BACKEND_PATH" || ! -x "$CPU_SERVER_PATH" ]] && [[ ! -x "$BACKEND_PATH" || ! -x "$VULKAN_SERVER_PATH" ]]; then
|
||||
SETUP_REASON="Linux backend binaries are missing or not executable."
|
||||
fi
|
||||
@@ -143,6 +144,9 @@ fi
|
||||
if [[ ! -x "$SPEECH_BACKEND_PATH" ]]; then
|
||||
SETUP_REASON="Linux whisper.cpp speech backend is missing or not executable."
|
||||
fi
|
||||
if [[ ! -d "$TTS_RUNTIME_PATH" ]]; then
|
||||
SETUP_REASON="Kokoro text-to-speech runtime is missing."
|
||||
fi
|
||||
|
||||
if [[ -n "$SETUP_REASON" ]]; then
|
||||
echo ""
|
||||
@@ -221,6 +225,7 @@ echo " Web UI: http://localhost:${FRONTEND_PORT}"
|
||||
echo " GPU API: Auto-selected by the app (starts at 8080)"
|
||||
echo " Text API: Starts when a GGUF model is loaded (port ${LLM_PORT})"
|
||||
echo " Speech: Managed locally by the app"
|
||||
echo " TTS: Managed locally by the app"
|
||||
echo ""
|
||||
echo " Press Ctrl+C in this window to stop all services."
|
||||
echo " ============================================================"
|
||||
|
||||
@@ -23,6 +23,7 @@ if [[ "$(uname -m)" == "arm64" ]]; then
|
||||
else
|
||||
LLM_BACKEND_PATH="$APP_DIR/llm-backend/mac/x64/llama-server"
|
||||
fi
|
||||
TTS_RUNTIME_PATH="$APP_DIR/tts-runtime/node_modules/kokoro-js"
|
||||
PLATFORM_LABEL="macOS"
|
||||
|
||||
DIST_INDEX="$APP_DIR/dist/index.html"
|
||||
@@ -111,6 +112,9 @@ fi
|
||||
if [[ ! -x "$LLM_BACKEND_PATH" ]]; then
|
||||
SETUP_REASON="No macOS llama.cpp text backend is installed."
|
||||
fi
|
||||
if [[ ! -d "$TTS_RUNTIME_PATH" ]]; then
|
||||
SETUP_REASON="Kokoro text-to-speech runtime is missing."
|
||||
fi
|
||||
|
||||
if [[ -n "$SETUP_REASON" ]]; then
|
||||
echo ""
|
||||
@@ -188,7 +192,8 @@ echo " Running!"
|
||||
echo " Web UI: http://localhost:${FRONTEND_PORT}"
|
||||
echo " GPU API: Auto-selected by the app (starts at 8080)"
|
||||
echo " Text API: Starts when a GGUF model is loaded (port ${LLM_PORT})"
|
||||
echo " Speech: Available when a whisper.cpp binary is present"
|
||||
echo " Speech: Managed locally by the app"
|
||||
echo " TTS: Managed locally by the app"
|
||||
echo ""
|
||||
echo " Press Ctrl+C in this window to stop all services."
|
||||
echo " ============================================================"
|
||||
|
||||
+24
-1
@@ -39,6 +39,13 @@ if (Test-Path $speechBackendDir) {
|
||||
Remove-Item $speechBackendDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Delete Kokoro TTS runtime dependencies
|
||||
$ttsRuntimeDir = Join-Path $appDir "tts-runtime"
|
||||
if (Test-Path $ttsRuntimeDir) {
|
||||
Write-Host " >> Removing Kokoro TTS runtime dependencies..." -ForegroundColor Cyan
|
||||
Remove-Item $ttsRuntimeDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Delete dist
|
||||
$distDir = Join-Path $appDir "dist"
|
||||
if (Test-Path $distDir) {
|
||||
@@ -64,12 +71,28 @@ if (Test-Path $speechModelsDir) {
|
||||
Write-Host " >> Preserving speech models in app/speech-models." -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
# Preserve TTS models
|
||||
$ttsModelsDir = Join-Path $appDir "tts-models"
|
||||
if (Test-Path $ttsModelsDir) {
|
||||
Write-Host " >> Preserving TTS models in app/tts-models." -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
# Preserve transcriptions
|
||||
$transcriptionsDir = Join-Path $appDir "transcriptions"
|
||||
if (Test-Path $transcriptionsDir) {
|
||||
Write-Host " >> Preserving speech transcripts in app/transcriptions." -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
# Preserve TTS outputs/cache
|
||||
$ttsOutputsDir = Join-Path $appDir "tts-outputs"
|
||||
if (Test-Path $ttsOutputsDir) {
|
||||
Write-Host " >> Preserving TTS outputs in app/tts-outputs." -ForegroundColor Cyan
|
||||
}
|
||||
$ttsCacheDir = Join-Path $appDir "tts-cache"
|
||||
if (Test-Path $ttsCacheDir) {
|
||||
Write-Host " >> Preserving TTS model cache in app/tts-cache." -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
# Preserve OpenVINO models
|
||||
$openVinoModelsDir = Join-Path $appDir "openvino-models"
|
||||
if (Test-Path $openVinoModelsDir) {
|
||||
@@ -101,7 +124,7 @@ if (Test-Path $lockFile) {
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " ============================================================" -ForegroundColor Green
|
||||
Write-Host " Reset complete. Image models, text models, speech models, transcripts, OpenVINO models, and generated outputs were preserved." -ForegroundColor Green
|
||||
Write-Host " Reset complete. Image models, text models, speech models, TTS models, transcripts, TTS outputs, OpenVINO models, and generated outputs were preserved." -ForegroundColor Green
|
||||
Write-Host " ============================================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Read-Host " Press Enter to close..."
|
||||
+20
-1
@@ -40,6 +40,12 @@ if [[ -d "$APP_DIR/speech-backend" ]]; then
|
||||
rm -rf "$APP_DIR/speech-backend"
|
||||
fi
|
||||
|
||||
# Delete Kokoro TTS runtime dependencies
|
||||
if [[ -d "$APP_DIR/tts-runtime" ]]; then
|
||||
echo " >> Removing Kokoro TTS runtime dependencies..."
|
||||
rm -rf "$APP_DIR/tts-runtime"
|
||||
fi
|
||||
|
||||
# Delete dist
|
||||
if [[ -d "$APP_DIR/dist" ]]; then
|
||||
echo " >> Removing dist/ build folder..."
|
||||
@@ -61,11 +67,24 @@ if [[ -d "$APP_DIR/speech-models" ]]; then
|
||||
echo " >> Preserving speech models in app/speech-models."
|
||||
fi
|
||||
|
||||
# Preserve TTS models
|
||||
if [[ -d "$APP_DIR/tts-models" ]]; then
|
||||
echo " >> Preserving TTS models in app/tts-models."
|
||||
fi
|
||||
|
||||
# Preserve transcriptions
|
||||
if [[ -d "$APP_DIR/transcriptions" ]]; then
|
||||
echo " >> Preserving speech transcripts in app/transcriptions."
|
||||
fi
|
||||
|
||||
# Preserve TTS outputs/cache
|
||||
if [[ -d "$APP_DIR/tts-outputs" ]]; then
|
||||
echo " >> Preserving TTS outputs in app/tts-outputs."
|
||||
fi
|
||||
if [[ -d "$APP_DIR/tts-cache" ]]; then
|
||||
echo " >> Preserving TTS model cache in app/tts-cache."
|
||||
fi
|
||||
|
||||
# Preserve OpenVINO models
|
||||
if [[ -d "$APP_DIR/openvino-models" ]]; then
|
||||
echo " >> Preserving OpenVINO models in app/openvino-models."
|
||||
@@ -92,7 +111,7 @@ fi
|
||||
|
||||
echo ""
|
||||
echo " ============================================================"
|
||||
echo " Reset complete. Image models, text models, speech models, transcripts, OpenVINO models, and generated outputs were preserved."
|
||||
echo " Reset complete. Image models, text models, speech models, TTS models, transcripts, TTS outputs, OpenVINO models, and generated outputs were preserved."
|
||||
echo " ============================================================"
|
||||
echo ""
|
||||
read -rp " Press Enter to close..."
|
||||
+537
-5
@@ -31,9 +31,11 @@ const PORT_FRONTEND = readPort(process.env.PORT || process.env.FRONTEND_PORT, 14
|
||||
const PREFERRED_BACKEND_PORT = readPort(process.env.BACKEND_PORT || process.env.SD_BACKEND_PORT, 8080);
|
||||
const PREFERRED_LLM_PORT = readPort(process.env.LLM_PORT, 10086);
|
||||
const PREFERRED_SPEECH_PORT = readPort(process.env.SPEECH_PORT, 10088);
|
||||
const PREFERRED_TTS_PORT = readPort(process.env.TTS_PORT, 10089);
|
||||
let PORT_BACKEND = PREFERRED_BACKEND_PORT;
|
||||
let PORT_LLM = PREFERRED_LLM_PORT;
|
||||
let PORT_SPEECH = PREFERRED_SPEECH_PORT;
|
||||
let PORT_TTS = PREFERRED_TTS_PORT;
|
||||
const MAX_JSON_BODY_BYTES = 64 * 1024 * 1024;
|
||||
const SERVER_BUILD = "text-image-v1";
|
||||
const ROOT = path.join(__dirname, "..");
|
||||
@@ -115,6 +117,20 @@ const TRANSCRIPTIONS = path.join(ROOT, "app", "transcriptions");
|
||||
if (!fs.existsSync(TRANSCRIPTIONS)) {
|
||||
fs.mkdirSync(TRANSCRIPTIONS, { recursive: true });
|
||||
}
|
||||
const TTS_MODELS = path.join(ROOT, "app", "tts-models");
|
||||
if (!fs.existsSync(TTS_MODELS)) {
|
||||
fs.mkdirSync(TTS_MODELS, { recursive: true });
|
||||
}
|
||||
const TTS_OUTPUTS = path.join(ROOT, "app", "tts-outputs");
|
||||
if (!fs.existsSync(TTS_OUTPUTS)) {
|
||||
fs.mkdirSync(TTS_OUTPUTS, { recursive: true });
|
||||
}
|
||||
const TTS_CACHE = path.join(ROOT, "app", "tts-cache");
|
||||
if (!fs.existsSync(TTS_CACHE)) {
|
||||
fs.mkdirSync(TTS_CACHE, { recursive: true });
|
||||
}
|
||||
const TTS_RUNTIME = path.join(ROOT, "app", "tts-runtime");
|
||||
const TTS_WORKER = path.join(ROOT, "scripts", "tts-kokoro-worker.mjs");
|
||||
const SPEECH_BACKEND_PATHS = {
|
||||
winCli: path.join(ROOT, "app", "speech-backend", "win", "whisper-cli.exe"),
|
||||
winServer: path.join(ROOT, "app", "speech-backend", "win", "whisper-server.exe"),
|
||||
@@ -135,6 +151,38 @@ const SPEECH_MODEL_CATALOG = [
|
||||
{ id: "base-q5_1", name: "Whisper Base Multilingual Q5", filename: "ggml-base-q5_1.bin", size: "57 MB", language: "Multilingual", recommended: false },
|
||||
{ id: "small", name: "Whisper Small Multilingual", filename: "ggml-small.bin", size: "466 MB", language: "Multilingual", recommended: false },
|
||||
];
|
||||
const TTS_MODEL_CATALOG = [
|
||||
{
|
||||
id: "kokoro-onnx-q8",
|
||||
name: "Kokoro 82M ONNX Q8",
|
||||
filename: "kokoro-onnx-q8.json",
|
||||
modelId: "onnx-community/Kokoro-82M-v1.0-ONNX",
|
||||
dtype: "q8",
|
||||
size: "Model cache managed by kokoro-js",
|
||||
format: "Kokoro ONNX",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: "kokoro-onnx-fp32",
|
||||
name: "Kokoro 82M ONNX FP32",
|
||||
filename: "kokoro-onnx-fp32.json",
|
||||
modelId: "onnx-community/Kokoro-82M-v1.0-ONNX",
|
||||
dtype: "fp32",
|
||||
size: "Model cache managed by kokoro-js",
|
||||
format: "Kokoro ONNX",
|
||||
recommended: false,
|
||||
},
|
||||
];
|
||||
const TTS_VOICES = [
|
||||
{ id: "af_heart", name: "Heart", language: "en-us", gender: "Female", recommended: true },
|
||||
{ id: "af_bella", name: "Bella", language: "en-us", gender: "Female", recommended: false },
|
||||
{ id: "af_nicole", name: "Nicole", language: "en-us", gender: "Female", recommended: false },
|
||||
{ id: "af_sarah", name: "Sarah", language: "en-us", gender: "Female", recommended: false },
|
||||
{ id: "am_michael", name: "Michael", language: "en-us", gender: "Male", recommended: false },
|
||||
{ id: "am_fenrir", name: "Fenrir", language: "en-us", gender: "Male", recommended: false },
|
||||
{ id: "bf_emma", name: "Emma", language: "en-gb", gender: "Female", recommended: false },
|
||||
{ id: "bm_george", name: "George", language: "en-gb", gender: "Male", recommended: false },
|
||||
];
|
||||
const OPENVINO_MODELS = path.join(ROOT, "app", "openvino-models");
|
||||
if (!fs.existsSync(OPENVINO_MODELS)) {
|
||||
fs.mkdirSync(OPENVINO_MODELS, { recursive: true });
|
||||
@@ -189,6 +237,24 @@ let speechTranscriptionState = {
|
||||
model: "",
|
||||
filename: "",
|
||||
};
|
||||
let ttsReady = false;
|
||||
let ttsError = null;
|
||||
let ttsOperationQueue = Promise.resolve();
|
||||
let ttsSettings = {
|
||||
model: null,
|
||||
voice: "af_heart",
|
||||
speed: 1,
|
||||
dtype: "q8",
|
||||
backendMode: "Kokoro ONNX",
|
||||
};
|
||||
let ttsGenerationState = {
|
||||
active: false,
|
||||
phase: "",
|
||||
progress: 0,
|
||||
model: "",
|
||||
voice: "",
|
||||
output: "",
|
||||
};
|
||||
let llmSettings = {
|
||||
model: null,
|
||||
threads: Math.max(1, Math.min(16, os.cpus().length || 4)),
|
||||
@@ -1674,6 +1740,300 @@ function resolveSpeechModel(value) {
|
||||
return { ...model, path: modelPath };
|
||||
}
|
||||
|
||||
function getTtsManifestPath(filename) {
|
||||
const manifestPath = path.join(TTS_MODELS, path.basename(String(filename || "")));
|
||||
return pathInside(manifestPath, TTS_MODELS) ? manifestPath : "";
|
||||
}
|
||||
|
||||
function readTtsManifest(filename) {
|
||||
const manifestPath = getTtsManifestPath(filename);
|
||||
if (!manifestPath || !fs.existsSync(manifestPath)) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function installTtsCatalogModel(modelIdOrFilename) {
|
||||
const raw = String(modelIdOrFilename || "").trim();
|
||||
const catalogModel = TTS_MODEL_CATALOG.find((model) => model.id === raw || model.filename === raw) || TTS_MODEL_CATALOG[0];
|
||||
if (!catalogModel) throw new Error("Unknown TTS model.");
|
||||
const manifestPath = getTtsManifestPath(catalogModel.filename);
|
||||
const manifest = {
|
||||
...catalogModel,
|
||||
installed: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
notes: "Kokoro model files are cached under app/tts-cache by kokoro-js on first generation.",
|
||||
};
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf8");
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function getTtsModels() {
|
||||
const catalog = TTS_MODEL_CATALOG.map((model) => {
|
||||
const manifest = readTtsManifest(model.filename);
|
||||
const installed = Boolean(manifest);
|
||||
const manifestPath = getTtsManifestPath(model.filename);
|
||||
const sizeBytes = installed ? getPathSize(manifestPath) : 0;
|
||||
return {
|
||||
...model,
|
||||
...(manifest || {}),
|
||||
installed,
|
||||
sizeBytes,
|
||||
localSize: installed ? formatBytes(sizeBytes) : "",
|
||||
url: `kokoro://install/${model.id}`,
|
||||
};
|
||||
});
|
||||
|
||||
const known = new Set(catalog.map((model) => model.filename.toLowerCase()));
|
||||
let custom = [];
|
||||
try {
|
||||
custom = fs.readdirSync(TTS_MODELS)
|
||||
.filter((filename) => filename.toLowerCase().endsWith(".json") && !known.has(filename.toLowerCase()))
|
||||
.map((filename) => {
|
||||
const manifest = readTtsManifest(filename) || {};
|
||||
const sizeBytes = getPathSize(path.join(TTS_MODELS, filename));
|
||||
return {
|
||||
id: manifest.id || filename,
|
||||
name: manifest.name || filename,
|
||||
filename,
|
||||
modelId: manifest.modelId || "onnx-community/Kokoro-82M-v1.0-ONNX",
|
||||
dtype: manifest.dtype || "q8",
|
||||
format: manifest.format || "Kokoro ONNX",
|
||||
size: formatBytes(sizeBytes),
|
||||
localSize: formatBytes(sizeBytes),
|
||||
sizeBytes,
|
||||
installed: true,
|
||||
recommended: false,
|
||||
custom: true,
|
||||
};
|
||||
});
|
||||
} catch (_) {}
|
||||
return [...catalog, ...custom];
|
||||
}
|
||||
|
||||
function resolveTtsModel(value) {
|
||||
const raw = String(value || "").trim();
|
||||
const model = getTtsModels().find((item) => item.id === raw || item.filename === raw) ||
|
||||
getTtsModels().find((item) => item.installed);
|
||||
if (!model) throw new Error("Download or import a Kokoro TTS model from Model Manager first.");
|
||||
const manifestPath = getTtsManifestPath(model.filename);
|
||||
if (!manifestPath || !fs.existsSync(manifestPath)) {
|
||||
throw new Error(`TTS model is not installed: ${model.filename}`);
|
||||
}
|
||||
return { ...model, path: manifestPath };
|
||||
}
|
||||
|
||||
function getTtsRuntimeStatus() {
|
||||
const nodeModules = path.join(TTS_RUNTIME, "node_modules");
|
||||
const installed = fs.existsSync(path.join(nodeModules, "kokoro-js"));
|
||||
return {
|
||||
installed: installed && fs.existsSync(TTS_WORKER),
|
||||
worker: TTS_WORKER,
|
||||
runtime: TTS_RUNTIME,
|
||||
cache: TTS_CACHE,
|
||||
backendMode: "Kokoro ONNX",
|
||||
};
|
||||
}
|
||||
|
||||
function runExclusiveTtsOperation(operation) {
|
||||
const run = ttsOperationQueue.catch(() => {}).then(operation);
|
||||
ttsOperationQueue = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
|
||||
async function startTts(settings = {}) {
|
||||
const runtime = getTtsRuntimeStatus();
|
||||
if (!runtime.installed) {
|
||||
throw new Error("Kokoro TTS runtime is not installed. Run scripts/setup-tts for this platform.");
|
||||
}
|
||||
const model = resolveTtsModel(settings.model || ttsSettings.model);
|
||||
await killBackend();
|
||||
await killOpenVinoWorker();
|
||||
await killLlm();
|
||||
await stopSpeech();
|
||||
PORT_TTS = PREFERRED_TTS_PORT;
|
||||
ttsError = null;
|
||||
ttsReady = true;
|
||||
ttsSettings = {
|
||||
...ttsSettings,
|
||||
model: model.filename,
|
||||
voice: settings.voice || ttsSettings.voice || "af_heart",
|
||||
speed: Math.max(0.5, Math.min(2, Number(settings.speed) || ttsSettings.speed || 1)),
|
||||
dtype: model.dtype || settings.dtype || ttsSettings.dtype || "q8",
|
||||
backendMode: runtime.backendMode,
|
||||
};
|
||||
}
|
||||
|
||||
async function stopTts() {
|
||||
ttsReady = false;
|
||||
ttsError = null;
|
||||
ttsGenerationState = { active: false, phase: "", progress: 0, model: "", voice: "", output: "" };
|
||||
}
|
||||
|
||||
function saveTtsOutput(result) {
|
||||
const createdAt = new Date().toISOString();
|
||||
const stamp = createdAt.replace(/[:.]/g, "-");
|
||||
const base = `tts-${stamp}-${safeOutputName(result.voice || "voice")}`;
|
||||
const wavFilename = `${base}.wav`;
|
||||
const jsonFilename = `${base}.json`;
|
||||
const wavPath = path.join(TTS_OUTPUTS, wavFilename);
|
||||
fs.copyFileSync(result.wavPath, wavPath);
|
||||
const metadata = {
|
||||
text: result.text || "",
|
||||
model: result.model,
|
||||
modelName: result.modelName,
|
||||
voice: result.voice,
|
||||
voiceName: result.voiceName,
|
||||
speed: result.speed,
|
||||
durationMs: result.durationMs,
|
||||
sampleRate: result.sampleRate || 24000,
|
||||
createdAt,
|
||||
audioFile: wavFilename,
|
||||
metadata: jsonFilename,
|
||||
displayName: result.displayName || `${result.voiceName || result.voice} - ${createdAt}`,
|
||||
};
|
||||
fs.writeFileSync(path.join(TTS_OUTPUTS, jsonFilename), JSON.stringify(metadata, null, 2), "utf8");
|
||||
return {
|
||||
...metadata,
|
||||
filename: jsonFilename,
|
||||
url: `/tts-outputs/${encodeURIComponent(wavFilename)}`,
|
||||
};
|
||||
}
|
||||
|
||||
function listTtsOutputs() {
|
||||
try {
|
||||
return fs.readdirSync(TTS_OUTPUTS)
|
||||
.filter((file) => file.toLowerCase().endsWith(".json"))
|
||||
.map((file) => {
|
||||
const filePath = path.join(TTS_OUTPUTS, file);
|
||||
try {
|
||||
const metadata = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
const stat = fs.statSync(filePath);
|
||||
return {
|
||||
...metadata,
|
||||
filename: file,
|
||||
displayName: metadata.displayName || metadata.text?.slice(0, 48) || metadata.audioFile || file,
|
||||
sizeBytes: stat.size,
|
||||
size: formatBytes(stat.size),
|
||||
modifiedAt: stat.mtime.toISOString(),
|
||||
createdAt: metadata.createdAt || stat.mtime.toISOString(),
|
||||
url: metadata.audioFile ? `/tts-outputs/${encodeURIComponent(metadata.audioFile)}` : "",
|
||||
};
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || "")));
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function synthesizeTts(text, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cleanedText = String(text || "").trim();
|
||||
if (!cleanedText) {
|
||||
reject(new Error("Enter text to synthesize."));
|
||||
return;
|
||||
}
|
||||
if (cleanedText.length > 5000) {
|
||||
reject(new Error("TTS text is too long. Limit is 5000 characters for V1."));
|
||||
return;
|
||||
}
|
||||
const runtime = getTtsRuntimeStatus();
|
||||
if (!runtime.installed) {
|
||||
reject(new Error("Kokoro TTS runtime is not installed."));
|
||||
return;
|
||||
}
|
||||
const model = resolveTtsModel(options.model || ttsSettings.model);
|
||||
const voice = String(options.voice || ttsSettings.voice || "af_heart");
|
||||
const voiceInfo = TTS_VOICES.find((item) => item.id === voice) || { id: voice, name: voice };
|
||||
const speed = Math.max(0.5, Math.min(2, Number(options.speed) || ttsSettings.speed || 1));
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const tempWav = path.join(TTS_OUTPUTS, `.tts-${stamp}.wav`);
|
||||
const payload = {
|
||||
text: cleanedText,
|
||||
modelId: model.modelId || "onnx-community/Kokoro-82M-v1.0-ONNX",
|
||||
dtype: model.dtype || "q8",
|
||||
voice,
|
||||
speed,
|
||||
output: tempWav,
|
||||
cacheDir: TTS_CACHE,
|
||||
};
|
||||
|
||||
ttsGenerationState = {
|
||||
active: true,
|
||||
phase: "Generating speech...",
|
||||
progress: -1,
|
||||
model: model.filename,
|
||||
voice,
|
||||
output: "",
|
||||
};
|
||||
|
||||
const startedAt = Date.now();
|
||||
const proc = spawn(process.execPath, [TTS_WORKER], {
|
||||
cwd: ROOT,
|
||||
stdio: "pipe",
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_PATH: path.join(TTS_RUNTIME, "node_modules"),
|
||||
TTS_RUNTIME,
|
||||
},
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
proc.stdout.on("data", (data) => { stdout += data.toString(); });
|
||||
proc.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
process.stderr.write(" [tts] " + data.toString());
|
||||
});
|
||||
proc.on("error", (err) => {
|
||||
ttsGenerationState = { active: false, phase: "Failed", progress: 0, model: model.filename, voice, output: "" };
|
||||
reject(err);
|
||||
});
|
||||
proc.on("exit", (code) => {
|
||||
ttsGenerationState = { active: false, phase: code === 0 ? "Complete" : "Failed", progress: code === 0 ? 100 : 0, model: model.filename, voice, output: "" };
|
||||
if (code !== 0) {
|
||||
try { fs.unlinkSync(tempWav); } catch (_) {}
|
||||
const message = (stderr || stdout || `Kokoro TTS exited with code ${code}`).trim().slice(-1400);
|
||||
ttsError = message;
|
||||
reject(new Error(message));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const workerResult = JSON.parse(stdout.trim() || "{}");
|
||||
if (!workerResult.ok || !fs.existsSync(tempWav)) {
|
||||
throw new Error(workerResult.error || "Kokoro TTS did not produce a WAV file.");
|
||||
}
|
||||
const saved = saveTtsOutput({
|
||||
text: cleanedText,
|
||||
model: model.filename,
|
||||
modelName: model.name,
|
||||
voice,
|
||||
voiceName: voiceInfo.name,
|
||||
speed,
|
||||
durationMs: Date.now() - startedAt,
|
||||
sampleRate: workerResult.sampleRate || 24000,
|
||||
wavPath: tempWav,
|
||||
displayName: `${voiceInfo.name || voice} - ${cleanedText.slice(0, 40)}`,
|
||||
});
|
||||
try { fs.unlinkSync(tempWav); } catch (_) {}
|
||||
ttsError = null;
|
||||
resolve(saved);
|
||||
} catch (err) {
|
||||
try { fs.unlinkSync(tempWav); } catch (_) {}
|
||||
ttsError = err.message || String(err);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
proc.stdin.end(JSON.stringify(payload));
|
||||
});
|
||||
}
|
||||
|
||||
function runExclusiveSpeechOperation(operation) {
|
||||
const run = speechOperationQueue.catch(() => {}).then(operation);
|
||||
speechOperationQueue = run.catch(() => {});
|
||||
@@ -2091,13 +2451,25 @@ async function getHealth() {
|
||||
totalBytes: getPathSize(OUTPUTS),
|
||||
totalSize: formatBytes(getPathSize(OUTPUTS)),
|
||||
},
|
||||
tts: {
|
||||
models: fs.existsSync(TTS_MODELS) ? fs.readdirSync(TTS_MODELS).filter((file) => file.toLowerCase().endsWith(".json")).length : 0,
|
||||
outputs: fs.existsSync(TTS_OUTPUTS) ? fs.readdirSync(TTS_OUTPUTS).filter((file) => file.toLowerCase().endsWith(".json")).length : 0,
|
||||
runtimeInstalled: getTtsRuntimeStatus().installed,
|
||||
totalBytes: getPathSize(TTS_MODELS) + getPathSize(TTS_OUTPUTS) + getPathSize(TTS_CACHE),
|
||||
totalSize: formatBytes(getPathSize(TTS_MODELS) + getPathSize(TTS_OUTPUTS) + getPathSize(TTS_CACHE)),
|
||||
},
|
||||
issues,
|
||||
};
|
||||
}
|
||||
|
||||
function addCleanupCandidate(candidates, id, targetPath, reason, options = {}) {
|
||||
if (!fs.existsSync(targetPath)) return;
|
||||
if (!options.allowUserData && (pathInside(targetPath, MODELS) || pathInside(targetPath, OUTPUTS))) return;
|
||||
if (!options.allowUserData && (
|
||||
pathInside(targetPath, MODELS) ||
|
||||
pathInside(targetPath, OUTPUTS) ||
|
||||
pathInside(targetPath, TTS_MODELS) ||
|
||||
pathInside(targetPath, TTS_OUTPUTS)
|
||||
)) return;
|
||||
const sizeBytes = getPathSize(targetPath);
|
||||
candidates.push({
|
||||
id,
|
||||
@@ -4072,7 +4444,13 @@ function cancelModelDownload() {
|
||||
try { fs.unlinkSync(activeDownload.tempPath || activeDownload.destPath); } catch (_) {}
|
||||
}
|
||||
} else if (filename) {
|
||||
const targetDir = downloadState.kind === "text" ? LLM_MODELS : downloadState.kind === "speech" ? SPEECH_MODELS : MODELS;
|
||||
const targetDir = downloadState.kind === "text"
|
||||
? LLM_MODELS
|
||||
: downloadState.kind === "speech"
|
||||
? SPEECH_MODELS
|
||||
: downloadState.kind === "tts"
|
||||
? TTS_MODELS
|
||||
: MODELS;
|
||||
try { fs.unlinkSync(`${path.join(targetDir, filename)}.part`); } catch (_) {}
|
||||
}
|
||||
activeDownload = null;
|
||||
@@ -4397,9 +4775,17 @@ function streamModelUpload(req, filename, targetDir = MODELS, mode = "image") {
|
||||
? lowerName.endsWith(".gguf")
|
||||
: mode === "speech"
|
||||
? lowerName.endsWith(".bin")
|
||||
: mode === "tts"
|
||||
? lowerName.endsWith(".json")
|
||||
: isModelFile(lowerName);
|
||||
if (!safeFilename || !valid) {
|
||||
reject(new Error(mode === "text" ? "Filename must end with .gguf" : mode === "speech" ? "Filename must end with .bin" : "Filename must end with .gguf, .safetensors, or .ckpt"));
|
||||
reject(new Error(mode === "text"
|
||||
? "Filename must end with .gguf"
|
||||
: mode === "speech"
|
||||
? "Filename must end with .bin"
|
||||
: mode === "tts"
|
||||
? "Filename must end with .json"
|
||||
: "Filename must end with .gguf, .safetensors, or .ckpt"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4771,6 +5157,136 @@ const server = http.createServer(async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (req.url === "/api/tts/status" && req.method === "GET") {
|
||||
const runtime = getTtsRuntimeStatus();
|
||||
return json(res, 200, {
|
||||
ok: true,
|
||||
ready: ttsReady,
|
||||
running: ttsReady,
|
||||
port: PORT_TTS,
|
||||
preferredPort: PREFERRED_TTS_PORT,
|
||||
runtimeInstalled: runtime.installed,
|
||||
runtimePath: runtime.runtime,
|
||||
workerPath: runtime.worker,
|
||||
cachePath: runtime.cache,
|
||||
backendMode: runtime.backendMode,
|
||||
error: ttsError,
|
||||
settings: ttsSettings,
|
||||
generation: ttsGenerationState,
|
||||
voices: TTS_VOICES,
|
||||
});
|
||||
}
|
||||
|
||||
if (req.url === "/api/tts/models" && req.method === "GET") {
|
||||
return json(res, 200, { ok: true, models: getTtsModels(), voices: TTS_VOICES });
|
||||
}
|
||||
|
||||
if (req.url === "/api/tts/start" && req.method === "POST") {
|
||||
const body = await readJsonBody(req, res);
|
||||
if (!body) return;
|
||||
try {
|
||||
await runExclusiveTtsOperation(() => startTts(body));
|
||||
return json(res, 200, { ok: true, ready: ttsReady, settings: ttsSettings, port: PORT_TTS });
|
||||
} catch (err) {
|
||||
ttsError = err.message || String(err);
|
||||
return json(res, 500, { ok: false, error: ttsError });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.url === "/api/tts/stop" && req.method === "POST") {
|
||||
await stopTts();
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
if (req.url === "/api/tts/speak" && req.method === "POST") {
|
||||
const body = await readJsonBody(req, res, 512 * 1024);
|
||||
if (!body) return;
|
||||
try {
|
||||
if (!ttsReady || (body.model && ttsSettings.model !== body.model)) {
|
||||
await runExclusiveTtsOperation(() => startTts(body));
|
||||
}
|
||||
const output = await runExclusiveTtsOperation(() => synthesizeTts(body.text, body));
|
||||
return json(res, 200, { ok: true, output });
|
||||
} catch (err) {
|
||||
ttsError = err.message || String(err);
|
||||
return json(res, 500, { ok: false, error: ttsError });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.url === "/api/tts/download-model" && req.method === "POST") {
|
||||
const body = await readJsonBody(req, res);
|
||||
if (!body) return;
|
||||
try {
|
||||
const model = installTtsCatalogModel(body.modelId || body.model_id || body.model || body.filename);
|
||||
return json(res, 200, { ok: true, message: "TTS model manifest installed", filename: model.filename, model });
|
||||
} catch (err) {
|
||||
return json(res, 500, { ok: false, error: err.message || String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.url.startsWith("/api/tts/import-model") && req.method === "POST") {
|
||||
try {
|
||||
const parsed = new URL(req.url, `http://${req.headers.host || "localhost"}`);
|
||||
const result = await streamModelUpload(req, parsed.searchParams.get("filename"), TTS_MODELS, "tts");
|
||||
return json(res, 200, { ok: true, message: `Imported ${result.filename}`, model: result });
|
||||
} catch (err) {
|
||||
console.error(" [api] Failed to import TTS model:", err);
|
||||
return json(res, 500, { ok: false, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.url === "/api/tts/delete-model" && req.method === "POST") {
|
||||
const body = await readJsonBody(req, res);
|
||||
if (!body) return;
|
||||
const filename = path.basename(String(body.filename || ""));
|
||||
const modelPath = path.join(TTS_MODELS, filename);
|
||||
if (!filename || !filename.endsWith(".json") || !pathInside(modelPath, TTS_MODELS)) {
|
||||
return json(res, 400, { ok: false, error: "Invalid filename" });
|
||||
}
|
||||
if (ttsSettings.model === filename) await stopTts();
|
||||
try {
|
||||
fs.unlinkSync(modelPath);
|
||||
return json(res, 200, { ok: true });
|
||||
} catch (err) {
|
||||
return json(res, err.code === "ENOENT" ? 404 : 500, { ok: false, error: err.code === "ENOENT" ? "TTS model not found" : err.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.url === "/api/tts/outputs" && req.method === "GET") {
|
||||
return json(res, 200, { ok: true, outputs: listTtsOutputs() });
|
||||
}
|
||||
|
||||
if (req.url === "/api/tts/delete-output" && req.method === "POST") {
|
||||
const body = await readJsonBody(req, res);
|
||||
if (!body) return;
|
||||
const filename = path.basename(String(body.filename || ""));
|
||||
if (!filename || !filename.endsWith(".json")) {
|
||||
return json(res, 400, { ok: false, error: "Invalid filename" });
|
||||
}
|
||||
const jsonPath = path.join(TTS_OUTPUTS, filename);
|
||||
if (!pathInside(jsonPath, TTS_OUTPUTS)) {
|
||||
return json(res, 400, { ok: false, error: "Invalid path" });
|
||||
}
|
||||
try {
|
||||
if (!fs.existsSync(jsonPath)) {
|
||||
return json(res, 404, { ok: false, error: "TTS output metadata not found" });
|
||||
}
|
||||
const metadata = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
|
||||
const audioFile = metadata.audioFile ? path.basename(String(metadata.audioFile)) : "";
|
||||
fs.unlinkSync(jsonPath);
|
||||
if (audioFile) {
|
||||
const audioPath = path.join(TTS_OUTPUTS, audioFile);
|
||||
if (pathInside(audioPath, TTS_OUTPUTS) && fs.existsSync(audioPath)) {
|
||||
fs.unlinkSync(audioPath);
|
||||
}
|
||||
}
|
||||
return json(res, 200, { ok: true });
|
||||
} catch (err) {
|
||||
console.error(" [api] Failed to delete TTS output:", err);
|
||||
return json(res, 500, { ok: false, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
function isOomError(msg) {
|
||||
if (!msg) return false;
|
||||
const lower = String(msg).toLowerCase();
|
||||
@@ -5307,6 +5823,21 @@ async function getLlmfitRecommendations(useCase = "chat", limit = 10) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url.startsWith("/tts-outputs/") && req.method === "GET") {
|
||||
const filename = path.basename(decodeURIComponent(req.url.replace(/^\/tts-outputs\//, "").split("?")[0] || ""));
|
||||
const filePath = path.join(TTS_OUTPUTS, filename);
|
||||
if (!filename || !pathInside(filePath, TTS_OUTPUTS) || !fs.existsSync(filePath)) {
|
||||
return json(res, 404, { ok: false, error: "TTS output not found" });
|
||||
}
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
fs.readFile(filePath, (err, data) => {
|
||||
if (err) return json(res, 500, { ok: false, error: err.message });
|
||||
res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream", "Access-Control-Allow-Origin": "*" });
|
||||
res.end(data);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// POST /api/save-output
|
||||
if (req.url === "/api/save-output" && req.method === "POST") {
|
||||
try {
|
||||
@@ -5436,6 +5967,7 @@ server.listen(PORT_FRONTEND, "0.0.0.0", () => {
|
||||
console.log(" Image API: http://127.0.0.1:" + PORT_BACKEND);
|
||||
console.log(" Text API : starts on http://127.0.0.1:" + PREFERRED_LLM_PORT);
|
||||
console.log(" Speech : managed locally, API on frontend port");
|
||||
console.log(" TTS : Kokoro ONNX managed locally, API on frontend port");
|
||||
console.log(" ============================================================");
|
||||
console.log("");
|
||||
|
||||
@@ -5444,5 +5976,5 @@ server.listen(PORT_FRONTEND, "0.0.0.0", () => {
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on("SIGINT", async () => { await killBackend(); await killOpenVinoWorker(); await killLlm(); await stopSpeech(); process.exit(0); });
|
||||
process.on("SIGTERM", async () => { await killBackend(); await killOpenVinoWorker(); await killLlm(); await stopSpeech(); process.exit(0); });
|
||||
process.on("SIGINT", async () => { await killBackend(); await killOpenVinoWorker(); await killLlm(); await stopSpeech(); await stopTts(); process.exit(0); });
|
||||
process.on("SIGTERM", async () => { await killBackend(); await killOpenVinoWorker(); await killLlm(); await stopSpeech(); await stopTts(); process.exit(0); });
|
||||
@@ -0,0 +1,52 @@
|
||||
# Local AI Studio - Kokoro TTS setup for Windows
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$rootDir = Split-Path -Parent $scriptDir
|
||||
$appDir = Join-Path $rootDir "app"
|
||||
$toolsDir = Join-Path $appDir "tools"
|
||||
$nodeDir = Join-Path $toolsDir "node-win"
|
||||
$nodeExe = Join-Path $nodeDir "node.exe"
|
||||
$npmCmd = Join-Path $nodeDir "npm.cmd"
|
||||
$runtimeDir = Join-Path $appDir "tts-runtime"
|
||||
$modelsDir = Join-Path $appDir "tts-models"
|
||||
$outputsDir = Join-Path $appDir "tts-outputs"
|
||||
$cacheDir = Join-Path $appDir "tts-cache"
|
||||
|
||||
function Print-OK { param([string]$m); Write-Host " OK $m" -ForegroundColor Green }
|
||||
function Print-Info { param([string]$m); Write-Host " >> $m" -ForegroundColor Cyan }
|
||||
function Print-Fail { param([string]$m); Write-Host " XX $m" -ForegroundColor Red }
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " ============================================================" -ForegroundColor Cyan
|
||||
Write-Host " Setting up Kokoro ONNX Text-to-Speech runtime" -ForegroundColor Cyan
|
||||
Write-Host " ============================================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $runtimeDir, $modelsDir, $outputsDir, $cacheDir | Out-Null
|
||||
|
||||
if (-not (Test-Path $nodeExe) -or -not (Test-Path $npmCmd)) {
|
||||
Print-Fail "Portable Node.js is missing. Run scripts/setup.ps1 first."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$packageJson = Join-Path $runtimeDir "package.json"
|
||||
if (-not (Test-Path $packageJson)) {
|
||||
'{"private":true,"type":"module","dependencies":{"kokoro-js":"^1.2.1"}}' | Out-File -FilePath $packageJson -Encoding utf8
|
||||
}
|
||||
|
||||
Push-Location $runtimeDir
|
||||
$oldPath = $env:PATH
|
||||
try {
|
||||
$env:PATH = "$nodeDir;$env:PATH"
|
||||
Print-Info "Installing kokoro-js into app/tts-runtime..."
|
||||
& $npmCmd install --prefer-offline 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Print-Fail "kokoro-js install failed."
|
||||
exit 1
|
||||
}
|
||||
} finally {
|
||||
$env:PATH = $oldPath
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Print-OK "Kokoro TTS runtime is ready."
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Local AI Studio - Kokoro TTS setup for Linux/macOS
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
APP_DIR="$ROOT_DIR/app"
|
||||
TOOLS_DIR="$APP_DIR/tools"
|
||||
PLATFORM="$(uname -s)"
|
||||
|
||||
if [[ "$PLATFORM" == "Darwin" ]]; then
|
||||
NODE_DIR="$TOOLS_DIR/node-mac"
|
||||
else
|
||||
NODE_DIR="$TOOLS_DIR/node-linux"
|
||||
fi
|
||||
|
||||
NODE_BIN="$NODE_DIR/bin/node"
|
||||
NPM_BIN="$NODE_DIR/bin/npm"
|
||||
RUNTIME_DIR="$APP_DIR/tts-runtime"
|
||||
MODELS_DIR="$APP_DIR/tts-models"
|
||||
OUTPUTS_DIR="$APP_DIR/tts-outputs"
|
||||
CACHE_DIR="$APP_DIR/tts-cache"
|
||||
|
||||
print_ok() { echo " OK $1"; }
|
||||
print_info() { echo " >> $1"; }
|
||||
print_fail() { echo " XX $1"; }
|
||||
|
||||
echo ""
|
||||
echo " ============================================================"
|
||||
echo " Setting up Kokoro ONNX Text-to-Speech runtime"
|
||||
echo " ============================================================"
|
||||
echo ""
|
||||
|
||||
mkdir -p "$RUNTIME_DIR" "$MODELS_DIR" "$OUTPUTS_DIR" "$CACHE_DIR"
|
||||
|
||||
if [[ ! -x "$NODE_BIN" || ! -x "$NPM_BIN" ]]; then
|
||||
print_fail "Portable Node.js is missing. Run scripts/setup.sh first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$RUNTIME_DIR/package.json" ]]; then
|
||||
cat > "$RUNTIME_DIR/package.json" <<'JSON'
|
||||
{"private":true,"type":"module","dependencies":{"kokoro-js":"^1.2.1"}}
|
||||
JSON
|
||||
fi
|
||||
|
||||
cd "$RUNTIME_DIR"
|
||||
export PATH="$NODE_DIR/bin:$PATH"
|
||||
print_info "Installing kokoro-js into app/tts-runtime..."
|
||||
"$NPM_BIN" install --prefer-offline
|
||||
|
||||
print_ok "Kokoro TTS runtime is ready."
|
||||
+10
-3
@@ -158,7 +158,7 @@ function Expand-WithProgress {
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
Print-Header
|
||||
|
||||
$steps = 6
|
||||
$steps = 7
|
||||
|
||||
# ── Step 1: Portable Node.js ──────────────────────────────────────────────────
|
||||
Print-Step 1 $steps "Setting up portable Node.js (app/tools/node-win/)"
|
||||
@@ -434,7 +434,14 @@ if (-not $?) {
|
||||
Read-Host; exit 1
|
||||
}
|
||||
|
||||
Print-Step 5 $steps "Installing frontend dependencies (app/frontend/)"
|
||||
Print-Step 5 $steps "Setting up Kokoro ONNX text-to-speech runtime"
|
||||
& (Join-Path $scriptDir "setup-tts.ps1")
|
||||
if (-not $?) {
|
||||
Print-Fail "Kokoro TTS setup failed."
|
||||
Read-Host; exit 1
|
||||
}
|
||||
|
||||
Print-Step 6 $steps "Installing frontend dependencies (app/frontend/)"
|
||||
Write-Host ""
|
||||
|
||||
if (-not (Test-Path $npmCmd)) {
|
||||
@@ -488,7 +495,7 @@ try {
|
||||
Print-OK "Dependencies installed!"
|
||||
|
||||
# ── Step 4: Build frontend ────────────────────────────────────────────────
|
||||
Print-Step 6 $steps "Building frontend -> app/dist/"
|
||||
Print-Step 7 $steps "Building frontend -> app/dist/"
|
||||
Write-Host ""
|
||||
|
||||
& $npmCmd run build 2>&1
|
||||
|
||||
+6
-3
@@ -258,7 +258,7 @@ if [[ "$PLATFORM" == "Linux" ]]; then
|
||||
check_glibc
|
||||
fi
|
||||
|
||||
TOTAL_STEPS=6
|
||||
TOTAL_STEPS=7
|
||||
|
||||
# ── Step 1: Portable Node.js ────────────────────────────────────────────────
|
||||
print_step 1 $TOTAL_STEPS "Setting up portable Node.js ($NODE_DIR/)"
|
||||
@@ -547,7 +547,10 @@ bash "$SCRIPT_DIR/setup-llama.sh"
|
||||
print_step 4 $TOTAL_STEPS "Setting up whisper.cpp speech backend"
|
||||
bash "$SCRIPT_DIR/setup-whisper.sh"
|
||||
|
||||
print_step 5 $TOTAL_STEPS "Installing frontend dependencies (app/frontend/)"
|
||||
print_step 5 $TOTAL_STEPS "Setting up Kokoro ONNX text-to-speech runtime"
|
||||
bash "$SCRIPT_DIR/setup-tts.sh"
|
||||
|
||||
print_step 6 $TOTAL_STEPS "Installing frontend dependencies (app/frontend/)"
|
||||
|
||||
if [[ ! -x "$NPM_BIN" ]]; then
|
||||
print_fail "Portable npm was not found at $NPM_BIN"
|
||||
@@ -645,7 +648,7 @@ else
|
||||
fi
|
||||
|
||||
# ── Step 4: Build frontend ──────────────────────────────────────────────────
|
||||
print_step 6 $TOTAL_STEPS "Building frontend -> app/dist/"
|
||||
print_step 7 $TOTAL_STEPS "Building frontend -> app/dist/"
|
||||
|
||||
if [ "$USE_SYMLINKS" = false ]; then
|
||||
# If symlinks are disabled, run vite directly using the local node executable.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createRequire } from "node:module";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
function readStdin() {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => { data += chunk; });
|
||||
process.stdin.on("end", () => resolve(data));
|
||||
process.stdin.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function resolveRuntimePackage(name) {
|
||||
const runtime = process.env.TTS_RUNTIME || "";
|
||||
if (runtime) {
|
||||
const pkg = path.join(runtime, "node_modules", name, "dist", "kokoro.js");
|
||||
return pathToFileUrl(pkg);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function pathToFileUrl(filePath) {
|
||||
const resolved = path.resolve(filePath).replace(/\\/g, "/");
|
||||
return `file:///${resolved.replace(/^\/+/, "")}`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const payload = JSON.parse(await readStdin());
|
||||
process.env.TRANSFORMERS_CACHE = payload.cacheDir || process.env.TRANSFORMERS_CACHE || "";
|
||||
process.env.HF_HOME = payload.cacheDir || process.env.HF_HOME || "";
|
||||
|
||||
const kokoro = await import(resolveRuntimePackage("kokoro-js"));
|
||||
const { KokoroTTS } = kokoro;
|
||||
|
||||
const tts = await KokoroTTS.from_pretrained(payload.modelId, {
|
||||
dtype: payload.dtype || "q8",
|
||||
device: "cpu",
|
||||
});
|
||||
const audio = await tts.generate(payload.text, {
|
||||
voice: payload.voice || "af_heart",
|
||||
speed: Number(payload.speed) || 1,
|
||||
});
|
||||
await fs.mkdir(path.dirname(payload.output), { recursive: true });
|
||||
audio.save(payload.output);
|
||||
process.stdout.write(JSON.stringify({
|
||||
ok: true,
|
||||
output: payload.output,
|
||||
sampleRate: audio.sampling_rate || audio.sampleRate || 24000,
|
||||
}));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
process.stderr.write(err?.stack || err?.message || String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -15,6 +15,7 @@ set LLM_VULKAN_BACKEND=%APP%\llm-backend\win\vulkan\llama-server.exe
|
||||
set LLM_SYCL_BACKEND=%APP%\llm-backend\win\sycl\llama-server.exe
|
||||
set LLM_CPU_BACKEND=%APP%\llm-backend\win\cpu\llama-server.exe
|
||||
set SPEECH_BACKEND=%APP%\speech-backend\win\whisper-cli.exe
|
||||
set TTS_RUNTIME=%APP%\tts-runtime\node_modules\kokoro-js
|
||||
set SERVE=%~dp0scripts\serve.cjs
|
||||
if "%FRONTEND_PORT%"=="" set FRONTEND_PORT=1420
|
||||
if "%LLM_PORT%"=="" set LLM_PORT=10086
|
||||
@@ -43,6 +44,10 @@ if not exist "%SPEECH_BACKEND%" (
|
||||
set SETUP_REASON=whisper.cpp speech backend is missing.
|
||||
goto :run_setup
|
||||
)
|
||||
if not exist "%TTS_RUNTIME%" (
|
||||
set SETUP_REASON=Kokoro text-to-speech runtime is missing.
|
||||
goto :run_setup
|
||||
)
|
||||
if exist "%CUDA_BACKEND%" goto :launch
|
||||
if exist "%VULKAN_BACKEND%" goto :launch
|
||||
set SETUP_REASON=No backend binary is installed.
|
||||
@@ -107,6 +112,7 @@ echo Web UI: http://localhost:%FRONTEND_PORT%
|
||||
echo GPU API: Auto-selected by the app (starts at 8080)
|
||||
echo Text API: Starts when a GGUF model is loaded (port %LLM_PORT%)
|
||||
echo Speech: Managed locally by the app
|
||||
echo TTS: Managed locally by the app
|
||||
echo.
|
||||
echo Press Ctrl+C in this window to stop all services.
|
||||
echo ============================================================
|
||||
|
||||
Reference in new issue
Block a user