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

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

No files matched your search

+4
View File
@@ -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/
+91 -2
View File
@@ -6,7 +6,8 @@ import ModelManager from "./components/ModelManager";
import Settings from "./components/Settings";
import TextChat from "./components/TextChat";
import SpeechTranscriber from "./components/SpeechTranscriber";
import { cleanupCandidates, formatBytes, getCleanupCandidates, getDiagnostics, getHardwareSpecs, getHealth, getTelemetry, getBackendOptions, getBackendStatus, listGeneratedOutputs, listSpeechTranscriptions, deleteSpeechTranscription, stopServer } from "./services/api";
import TextToSpeech from "./components/TextToSpeech";
import { cleanupCandidates, formatBytes, getCleanupCandidates, getDiagnostics, getHardwareSpecs, getHealth, getTelemetry, getBackendOptions, getBackendStatus, listGeneratedOutputs, listSpeechTranscriptions, deleteSpeechTranscription, listTtsOutputs, deleteTtsOutput, stopServer } from "./services/api";
import "./App.css";
function App() {
@@ -183,6 +184,29 @@ function App() {
localStorage.setItem("speechSettings", JSON.stringify(speechSettings));
}, [speechSettings]);
const [ttsSettings, setTtsSettings] = useState(() => {
const saved = localStorage.getItem("ttsSettings");
if (saved) {
try {
const parsed = JSON.parse(saved);
return {
model: parsed.model || "",
voice: parsed.voice || "af_heart",
speed: Math.max(0.5, Math.min(2, Number(parsed.speed) || 1)),
};
} catch (_) {}
}
return {
model: "",
voice: "af_heart",
speed: 1,
};
});
useEffect(() => {
localStorage.setItem("ttsSettings", JSON.stringify(ttsSettings));
}, [ttsSettings]);
// Lifted Chat History States
const [conversations, setConversations] = useState([]);
const [activeConversationId, setActiveConversationId] = useState(null);
@@ -190,6 +214,9 @@ function App() {
const [speechTranscriptions, setSpeechTranscriptions] = useState([]);
const [selectedSpeechTranscript, setSelectedSpeechTranscript] = useState(null);
const [showSpeechHistory, setShowSpeechHistory] = useState(false);
const [ttsOutputs, setTtsOutputs] = useState([]);
const [selectedTtsOutput, setSelectedTtsOutput] = useState(null);
const [showTtsHistory, setShowTtsHistory] = useState(false);
useEffect(() => {
const saved = localStorage.getItem("chat_conversations");
@@ -315,6 +342,49 @@ function App() {
refreshSpeechTranscriptions();
}, [refreshSpeechTranscriptions]);
const refreshTtsOutputs = useCallback(async () => {
try {
const list = await listTtsOutputs();
setTtsOutputs(list);
} catch (err) {
console.warn("Could not load TTS outputs:", err);
setTtsOutputs([]);
}
}, []);
const handleDeleteTtsOutput = useCallback(async (item, e) => {
if (e) e.stopPropagation();
const itemId = item.filename || item.metadata;
if (!itemId) return;
const ok = await showConfirm({
title: "Delete TTS Output",
message: `Are you sure you want to delete "${item.displayName || item.audioFile || "this audio"}"? This action cannot be undone.`,
confirmLabel: "Delete",
danger: true
});
if (!ok) return;
try {
await deleteTtsOutput(itemId);
setSelectedTtsOutput((prev) => {
if (prev) {
const prevId = prev.filename || prev.metadata;
if (prevId === itemId) return null;
}
return prev;
});
refreshTtsOutputs();
} catch (err) {
console.error("Failed to delete TTS output:", err);
showAlert({ title: "Delete Failed", message: err.message || String(err), danger: true });
}
}, [showConfirm, showAlert, refreshTtsOutputs]);
useEffect(() => {
refreshTtsOutputs();
}, [refreshTtsOutputs]);
// Load hardware specifications on mount
useEffect(() => {
async function loadSpecs() {
@@ -565,8 +635,14 @@ function App() {
showSpeechHistory={showSpeechHistory}
setShowSpeechHistory={setShowSpeechHistory}
onDeleteSpeechTranscription={handleDeleteSpeechTranscription}
ttsOutputs={ttsOutputs}
selectedTtsOutput={selectedTtsOutput}
setSelectedTtsOutput={setSelectedTtsOutput}
showTtsHistory={showTtsHistory}
setShowTtsHistory={setShowTtsHistory}
onDeleteTtsOutput={handleDeleteTtsOutput}
/>
), [activeTab, specs, conversations, activeConversationId, showHistory, handleDeleteConversation, speechTranscriptions, selectedSpeechTranscript, showSpeechHistory, handleDeleteSpeechTranscription]);
), [activeTab, specs, conversations, activeConversationId, showHistory, handleDeleteConversation, speechTranscriptions, selectedSpeechTranscript, showSpeechHistory, handleDeleteSpeechTranscription, ttsOutputs, selectedTtsOutput, showTtsHistory, handleDeleteTtsOutput]);
const handleStopServer = useCallback(async () => {
if (!serverRunning || isStoppingServer) return;
@@ -673,6 +749,17 @@ function App() {
/>
</div>
<div style={{ display: activeTab === "tts" ? "flex" : "none", flex: 1, flexDirection: "column", overflow: "hidden" }}>
<TextToSpeech
showAlert={showAlert}
showConfirm={showConfirm}
selectedOutput={selectedTtsOutput}
onOutputsChanged={refreshTtsOutputs}
ttsSettings={ttsSettings}
setTtsSettings={setTtsSettings}
/>
</div>
<div style={{ display: activeTab === "settings" ? "flex" : "none", flex: 1, flexDirection: "column", overflow: "hidden" }}>
<Settings
constraints={constraints}
@@ -689,6 +776,8 @@ function App() {
setTextSettings={setTextSettings}
speechSettings={speechSettings}
setSpeechSettings={setSpeechSettings}
ttsSettings={ttsSettings}
setTtsSettings={setTtsSettings}
health={health}
cleanupItems={cleanupItems}
isReadinessBusy={isReadinessBusy}
+183 -23
View File
@@ -28,7 +28,14 @@ import {
downloadSpeechModel,
deleteSpeechModel,
importSpeechModel,
getSpeechStatus
getSpeechStatus,
listTtsModels,
startTts,
stopTts,
downloadTtsModel,
deleteTtsModel,
importTtsModel,
getTtsStatus
} from "../services/api";
const MODEL_FILTERS = [
@@ -181,6 +188,38 @@ const OPENVINO_MODEL_LIBRARY = [
},
];
const TTS_MODEL_LIBRARY = [
{
group: "Kokoro Text-to-Speech Models",
items: [
{
id: "kokoro-onnx-q8",
name: "Kokoro 82M ONNX Q8",
filename: "kokoro-onnx-q8.json",
format: "Kokoro ONNX",
approxSize: "Model cache on first use",
resolution: "24 kHz WAV",
notes: "Recommended local TTS model. Good quality and faster startup than full precision on most PCs.",
url: "kokoro://install/kokoro-onnx-q8",
pageUrl: "https://huggingface.co/onnx-community/Kokoro-82M-v1.0-ONNX",
recommendedTiers: ["Low", "Mid", "High"],
},
{
id: "kokoro-onnx-fp32",
name: "Kokoro 82M ONNX FP32",
filename: "kokoro-onnx-fp32.json",
format: "Kokoro ONNX",
approxSize: "Model cache on first use",
resolution: "24 kHz WAV",
notes: "Full precision Kokoro ONNX path for quality testing. Uses more memory and disk cache.",
url: "kokoro://install/kokoro-onnx-fp32",
pageUrl: "https://huggingface.co/onnx-community/Kokoro-82M-v1.0-ONNX",
recommendedTiers: ["Mid", "High"],
},
],
},
];
const COREML_MODEL_LIBRARY = [
{
group: "Apple Silicon NPU - CoreML Test",
@@ -227,7 +266,7 @@ function ModelManager({
specs,
textSettings,
}) {
const [activeModelType, setActiveModelType] = useState("image"); // "image", "text", or "speech"
const [activeModelType, setActiveModelType] = useState("image"); // "image", "text", "speech", or "tts"
const [localModels, setLocalModels] = useState([]);
const [isLoadingModels, setIsLoadingModels] = useState(true);
const [downloadingModelId, setDownloadingModelId] = useState(null);
@@ -248,6 +287,8 @@ function ModelManager({
const [llmRunning, setLlmRunning] = useState(false);
const [activeSpeechModel, setActiveSpeechModel] = useState(null);
const [speechRunning, setSpeechRunning] = useState(false);
const [activeTtsModel, setActiveTtsModel] = useState(null);
const [ttsRunning, setTtsRunning] = useState(false);
const [modelSearch, setModelSearch] = useState("");
const [selectedFilters, setSelectedFilters] = useState([]);
const [huggingFaceModels, setHuggingFaceModels] = useState([]);
@@ -271,13 +312,13 @@ function ModelManager({
}
};
const normalizedLocalModels = activeModelType === "speech"
const normalizedLocalModels = activeModelType === "speech" || activeModelType === "tts"
? localModels.filter((model) => model.filename)
: localModels.map(normalizeModel).filter((model) => model.filename);
const displayedLocalModels = activeModelType === "speech"
const displayedLocalModels = activeModelType === "speech" || activeModelType === "tts"
? normalizedLocalModels.filter((model) => model.installed)
: normalizedLocalModels.filter((model) => !model.isProjector);
const allModelNames = activeModelType === "speech"
const allModelNames = activeModelType === "speech" || activeModelType === "tts"
? displayedLocalModels.map((model) => model.filename)
: normalizedLocalModels.map((model) => model.filename);
const modelNames = displayedLocalModels.map((model) => model.filename);
@@ -306,7 +347,7 @@ function ModelManager({
group: "Recommended Text Models from Hugging Face",
items: hasHuggingFaceResults ? displayedHuggingFaceModels : TEXT_MODEL_LIBRARY[0].items,
}];
} else {
} else if (activeModelType === "speech") {
visibleModelLibrary = [{
group: "Whisper Speech Models",
items: normalizedLocalModels.filter((model) => model.url).map((model) => ({
@@ -317,6 +358,8 @@ function ModelManager({
recommendedTiers: model.recommended ? ["Low", "Mid", "High"] : [],
})),
}];
} else {
visibleModelLibrary = TTS_MODEL_LIBRARY;
}
useEffect(() => {
@@ -393,9 +436,12 @@ function ModelManager({
} else if (activeModelType === "text") {
const list = await listLlmModels();
setLocalModels(list.map(normalizeModel).filter((model) => model.filename));
} else {
} else if (activeModelType === "speech") {
const list = await listSpeechModels();
setLocalModels(list.filter((model) => model.filename));
} else {
const list = await listTtsModels();
setLocalModels(list.filter((model) => model.filename));
}
} catch (e) {
console.error(e);
@@ -421,10 +467,11 @@ function ModelManager({
let cancelled = false;
const updateBackendInfo = async () => {
try {
const [sdStatus, llmStatus, speechStatus] = await Promise.all([
const [sdStatus, llmStatus, speechStatus, ttsStatus] = await Promise.all([
getBackendStatus(),
getLlmStatus(),
getSpeechStatus()
getSpeechStatus(),
getTtsStatus()
]);
if (cancelled) return;
@@ -434,6 +481,7 @@ function ModelManager({
backendDevice: sdStatus.settings?.backendDevice || sdStatus.loading?.device || "",
llmBackendMode: llmStatus.settings?.backendMode || llmStatus.settings?.backendBinary || "",
speechBackendMode: speechStatus.settings?.backendMode || speechStatus.backendMode || "",
ttsBackendMode: ttsStatus.settings?.backendMode || ttsStatus.backendMode || "",
});
if (llmStatus.ready) {
@@ -450,6 +498,13 @@ function ModelManager({
setActiveSpeechModel(null);
setSpeechRunning(false);
}
if (ttsStatus.ready) {
setActiveTtsModel(ttsStatus.settings?.model || null);
setTtsRunning(true);
} else {
setActiveTtsModel(null);
setTtsRunning(false);
}
} catch (_) {}
};
@@ -500,8 +555,10 @@ function ModelManager({
res = await downloadModel(url);
} else if (activeModelType === "text") {
res = await downloadLlmModel(url, expectedFilename, companion);
} else {
} else if (activeModelType === "speech") {
res = await downloadSpeechModel(url, expectedFilename);
} else {
res = await downloadTtsModel(url, expectedFilename);
}
if (res && res.ok) {
if (!companion && res.projectorUrl && res.projectorFilename) {
@@ -563,7 +620,7 @@ function ModelManager({
e.preventDefault();
if (!downloadUrl.trim()) return;
let modelName = activeModelType === "image" ? "model.safetensors" : activeModelType === "text" ? "model.gguf" : "model.bin";
let modelName = activeModelType === "image" ? "model.safetensors" : activeModelType === "text" ? "model.gguf" : activeModelType === "speech" ? "model.bin" : "model.json";
try {
const parsed = new URL(downloadUrl.trim());
modelName = parsed.pathname.split("/").pop() || modelName;
@@ -591,6 +648,18 @@ function ModelManager({
return;
}
if (activeModelType === "tts") {
const confirmed = await showConfirm({
title: "Download TTS Model?",
message: `Install "${model.name}"? Kokoro model files are cached on first generation.`,
confirmLabel: "Install",
});
if (confirmed) {
await downloadByUrl(model.id || model.url, model.filename);
}
return;
}
if (model.backendType === "openvino-npu") {
const confirmed = await showConfirm({
title: "Download OpenVINO Model?",
@@ -734,7 +803,7 @@ function ModelManager({
setModelLoadProgress(null);
setLoadingModelId(null);
}
} else {
} else if (activeModelType === "speech") {
setLoadingModelId(modelId);
setModelLoadProgress({
progress: 30,
@@ -763,6 +832,38 @@ function ModelManager({
setModelLoadProgress(null);
setLoadingModelId(null);
}
} else {
setLoadingModelId(modelId);
setModelLoadProgress({
progress: 30,
phase: "Starting Kokoro ONNX TTS runtime...",
speed: "",
current: 0,
total: 0,
model: modelId,
backendMode: "",
backendBinary: "",
device: "",
});
try {
await stopServer();
await stopLlm();
await stopSpeech();
await startTts(modelId);
setActiveTtsModel(modelId);
setTtsRunning(true);
setActiveSpeechModel(null);
setSpeechRunning(false);
setActiveLlmModel(null);
setLlmRunning(false);
setActiveModel(null);
setServerRunning(false);
} catch (err) {
showAlert({ title: "TTS Model Load Failed", message: err.message || String(err), danger: true });
} finally {
setModelLoadProgress(null);
setLoadingModelId(null);
}
}
};
@@ -966,7 +1067,7 @@ function ModelManager({
setIsUnloading(false);
setUnloadProgress({ progress: 0, phase: "" });
}
} else {
} else if (activeModelType === "speech") {
setIsUnloading(true);
setUnloadProgress({ progress: 50, phase: "Stopping whisper.cpp speech runtime..." });
try {
@@ -979,6 +1080,19 @@ function ModelManager({
setIsUnloading(false);
setUnloadProgress({ progress: 0, phase: "" });
}
} else {
setIsUnloading(true);
setUnloadProgress({ progress: 50, phase: "Stopping Kokoro TTS runtime..." });
try {
await stopTts();
setActiveTtsModel(null);
setTtsRunning(false);
} catch (e) {
showAlert({ title: "Unload Failed", message: e.message || String(e), danger: true });
} finally {
setIsUnloading(false);
setUnloadProgress({ progress: 0, phase: "" });
}
}
};
@@ -1000,11 +1114,16 @@ function ModelManager({
if (activeLlmModel === filename) {
await handleUnloadModel();
}
} else {
} else if (activeModelType === "speech") {
await deleteSpeechModel(filename);
if (activeSpeechModel === filename) {
await handleUnloadModel();
}
} else {
await deleteTtsModel(filename);
if (activeTtsModel === filename) {
await handleUnloadModel();
}
}
await fetchModels();
} catch (err) {
@@ -1046,7 +1165,7 @@ function ModelManager({
status: progressData.status
});
}, controller.signal);
} else {
} else if (activeModelType === "speech") {
await importSpeechModel(sourcePath, (progressData) => {
setImportProgress(Math.round(progressData.progress));
setImportInfo({
@@ -1056,6 +1175,16 @@ function ModelManager({
status: progressData.status
});
}, controller.signal);
} else {
await importTtsModel(sourcePath, (progressData) => {
setImportProgress(Math.round(progressData.progress));
setImportInfo({
filename: progressData.filename,
speed: progressData.speed_mb_s.toFixed(1),
eta: Math.round(progressData.eta_secs),
status: progressData.status
});
}, controller.signal);
}
await fetchModels();
@@ -1112,6 +1241,13 @@ function ModelManager({
>
Speech Models (Whisper)
</button>
<button
className={`m3-btn ${activeModelType === "tts" ? "m3-btn-filled" : "m3-btn-outlined"}`}
onClick={() => setActiveModelType("tts")}
style={{ height: "40px", padding: "0 20px" }}
>
TTS Models (Kokoro)
</button>
</div>
@@ -1190,6 +1326,26 @@ function ModelManager({
</div>
)}
{activeModelType === "tts" && activeTtsModel && (
<div className="m3-card" style={{ borderLeft: "4px solid var(--md-sys-color-primary)", background: "var(--md-sys-color-primary-container)", color: "var(--md-sys-color-on-primary-container)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div>
<h4 style={{ fontWeight: 700 }}>Active TTS Model: {activeTtsModel}</h4>
<p style={{ fontSize: "0.85rem", marginTop: "2px", opacity: 0.9 }}>
The local Kokoro ONNX runtime is ready. Text to Speech can generate WAV narration.
</p>
{backendInfo.ttsBackendMode && (
<div style={{ display: "flex", gap: "8px", marginTop: "8px", flexWrap: "wrap" }}>
<span className="status-chip" style={{ cursor: "default", background: "rgba(255,255,255,0.15)", color: "inherit", border: "1px solid rgba(255,255,255,0.2)" }}>
<span>{backendInfo.ttsBackendMode}</span>
</span>
</div>
)}
</div>
</div>
</div>
)}
{/* Loading Progress Bar */}
{modelLoadProgress && (
<div className="m3-card" style={{ borderLeft: "4px solid var(--md-sys-color-primary)", marginTop: "24px" }}>
@@ -1241,7 +1397,7 @@ function ModelManager({
<div className="m3-card" style={{ marginTop: "24px" }}>
<h3 className="m3-card-title">
<Database size={18} style={{ color: "var(--md-sys-color-primary)" }} />
Local {activeModelType === "image" ? "Image" : activeModelType === "text" ? "Text" : "Speech"} Models ({displayedLocalModels.length})
Local {activeModelType === "image" ? "Image" : activeModelType === "text" ? "Text" : activeModelType === "speech" ? "Speech" : "TTS"} Models ({displayedLocalModels.length})
</h3>
{isLoadingModels ? (
@@ -1255,13 +1411,15 @@ function ModelManager({
? "No image models detected in app/models/. Download from the library below or import a file."
: activeModelType === "text"
? "No text models detected in app/llm-models/. Download from the library below or import a file."
: "No speech models detected in app/speech-models/. Download from the library below or import a .bin file."}
: activeModelType === "speech"
? "No speech models detected in app/speech-models/. Download from the library below or import a .bin file."
: "No TTS models detected in app/tts-models/. Install Kokoro from the library below or import a .json manifest."}
</p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
{displayedLocalModels.map((model) => {
const filename = model.filename;
const isActive = activeModelType === "image" ? activeModel === filename : activeModelType === "text" ? activeLlmModel === filename : activeSpeechModel === filename;
const isActive = activeModelType === "image" ? activeModel === filename : activeModelType === "text" ? activeLlmModel === filename : activeModelType === "speech" ? activeSpeechModel === filename : activeTtsModel === filename;
return (
<div
@@ -1284,7 +1442,9 @@ function ModelManager({
? (model.backendType === "openvino-npu" ? "OpenVINO NPU Model" : model.format || "Local Weights File")
: activeModelType === "text"
? "llama.cpp GGUF Model"
: `${model.language || "Whisper"} Model`
: activeModelType === "speech"
? `${model.language || "Whisper"} Model`
: "Kokoro ONNX Model"
} • {model.size || formatBytes(model.sizeBytes)}
</span>
</div>
@@ -1332,7 +1492,7 @@ function ModelManager({
<div className="workspace-title-section" style={{ marginTop: "32px", marginBottom: "16px" }}>
<h3 className="m3-card-title">
<Library size={20} style={{ color: "var(--md-sys-color-primary)" }} />
{activeModelType === "text" ? "Hugging Face Model Library" : activeModelType === "speech" ? "Speech Model Library" : "Model Library"}
{activeModelType === "text" ? "Hugging Face Model Library" : activeModelType === "speech" ? "Speech Model Library" : activeModelType === "tts" ? "TTS Model Library" : "Model Library"}
</h3>
</div>
@@ -1541,14 +1701,14 @@ function ModelManager({
<input
type="file"
style={{ display: "none" }}
accept={activeModelType === "image" ? ".safetensors,.ckpt" : activeModelType === "text" ? ".gguf" : ".bin"}
accept={activeModelType === "image" ? ".safetensors,.ckpt" : activeModelType === "text" ? ".gguf" : activeModelType === "speech" ? ".bin" : ".json"}
onChange={handleImportFile}
disabled={importProgress !== null}
/>
<FolderOpen className="import-icon" />
<span style={{ fontWeight: 600 }}>Choose weights file</span>
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", textAlign: "center" }}>
Select {activeModelType === "image" ? "`.safetensors` or `.ckpt` weights." : activeModelType === "text" ? "`.gguf` weights." : "`whisper.cpp .bin` weights."}
Select {activeModelType === "image" ? "`.safetensors` or `.ckpt` weights." : activeModelType === "text" ? "`.gguf` weights." : activeModelType === "speech" ? "`whisper.cpp .bin` weights." : "`Kokoro .json` manifest."}
</span>
</label>
@@ -1558,7 +1718,7 @@ function ModelManager({
Download Model from URL
</h4>
<p style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", marginBottom: "12px" }}>
Download any {activeModelType === "image" ? "Safetensors" : activeModelType === "text" ? "GGUF" : "Whisper .bin"} model from Hugging Face directly to your models folder.
Download any {activeModelType === "image" ? "Safetensors" : activeModelType === "text" ? "GGUF" : activeModelType === "speech" ? "Whisper .bin" : "Kokoro manifest"} model from Hugging Face directly to your models folder.
</p>
{isUrlDownloading ? (
<div className="model-progress-section" style={{ marginTop: "0px" }}>
+100
View File
@@ -207,6 +207,8 @@ function Settings({
setTextSettings,
speechSettings,
setSpeechSettings,
ttsSettings,
setTtsSettings,
showAlert = async ({ message }) => window.alert(message),
showConfirm = async ({ message }) => window.confirm(message),
health,
@@ -230,6 +232,7 @@ function Settings({
image: localStorage.getItem("settings_section_image") === "true",
text: localStorage.getItem("settings_section_text") === "true",
speech: localStorage.getItem("settings_section_speech") === "true",
tts: localStorage.getItem("settings_section_tts") === "true",
};
});
@@ -297,6 +300,13 @@ function Settings({
}));
};
const updateTtsSetting = (key, value) => {
setTtsSettings((prev) => ({
...prev,
[key]: value
}));
};
const buildTextStartOptions = (settings) => ({
threads: settings?.threads || specs?.cpu_cores_physical || 4,
contextSize: settings?.contextSize ?? 0,
@@ -1037,6 +1047,93 @@ function Settings({
};
// ─── Appearance Settings ───
const renderTtsSettings = () => {
const TTS_VOICES = [
{ value: "af_heart", label: "Heart - Female, US English" },
{ value: "af_bella", label: "Bella - Female, US English" },
{ value: "af_nicole", label: "Nicole - Female, US English" },
{ value: "af_sarah", label: "Sarah - Female, US English" },
{ value: "am_michael", label: "Michael - Male, US English" },
{ value: "am_fenrir", label: "Fenrir - Male, US English" },
{ value: "bf_emma", label: "Emma - Female, UK English" },
{ value: "bm_george", label: "George - Male, UK English" },
];
return (
<>
<SectionHeader
icon={Volume2}
title="Text to Speech"
count={2}
color="#8b5cf6"
isExpanded={expandedSections.tts}
onToggle={() => toggleSection("tts")}
/>
{expandedSections.tts && (
<div className="settings-expanded-content">
<div className="settings-two-column">
<div className="settings-column">
<div className="settings-subsection">
<div className="settings-subsection-title">
<Volume2 size={16} />
Voice Defaults
</div>
<div className="m3-field-group">
<div className="m3-text-field">
<label className="m3-text-field-label">Default Voice</label>
<select
className="m3-input"
value={ttsSettings?.voice || "af_heart"}
onChange={(e) => updateTtsSetting("voice", e.target.value)}
>
{TTS_VOICES.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
<span className="settings-option-desc" style={{ marginTop: "4px", display: "block" }}>
Default Kokoro voice for generated WAV files.
</span>
</div>
</div>
</div>
</div>
<div className="settings-column">
<div className="settings-subsection">
<div className="settings-subsection-title">
<Gauge size={16} />
Speech Timing
</div>
<div className="m3-slider-group">
<div className="m3-slider-header">
<span className="m3-slider-label">Speed</span>
<span className="settings-value-badge">{(ttsSettings?.speed || 1).toFixed(2)}x</span>
</div>
<input
type="range"
className="m3-slider"
value={ttsSettings?.speed || 1}
onChange={(e) => updateTtsSetting("speed", parseFloat(e.target.value))}
min="0.5"
max="2"
step="0.05"
/>
<span className="settings-option-desc">
1.00x is natural speed. Lower values are slower, higher values are faster.
</span>
</div>
</div>
</div>
</div>
</div>
)}
</>
);
};
const renderAppearanceSettings = () => (
<>
<SectionHeader
@@ -1144,6 +1241,9 @@ function Settings({
{/* Speech Settings Section */}
{renderSpeechSettings()}
{/* Text to Speech Settings Section */}
{renderTtsSettings()}
</div>
);
}
+129 -2
View File
@@ -1,5 +1,5 @@
import React, { memo } from "react";
import { Image, FolderDown, MessageSquare, Mic, Settings, Shield, Terminal, ChevronDown, ChevronUp, Trash2 } from "lucide-react";
import { Image, FolderDown, MessageSquare, Mic, Settings, Shield, Terminal, ChevronDown, ChevronUp, Trash2, Volume2 } from "lucide-react";
function formatSidebarDate(value) {
const date = new Date(value);
@@ -24,7 +24,13 @@ function Sidebar({
setSelectedSpeechTranscript,
showSpeechHistory,
setShowSpeechHistory,
onDeleteSpeechTranscription
onDeleteSpeechTranscription,
ttsOutputs = [],
selectedTtsOutput,
setSelectedTtsOutput,
showTtsHistory,
setShowTtsHistory,
onDeleteTtsOutput
}) {
return (
<div className="sidebar">
@@ -283,6 +289,127 @@ function Sidebar({
)}
</div>
<div className="nav-item-wrapper" style={{ display: "flex", flexDirection: "column" }}>
<div
className={`nav-item ${activeTab === "tts" ? "active" : ""}`}
onClick={() => setActiveTab("tts")}
style={{ display: "flex", justifyContent: "space-between", alignItems: "center", width: "100%", boxSizing: "border-box" }}
>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<Volume2 size={20} />
<span>Text to Speech</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
setShowTtsHistory(!showTtsHistory);
}}
style={{
background: "none",
border: "none",
color: "inherit",
cursor: "pointer",
padding: "4px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "50%",
transition: "background-color 0.2s"
}}
className="history-toggle-arrow"
title={showTtsHistory ? "Hide TTS Outputs" : "Show TTS Outputs"}
>
{showTtsHistory ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
</div>
{showTtsHistory && (
<div
className="sidebar-history-list"
style={{
paddingLeft: "14px",
marginTop: "6px",
marginBottom: "6px",
display: "flex",
flexDirection: "column",
gap: "4px",
maxHeight: "220px",
overflowY: "auto",
borderLeft: "2px solid var(--border-color)"
}}
>
{ttsOutputs.length === 0 ? (
<div style={{ padding: "8px 12px", fontSize: "0.78rem", color: "var(--md-sys-color-outline)", opacity: 0.8 }}>
No saved audio
</div>
) : (
ttsOutputs.map((item) => {
const itemId = item.filename || item.metadata || item.audioFile;
const isActive = selectedTtsOutput && (selectedTtsOutput.filename || selectedTtsOutput.metadata || selectedTtsOutput.audioFile) === itemId;
const title = item.displayName || item.text || item.audioFile || item.filename || "TTS Output";
const date = formatSidebarDate(item.modifiedAt || item.createdAt);
return (
<div
key={itemId}
onClick={() => {
setSelectedTtsOutput(item);
setActiveTab("tts");
}}
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: "7px 8px 7px 10px",
borderRadius: "var(--md-shape-corner-small)",
fontSize: "0.78rem",
cursor: "pointer",
background: isActive ? "var(--md-sys-color-secondary-container)" : "transparent",
color: isActive ? "var(--md-sys-color-on-secondary-container)" : "var(--md-sys-color-on-surface-variant)",
border: isActive ? "1px solid var(--md-sys-color-outline-variant)" : "1px solid transparent",
transition: "background 0.2s"
}}
className="sidebar-history-item"
title={title}
>
<div style={{ display: "flex", flexDirection: "column", gap: "2px", flex: 1, minWidth: 0 }}>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: isActive ? 600 : 400 }}>
{title}
</span>
{date && (
<span style={{ fontSize: "0.68rem", opacity: 0.72, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{date}
</span>
)}
</div>
<button
onClick={(e) => {
e.stopPropagation();
onDeleteTtsOutput?.(item, e);
}}
style={{
background: "none",
border: "none",
color: "var(--md-sys-color-outline)",
cursor: "pointer",
padding: "2px",
marginLeft: "6px",
display: "flex",
alignItems: "center"
}}
className="sidebar-history-delete"
title="Delete TTS Output"
>
<Trash2 size={12} />
</button>
</div>
);
})
)}
</div>
)}
</div>
<div
className={`nav-item ${activeTab === "models" ? "active" : ""}`}
onClick={() => setActiveTab("models")}
@@ -0,0 +1,317 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
LoaderCircle,
Play,
RefreshCw,
Save,
Square,
Volume2,
} from "lucide-react";
import {
getTtsStatus,
listTtsModels,
speakTts,
startTts,
stopTts,
} from "../services/api";
const FALLBACK_VOICES = [
{ id: "af_heart", name: "Heart", language: "en-us", gender: "Female" },
{ id: "af_bella", name: "Bella", language: "en-us", gender: "Female" },
{ id: "am_michael", name: "Michael", language: "en-us", gender: "Male" },
{ id: "bf_emma", name: "Emma", language: "en-gb", gender: "Female" },
{ id: "bm_george", name: "George", language: "en-gb", gender: "Male" },
];
function downloadUrl(url, filename) {
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
}
export default function TextToSpeech({
showAlert,
selectedOutput,
onOutputsChanged,
ttsSettings,
setTtsSettings,
}) {
const [status, setStatus] = useState({ ready: false, running: false, runtimeInstalled: false, settings: {}, voices: [] });
const [models, setModels] = useState([]);
const [selectedModel, setSelectedModel] = useState("");
const [text, setText] = useState("Hello from Local AI Studio text to speech.");
const [isLoadingModel, setIsLoadingModel] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const [output, setOutput] = useState(null);
const abortRef = useRef(null);
const installedModels = useMemo(() => models.filter((model) => model.installed), [models]);
const voices = status.voices?.length ? status.voices : FALLBACK_VOICES;
const selectedVoice = ttsSettings?.voice || "af_heart";
const speed = ttsSettings?.speed || 1;
const updateTtsSetting = (key, value) => {
setTtsSettings((prev) => ({ ...prev, [key]: value }));
};
const refresh = useCallback(async () => {
const [nextStatus, nextModels] = await Promise.all([
getTtsStatus(),
listTtsModels(),
]);
setStatus(nextStatus);
setModels(nextModels);
setSelectedModel((current) => {
const installed = nextModels.filter((model) => model.installed);
if (current && installed.some((model) => model.filename === current || model.id === current)) return current;
const active = nextStatus.settings?.model;
if (active && installed.some((model) => model.filename === active)) return active;
const preferred = ttsSettings?.model;
if (preferred && installed.some((model) => model.filename === preferred)) return preferred;
return installed.find((model) => model.recommended)?.filename || installed[0]?.filename || "";
});
}, [ttsSettings?.model]);
useEffect(() => {
refresh().catch((err) => showAlert?.({ title: "TTS Status Failed", message: err.message || String(err), danger: true }));
const interval = setInterval(() => {
refresh().catch(() => {});
}, 2500);
return () => clearInterval(interval);
}, [refresh, showAlert]);
useEffect(() => {
if (!selectedOutput) return;
setOutput(selectedOutput);
setText(selectedOutput.text || "");
if (selectedOutput.voice) updateTtsSetting("voice", selectedOutput.voice);
if (selectedOutput.speed) updateTtsSetting("speed", selectedOutput.speed);
if (selectedOutput.model) setSelectedModel(selectedOutput.model);
}, [selectedOutput]);
useEffect(() => {
return () => abortRef.current?.abort();
}, []);
const handleLoadModel = async () => {
if (!selectedModel) return;
setIsLoadingModel(true);
try {
await startTts(selectedModel, { voice: selectedVoice, speed });
updateTtsSetting("model", selectedModel);
await refresh();
} catch (err) {
showAlert?.({ title: "TTS Model Load Failed", message: err.message || String(err), danger: true });
} finally {
setIsLoadingModel(false);
}
};
const handleStop = async () => {
try {
await stopTts();
await refresh();
} catch (err) {
showAlert?.({ title: "Stop TTS Failed", message: err.message || String(err), danger: true });
}
};
const handleGenerate = async () => {
if (!text.trim() || !selectedModel) return;
setIsGenerating(true);
const controller = new AbortController();
abortRef.current = controller;
try {
if (!status.ready || status.settings?.model !== selectedModel) {
setIsLoadingModel(true);
try {
await startTts(selectedModel, { voice: selectedVoice, speed });
updateTtsSetting("model", selectedModel);
await refresh();
} finally {
setIsLoadingModel(false);
}
}
const generated = await speakTts(text, {
model: selectedModel,
voice: selectedVoice,
speed,
signal: controller.signal,
});
setOutput(generated);
await refresh();
await onOutputsChanged?.();
} catch (err) {
if (err.name !== "AbortError") {
showAlert?.({ title: "TTS Generation Failed", message: err.message || String(err), danger: true });
}
} finally {
abortRef.current = null;
setIsGenerating(false);
}
};
const handleCancel = () => {
abortRef.current?.abort();
setIsGenerating(false);
};
const isLoaded = status.ready && status.settings?.model === selectedModel;
return (
<div className="workspace-area speech-workspace">
<div className="workspace-title-section">
<h2 className="workspace-title">Text to Speech</h2>
<p className="workspace-subtitle">
Generate local WAV narration with Kokoro ONNX.
</p>
</div>
<div className="speech-grid">
<section className="m3-card speech-panel">
<div className="speech-panel-header">
<h3>Runtime</h3>
<span className={`status-chip ${status.runtimeInstalled ? "" : "offline"}`}>
{status.runtimeInstalled ? (status.ready ? "Ready" : "Installed") : "Runtime missing"}
</span>
</div>
{status.error && <div className="text-progress error">{status.error}</div>}
<label className="speech-label">
Kokoro model
<select className="m3-input" value={selectedModel} onChange={(event) => setSelectedModel(event.target.value)}>
{installedModels.length === 0 && <option value="">No downloaded TTS models</option>}
{installedModels.map((model) => (
<option key={model.id || model.filename} value={model.filename}>
{model.name || model.filename}
</option>
))}
</select>
</label>
<div className="speech-controls-row">
<label className="speech-label">
Voice
<select className="m3-input" value={selectedVoice} onChange={(event) => updateTtsSetting("voice", event.target.value)}>
{voices.map((voice) => (
<option key={voice.id} value={voice.id}>
{voice.name} ({voice.gender}, {voice.language})
</option>
))}
</select>
</label>
<label className="speech-label">
Speed
<input
className="m3-input"
type="number"
min="0.5"
max="2"
step="0.05"
value={speed}
onChange={(event) => updateTtsSetting("speed", Math.max(0.5, Math.min(2, Number(event.target.value) || 1)))}
/>
</label>
</div>
<div className="speech-button-row">
<button
className="m3-btn m3-btn-filled"
onClick={handleLoadModel}
disabled={!selectedModel || isLoadingModel || isGenerating || isLoaded}
>
{isLoadingModel ? <LoaderCircle className="progress-spinner" size={14} /> : <Play size={14} />}
<span>{isLoaded ? "Loaded" : isLoadingModel ? "Loading" : "Load"}</span>
</button>
<button className="m3-btn m3-btn-outlined" onClick={refresh}>
<RefreshCw size={14} />
<span>Refresh</span>
</button>
{status.ready && (
<button className="m3-btn m3-btn-error" onClick={handleStop}>
<Square size={14} />
<span>Stop Runtime</span>
</button>
)}
</div>
{installedModels.length === 0 && (
<div className="text-progress">
Download a Kokoro model from Model Manager, TTS Models first.
</div>
)}
</section>
<section className="m3-card speech-panel">
<div className="speech-panel-header">
<h3>Input</h3>
<span className="status-chip">{text.trim().length} chars</span>
</div>
<textarea
className="m3-input"
value={text}
onChange={(event) => setText(event.target.value)}
rows={10}
maxLength={5000}
style={{ width: "100%", resize: "vertical", minHeight: "220px", lineHeight: 1.45 }}
placeholder="Type text to turn into speech..."
/>
<div className="speech-button-row">
{isGenerating ? (
<button className="m3-btn m3-btn-error" onClick={handleCancel}>
<Square size={14} />
<span>Cancel</span>
</button>
) : (
<button
className="m3-btn m3-btn-filled"
onClick={handleGenerate}
disabled={!text.trim() || !selectedModel || !status.runtimeInstalled}
>
<Volume2 size={14} />
<span>Generate WAV</span>
</button>
)}
{isGenerating && <span className="text-progress"><LoaderCircle className="progress-spinner" size={14} /> Generating locally...</span>}
</div>
</section>
</div>
<section className="m3-card speech-result-panel">
<div className="speech-panel-header">
<h3>Output</h3>
<div className="speech-button-row">
<button className="m3-btn m3-btn-outlined" onClick={() => output?.url && downloadUrl(output.url, output.audioFile || "tts.wav")} disabled={!output?.url}>
<Save size={14} />
<span>WAV</span>
</button>
</div>
</div>
{output?.url ? (
<div style={{ display: "flex", flexDirection: "column", gap: "14px" }}>
<audio controls src={output.url} style={{ width: "100%" }} />
<div className="text-progress">
{output.voiceName || output.voice} &bull; {output.modelName || output.model} &bull; {new Date(output.createdAt).toLocaleString()}
</div>
<textarea
className="m3-input"
value={output.text || text}
onChange={(event) => setOutput((prev) => ({ ...(prev || {}), text: event.target.value }))}
rows={5}
style={{ width: "100%", resize: "vertical", minHeight: "120px", lineHeight: 1.45 }}
/>
</div>
) : (
<div className="empty-state" style={{ padding: "32px 0" }}>
<Volume2 size={42} />
<p>Generated speech will appear here.</p>
</div>
)}
</section>
</div>
);
}
+89
View File
@@ -663,6 +663,95 @@ export async function deleteSpeechTranscription(filename) {
return await readJsonResponse(res, "The local server returned an invalid speech transcription delete response.");
}
export async function getTtsStatus() {
try {
const res = await fetch("/api/tts/status");
return await readJsonResponse(res, "The local server returned invalid TTS runtime status.");
} catch (err) {
return { ready: false, running: false, runtimeInstalled: false, error: err.message, settings: {}, voices: [] };
}
}
export async function listTtsModels() {
const res = await fetch("/api/tts/models");
const data = await readJsonResponse(res, "The local server returned invalid TTS model data.");
return data.models || [];
}
export async function startTts(model, options = {}) {
const res = await fetch("/api/tts/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model,
voice: options.voice,
speed: options.speed,
}),
});
return await readJsonResponse(res, "The local server returned an invalid TTS start response.");
}
export async function stopTts() {
const res = await fetch("/api/tts/stop", { method: "POST" });
return await readJsonResponse(res, "The local server returned an invalid TTS stop response.");
}
export async function speakTts(text, options = {}) {
const res = await fetch("/api/tts/speak", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text,
model: options.model,
voice: options.voice,
speed: options.speed,
}),
signal: options.signal,
});
const data = await readJsonResponse(res, "The local server returned an invalid TTS response.");
return data.output;
}
export async function downloadTtsModel(modelIdOrUrl, filename = null) {
const res = await fetch("/api/tts/download-model", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: modelIdOrUrl,
filename,
}),
});
return await readJsonResponse(res, "The local server returned an invalid TTS download response.");
}
export async function importTtsModel(file, onProgress, signal) {
return await uploadModelFileToEndpoint(file, "/api/tts/import-model", onProgress, signal);
}
export async function deleteTtsModel(filename) {
const res = await fetch("/api/tts/delete-model", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename }),
});
return await readJsonResponse(res, "The local server returned an invalid TTS model delete response.");
}
export async function listTtsOutputs() {
const res = await fetch("/api/tts/outputs");
const data = await readJsonResponse(res, "The local server returned invalid TTS output history.");
return data.outputs || [];
}
export async function deleteTtsOutput(filename) {
const res = await fetch("/api/tts/delete-output", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename }),
});
return await readJsonResponse(res, "The local server returned an invalid TTS output delete response.");
}
export async function getLlmRecommendations(useCase = "chat", limit = 10) {
try {
const res = await fetch(`/api/llm/recommend?useCase=${encodeURIComponent(useCase)}&limit=${limit}`);
+5
View File
@@ -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 " ============================================================"
+6 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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); });
+52
View File
@@ -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."
+55
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+59
View File
@@ -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);
});
+6
View File
@@ -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 ============================================================