setActiveTab("models")}
diff --git a/app/frontend/src/components/TextToSpeech.jsx b/app/frontend/src/components/TextToSpeech.jsx
new file mode 100644
index 0000000..a621eda
--- /dev/null
+++ b/app/frontend/src/components/TextToSpeech.jsx
@@ -0,0 +1,317 @@
+import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ LoaderCircle,
+ Play,
+ RefreshCw,
+ Save,
+ Square,
+ Volume2,
+} from "lucide-react";
+import {
+ getTtsStatus,
+ listTtsModels,
+ speakTts,
+ startTts,
+ stopTts,
+} from "../services/api";
+
+const FALLBACK_VOICES = [
+ { id: "af_heart", name: "Heart", language: "en-us", gender: "Female" },
+ { id: "af_bella", name: "Bella", language: "en-us", gender: "Female" },
+ { id: "am_michael", name: "Michael", language: "en-us", gender: "Male" },
+ { id: "bf_emma", name: "Emma", language: "en-gb", gender: "Female" },
+ { id: "bm_george", name: "George", language: "en-gb", gender: "Male" },
+];
+
+function downloadUrl(url, filename) {
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+}
+
+export default function TextToSpeech({
+ showAlert,
+ selectedOutput,
+ onOutputsChanged,
+ ttsSettings,
+ setTtsSettings,
+}) {
+ const [status, setStatus] = useState({ ready: false, running: false, runtimeInstalled: false, settings: {}, voices: [] });
+ const [models, setModels] = useState([]);
+ const [selectedModel, setSelectedModel] = useState("");
+ const [text, setText] = useState("Hello from Local AI Studio text to speech.");
+ const [isLoadingModel, setIsLoadingModel] = useState(false);
+ const [isGenerating, setIsGenerating] = useState(false);
+ const [output, setOutput] = useState(null);
+ const abortRef = useRef(null);
+
+ const installedModels = useMemo(() => models.filter((model) => model.installed), [models]);
+ const voices = status.voices?.length ? status.voices : FALLBACK_VOICES;
+ const selectedVoice = ttsSettings?.voice || "af_heart";
+ const speed = ttsSettings?.speed || 1;
+
+ const updateTtsSetting = (key, value) => {
+ setTtsSettings((prev) => ({ ...prev, [key]: value }));
+ };
+
+ const refresh = useCallback(async () => {
+ const [nextStatus, nextModels] = await Promise.all([
+ getTtsStatus(),
+ listTtsModels(),
+ ]);
+ setStatus(nextStatus);
+ setModels(nextModels);
+ setSelectedModel((current) => {
+ const installed = nextModels.filter((model) => model.installed);
+ if (current && installed.some((model) => model.filename === current || model.id === current)) return current;
+ const active = nextStatus.settings?.model;
+ if (active && installed.some((model) => model.filename === active)) return active;
+ const preferred = ttsSettings?.model;
+ if (preferred && installed.some((model) => model.filename === preferred)) return preferred;
+ return installed.find((model) => model.recommended)?.filename || installed[0]?.filename || "";
+ });
+ }, [ttsSettings?.model]);
+
+ useEffect(() => {
+ refresh().catch((err) => showAlert?.({ title: "TTS Status Failed", message: err.message || String(err), danger: true }));
+ const interval = setInterval(() => {
+ refresh().catch(() => {});
+ }, 2500);
+ return () => clearInterval(interval);
+ }, [refresh, showAlert]);
+
+ useEffect(() => {
+ if (!selectedOutput) return;
+ setOutput(selectedOutput);
+ setText(selectedOutput.text || "");
+ if (selectedOutput.voice) updateTtsSetting("voice", selectedOutput.voice);
+ if (selectedOutput.speed) updateTtsSetting("speed", selectedOutput.speed);
+ if (selectedOutput.model) setSelectedModel(selectedOutput.model);
+ }, [selectedOutput]);
+
+ useEffect(() => {
+ return () => abortRef.current?.abort();
+ }, []);
+
+ const handleLoadModel = async () => {
+ if (!selectedModel) return;
+ setIsLoadingModel(true);
+ try {
+ await startTts(selectedModel, { voice: selectedVoice, speed });
+ updateTtsSetting("model", selectedModel);
+ await refresh();
+ } catch (err) {
+ showAlert?.({ title: "TTS Model Load Failed", message: err.message || String(err), danger: true });
+ } finally {
+ setIsLoadingModel(false);
+ }
+ };
+
+ const handleStop = async () => {
+ try {
+ await stopTts();
+ await refresh();
+ } catch (err) {
+ showAlert?.({ title: "Stop TTS Failed", message: err.message || String(err), danger: true });
+ }
+ };
+
+ const handleGenerate = async () => {
+ if (!text.trim() || !selectedModel) return;
+ setIsGenerating(true);
+ const controller = new AbortController();
+ abortRef.current = controller;
+ try {
+ if (!status.ready || status.settings?.model !== selectedModel) {
+ setIsLoadingModel(true);
+ try {
+ await startTts(selectedModel, { voice: selectedVoice, speed });
+ updateTtsSetting("model", selectedModel);
+ await refresh();
+ } finally {
+ setIsLoadingModel(false);
+ }
+ }
+ const generated = await speakTts(text, {
+ model: selectedModel,
+ voice: selectedVoice,
+ speed,
+ signal: controller.signal,
+ });
+ setOutput(generated);
+ await refresh();
+ await onOutputsChanged?.();
+ } catch (err) {
+ if (err.name !== "AbortError") {
+ showAlert?.({ title: "TTS Generation Failed", message: err.message || String(err), danger: true });
+ }
+ } finally {
+ abortRef.current = null;
+ setIsGenerating(false);
+ }
+ };
+
+ const handleCancel = () => {
+ abortRef.current?.abort();
+ setIsGenerating(false);
+ };
+
+ const isLoaded = status.ready && status.settings?.model === selectedModel;
+
+ return (
+
+
+
Text to Speech
+
+ Generate local WAV narration with Kokoro ONNX.
+
+
+
+
+
+
+
Runtime
+
+ {status.runtimeInstalled ? (status.ready ? "Ready" : "Installed") : "Runtime missing"}
+
+
+
+ {status.error && {status.error}
}
+
+
+ Kokoro model
+ setSelectedModel(event.target.value)}>
+ {installedModels.length === 0 && No downloaded TTS models }
+ {installedModels.map((model) => (
+
+ {model.name || model.filename}
+
+ ))}
+
+
+
+
+
+ Voice
+ updateTtsSetting("voice", event.target.value)}>
+ {voices.map((voice) => (
+
+ {voice.name} ({voice.gender}, {voice.language})
+
+ ))}
+
+
+
+ Speed
+ updateTtsSetting("speed", Math.max(0.5, Math.min(2, Number(event.target.value) || 1)))}
+ />
+
+
+
+
+
+ {isLoadingModel ? : }
+ {isLoaded ? "Loaded" : isLoadingModel ? "Loading" : "Load"}
+
+
+
+ Refresh
+
+ {status.ready && (
+
+
+ Stop Runtime
+
+ )}
+
+
+ {installedModels.length === 0 && (
+
+ Download a Kokoro model from Model Manager, TTS Models first.
+
+ )}
+
+
+
+
+
Input
+ {text.trim().length} chars
+
+
+
+
+
+
+
Output
+
+ output?.url && downloadUrl(output.url, output.audioFile || "tts.wav")} disabled={!output?.url}>
+
+ WAV
+
+
+
+ {output?.url ? (
+
+
+
+ {output.voiceName || output.voice} • {output.modelName || output.model} • {new Date(output.createdAt).toLocaleString()}
+
+
+ ) : (
+
+
+
Generated speech will appear here.
+
+ )}
+
+
+ );
+}
diff --git a/app/frontend/src/services/api.js b/app/frontend/src/services/api.js
index 9c267c1..ce3588e 100644
--- a/app/frontend/src/services/api.js
+++ b/app/frontend/src/services/api.js
@@ -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}`);
diff --git a/linux.sh b/linux.sh
index b58cd9b..79c19ca 100755
--- a/linux.sh
+++ b/linux.sh
@@ -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 " ============================================================"
diff --git a/mac.sh b/mac.sh
index cc75ef9..1714711 100755
--- a/mac.sh
+++ b/mac.sh
@@ -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 " ============================================================"
diff --git a/scripts/reset.ps1 b/scripts/reset.ps1
index 725d08f..ed6a8f1 100644
--- a/scripts/reset.ps1
+++ b/scripts/reset.ps1
@@ -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..."
diff --git a/scripts/reset.sh b/scripts/reset.sh
index 3262728..7de4ec6 100755
--- a/scripts/reset.sh
+++ b/scripts/reset.sh
@@ -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..."
diff --git a/scripts/serve.cjs b/scripts/serve.cjs
index 9055b68..7746461 100644
--- a/scripts/serve.cjs
+++ b/scripts/serve.cjs
@@ -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); });
diff --git a/scripts/setup-tts.ps1 b/scripts/setup-tts.ps1
new file mode 100644
index 0000000..2bd6223
--- /dev/null
+++ b/scripts/setup-tts.ps1
@@ -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."
diff --git a/scripts/setup-tts.sh b/scripts/setup-tts.sh
new file mode 100644
index 0000000..46f11c3
--- /dev/null
+++ b/scripts/setup-tts.sh
@@ -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."
diff --git a/scripts/setup.ps1 b/scripts/setup.ps1
index 5eacced..850581f 100644
--- a/scripts/setup.ps1
+++ b/scripts/setup.ps1
@@ -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
diff --git a/scripts/setup.sh b/scripts/setup.sh
index 54411c2..e02f5b5 100755
--- a/scripts/setup.sh
+++ b/scripts/setup.sh
@@ -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.
diff --git a/scripts/tts-kokoro-worker.mjs b/scripts/tts-kokoro-worker.mjs
new file mode 100644
index 0000000..359df16
--- /dev/null
+++ b/scripts/tts-kokoro-worker.mjs
@@ -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);
+});
diff --git a/windows.bat b/windows.bat
index 0467ed5..e7f7712 100644
--- a/windows.bat
+++ b/windows.bat
@@ -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 ============================================================