diff --git a/app/frontend/src/components/Sidebar.jsx b/app/frontend/src/components/Sidebar.jsx
index db86544..4b27604 100644
--- a/app/frontend/src/components/Sidebar.jsx
+++ b/app/frontend/src/components/Sidebar.jsx
@@ -1,5 +1,11 @@
import React, { memo } from "react";
-import { Image, FolderDown, MessageSquare, Settings, Shield, Terminal, ChevronDown, ChevronUp, Trash2 } from "lucide-react";
+import { Image, FolderDown, MessageSquare, Mic, Settings, Shield, Terminal, ChevronDown, ChevronUp, Trash2 } from "lucide-react";
+
+function formatSidebarDate(value) {
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return "";
+ return date.toLocaleDateString();
+}
function Sidebar({
activeTab,
@@ -10,7 +16,12 @@ function Sidebar({
setActiveConversationId,
showHistory,
setShowHistory,
- onDeleteConversation
+ onDeleteConversation,
+ speechTranscriptions = [],
+ selectedSpeechTranscript,
+ setSelectedSpeechTranscript,
+ showSpeechHistory,
+ setShowSpeechHistory
}) {
return (
setActiveTab("models")}
diff --git a/app/frontend/src/components/SpeechTranscriber.jsx b/app/frontend/src/components/SpeechTranscriber.jsx
new file mode 100644
index 0000000..d72aee3
--- /dev/null
+++ b/app/frontend/src/components/SpeechTranscriber.jsx
@@ -0,0 +1,478 @@
+import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ FileAudio,
+ LoaderCircle,
+ Mic,
+ Play,
+ Save,
+ Square,
+ Upload,
+ X,
+} from "lucide-react";
+import {
+ getSpeechStatus,
+ listSpeechModels,
+ startSpeech,
+ stopSpeech,
+ transcribeSpeech,
+} from "../services/api";
+
+const LANGUAGE_OPTIONS = [
+ { value: "auto", label: "Auto detect" },
+ { value: "en", label: "English" },
+ { value: "es", label: "Spanish" },
+ { value: "fr", label: "French" },
+ { value: "de", label: "German" },
+ { value: "it", label: "Italian" },
+ { value: "pt", label: "Portuguese" },
+ { value: "hi", label: "Hindi" },
+ { value: "ja", label: "Japanese" },
+ { value: "ko", label: "Korean" },
+ { value: "zh", label: "Chinese" },
+];
+
+function flattenFloat32(chunks) {
+ const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
+ const merged = new Float32Array(total);
+ let offset = 0;
+ chunks.forEach((chunk) => {
+ merged.set(chunk, offset);
+ offset += chunk.length;
+ });
+ return merged;
+}
+
+function resampleLinear(input, sourceRate, targetRate) {
+ if (sourceRate === targetRate) return input;
+ const ratio = sourceRate / targetRate;
+ const newLength = Math.max(1, Math.round(input.length / ratio));
+ const output = new Float32Array(newLength);
+ for (let i = 0; i < newLength; i += 1) {
+ const src = i * ratio;
+ const left = Math.floor(src);
+ const right = Math.min(input.length - 1, left + 1);
+ const weight = src - left;
+ output[i] = input[left] * (1 - weight) + input[right] * weight;
+ }
+ return output;
+}
+
+function encodeWavPcm16(samples, sampleRate = 16000) {
+ const bytesPerSample = 2;
+ const buffer = new ArrayBuffer(44 + samples.length * bytesPerSample);
+ const view = new DataView(buffer);
+
+ const writeString = (offset, value) => {
+ for (let i = 0; i < value.length; i += 1) {
+ view.setUint8(offset + i, value.charCodeAt(i));
+ }
+ };
+
+ writeString(0, "RIFF");
+ view.setUint32(4, 36 + samples.length * bytesPerSample, true);
+ writeString(8, "WAVE");
+ writeString(12, "fmt ");
+ view.setUint32(16, 16, true);
+ view.setUint16(20, 1, true);
+ view.setUint16(22, 1, true);
+ view.setUint32(24, sampleRate, true);
+ view.setUint32(28, sampleRate * bytesPerSample, true);
+ view.setUint16(32, bytesPerSample, true);
+ view.setUint16(34, 16, true);
+ writeString(36, "data");
+ view.setUint32(40, samples.length * bytesPerSample, true);
+
+ let offset = 44;
+ for (let i = 0; i < samples.length; i += 1, offset += 2) {
+ const sample = Math.max(-1, Math.min(1, samples[i]));
+ view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
+ }
+ return new Blob([view], { type: "audio/wav" });
+}
+
+function downloadBlob(filename, content, type) {
+ const blob = content instanceof Blob ? content : new Blob([content], { type });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ URL.revokeObjectURL(url);
+}
+
+function normalizeTimestamp(value) {
+ if (!value) return "";
+ if (typeof value === "string") {
+ const from = value.match(/from=([^;},]+)/)?.[1];
+ const to = value.match(/to=([^;},]+)/)?.[1];
+ return from && to ? `${from} - ${to}` : value;
+ }
+ if (typeof value === "object" && value.from && value.to) {
+ return `${value.from} - ${value.to}`;
+ }
+ return "";
+}
+
+function formatTranscriptWithTimes(transcription) {
+ const segments = transcription?.raw?.transcription;
+ if (!Array.isArray(segments) || segments.length === 0) {
+ return transcription?.text || "";
+ }
+ return segments
+ .map((segment) => {
+ const time = normalizeTimestamp(segment.timestamps);
+ const text = String(segment.text || "").trim();
+ return time ? `[${time}] ${text}` : text;
+ })
+ .filter(Boolean)
+ .join("\n");
+}
+
+export default function SpeechTranscriber({ showAlert, showConfirm, selectedTranscript, onTranscriptionsChanged }) {
+ const [status, setStatus] = useState({ ready: false, running: false, backendInstalled: false, settings: {} });
+ const [models, setModels] = useState([]);
+ const [selectedModel, setSelectedModel] = useState("");
+ const [language, setLanguage] = useState("auto");
+ const [threads, setThreads] = useState(Math.max(1, Math.min(8, navigator.hardwareConcurrency || 4)));
+ const [audioBlob, setAudioBlob] = useState(null);
+ const [audioName, setAudioName] = useState("");
+ const [audioUrl, setAudioUrl] = useState("");
+ const [isRecording, setIsRecording] = useState(false);
+ const [isTranscribing, setIsTranscribing] = useState(false);
+ const [result, setResult] = useState(null);
+ const [resultText, setResultText] = useState("");
+ const [isLoadingModel, setIsLoadingModel] = useState(false);
+ const recorderRef = useRef(null);
+ const abortRef = useRef(null);
+
+ const installedModels = useMemo(() => models.filter((model) => model.installed), [models]);
+
+ const refresh = useCallback(async () => {
+ const [nextStatus, nextModels] = await Promise.all([
+ getSpeechStatus(),
+ listSpeechModels(),
+ ]);
+ 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;
+ return installed.find((model) => model.recommended)?.filename ||
+ installed[0]?.filename ||
+ "";
+ });
+ }, []);
+
+ useEffect(() => {
+ refresh().catch((err) => showAlert?.({ title: "Speech Status Failed", message: err.message || String(err), danger: true }));
+ }, [refresh, showAlert]);
+
+ useEffect(() => {
+ if (!selectedTranscript) return;
+ setResult(selectedTranscript);
+ setResultText(formatTranscriptWithTimes(selectedTranscript));
+ }, [selectedTranscript]);
+
+ useEffect(() => {
+ return () => {
+ if (audioUrl) URL.revokeObjectURL(audioUrl);
+ abortRef.current?.abort();
+ };
+ }, [audioUrl]);
+
+ const setCurrentAudio = useCallback((blob, name) => {
+ setAudioBlob(blob);
+ setAudioName(name);
+ setResult(null);
+ setResultText("");
+ setAudioUrl((oldUrl) => {
+ if (oldUrl) URL.revokeObjectURL(oldUrl);
+ return URL.createObjectURL(blob);
+ });
+ }, []);
+
+ const clearCurrentAudio = useCallback(() => {
+ setAudioBlob(null);
+ setAudioName("");
+ setAudioUrl((oldUrl) => {
+ if (oldUrl) URL.revokeObjectURL(oldUrl);
+ return "";
+ });
+ }, []);
+
+ const handleLoadSpeechModel = async () => {
+ if (!selectedModel) return;
+ setIsLoadingModel(true);
+ try {
+ if (!status.ready || status.settings?.model !== selectedModel) {
+ await startSpeech(selectedModel, { language, threads });
+ }
+ await refresh();
+ } catch (err) {
+ showAlert?.({ title: "Speech Model Load Failed", message: err.message || String(err), danger: true });
+ } finally {
+ setIsLoadingModel(false);
+ }
+ };
+ const startRecording = async () => {
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ const audioContext = new AudioContext();
+ const source = audioContext.createMediaStreamSource(stream);
+ const processor = audioContext.createScriptProcessor(4096, 1, 1);
+ const chunks = [];
+ processor.onaudioprocess = (event) => {
+ chunks.push(new Float32Array(event.inputBuffer.getChannelData(0)));
+ };
+ source.connect(processor);
+ processor.connect(audioContext.destination);
+ recorderRef.current = { stream, audioContext, source, processor, chunks, sampleRate: audioContext.sampleRate };
+ setIsRecording(true);
+ } catch (err) {
+ showAlert?.({ title: "Microphone Failed", message: err.message || String(err), danger: true });
+ }
+ };
+
+ const stopRecording = async () => {
+ const recorder = recorderRef.current;
+ if (!recorder) return;
+ recorder.processor.disconnect();
+ recorder.source.disconnect();
+ recorder.stream.getTracks().forEach((track) => track.stop());
+ await recorder.audioContext.close();
+ recorderRef.current = null;
+ setIsRecording(false);
+ const merged = flattenFloat32(recorder.chunks);
+ const resampled = resampleLinear(merged, recorder.sampleRate, 16000);
+ const wav = encodeWavPcm16(resampled, 16000);
+ setCurrentAudio(wav, `recording-${new Date().toISOString().replace(/[:.]/g, "-")}.wav`);
+ };
+
+ const handleUploadAudio = (event) => {
+ const file = event.target.files?.[0];
+ event.target.value = "";
+ if (!file) return;
+ if (!file.name.toLowerCase().endsWith(".wav")) {
+ showAlert?.({ title: "Unsupported Audio", message: "V1 accepts WAV files only. MP3/WebM/M4A will need the later FFmpeg path.", danger: true });
+ return;
+ }
+ setCurrentAudio(file, file.name);
+ };
+
+ const handleTranscribe = async () => {
+ if (!audioBlob || !selectedModel) return;
+ setIsTranscribing(true);
+ setResult(null);
+ setResultText("");
+ const controller = new AbortController();
+ abortRef.current = controller;
+ try {
+ if (!status.ready || status.settings?.model !== selectedModel) {
+ await startSpeech(selectedModel, { language, threads });
+ }
+ const transcription = await transcribeSpeech(audioBlob, {
+ model: selectedModel,
+ language,
+ threads,
+ filename: audioName || "audio.wav",
+ signal: controller.signal,
+ });
+ setResult(transcription);
+ setResultText(formatTranscriptWithTimes(transcription));
+ await refresh();
+ await onTranscriptionsChanged?.();
+ } catch (err) {
+ if (err.name !== "AbortError") {
+ showAlert?.({ title: "Transcription Failed", message: err.message || String(err), danger: true });
+ }
+ } finally {
+ abortRef.current = null;
+ setIsTranscribing(false);
+ }
+ };
+
+ const handleCancel = () => {
+ abortRef.current?.abort();
+ setIsTranscribing(false);
+ };
+
+ const saveText = () => {
+ if (!resultText.trim()) return;
+ downloadBlob("transcription.txt", resultText, "text/plain;charset=utf-8");
+ };
+
+ const saveJson = () => {
+ const payload = { ...(result || {}), text: resultText };
+ downloadBlob("transcription.json", JSON.stringify(payload, null, 2), "application/json;charset=utf-8");
+ };
+
+ return (
+
+
+
Speech Transcriber
+
+ Record microphone audio or upload a WAV file, then transcribe locally with whisper.cpp.
+
+
+
+
+
+
+
Runtime
+
+ {status.backendInstalled ? (status.ready ? "Ready" : "Installed") : "Backend missing"}
+
+
+
+ {status.error && {status.error}
}
+
+
+ Whisper model
+ setSelectedModel(event.target.value)}>
+ {installedModels.length === 0 && No downloaded speech models }
+ {installedModels.map((model) => (
+
+ {model.name || model.filename}
+
+ ))}
+
+
+
+
+
+ Language
+ setLanguage(event.target.value)}>
+ {LANGUAGE_OPTIONS.map((item) => {item.label} )}
+
+
+
+ Threads
+ setThreads(Math.max(1, Math.min(32, Number(event.target.value) || 1)))}
+ />
+
+
+
+
+
+ {isLoadingModel ? : }
+ {status.ready && status.settings?.model === selectedModel ? "Loaded" : isLoadingModel ? "Loading" : "Load"}
+
+
+
+ Refresh
+
+
+
+ {installedModels.length === 0 && (
+
+ Download a Whisper model from Model Manager, Speech Models first.
+
+ )}
+
+
+
+
+
Audio
+ {audioName || "No audio selected"}
+
+
+
+
+
+
{audioName || "Record or upload WAV"}
+
Microphone recordings are converted to 16 kHz mono PCM16 WAV before transcription.
+
+
+
+ {audioUrl && }
+
+
+ {isRecording ? (
+
+
+ Stop Recording
+
+ ) : (
+
+
+ Record
+
+ )}
+
+
+ Upload WAV
+
+
+ {audioBlob && (
+
+
+ Remove Audio
+
+ )}
+
+
+
+ {isTranscribing ? (
+
+
+ Cancel
+
+ ) : (
+
+
+ Transcribe
+
+ )}
+ {isTranscribing && Transcribing locally... }
+
+
+
+
+
+
+
Transcript
+
+
+
+ TXT
+
+
+
+ JSON
+
+ {status.ready && (
+ stopSpeech().then(refresh)}>
+
+ Stop Runtime
+
+ )}
+
+
+
+
+ );
+}
diff --git a/app/frontend/src/services/api.js b/app/frontend/src/services/api.js
index 61f3a7c..2a9744e 100644
--- a/app/frontend/src/services/api.js
+++ b/app/frontend/src/services/api.js
@@ -570,6 +570,89 @@ export async function deleteLlmModel(filename) {
return await readJsonResponse(res, "The local server returned an invalid text model delete response.");
}
+export async function getSpeechStatus() {
+ try {
+ const res = await fetch("/api/speech/status");
+ return await readJsonResponse(res, "The local server returned invalid speech backend status.");
+ } catch (err) {
+ return { ready: false, running: false, backendInstalled: false, error: err.message, settings: {} };
+ }
+}
+
+export async function listSpeechModels() {
+ const res = await fetch("/api/speech/models");
+ const data = await readJsonResponse(res, "The local server returned invalid speech model data.");
+ return data.models || [];
+}
+
+export async function startSpeech(model, options = {}) {
+ const res = await fetch("/api/speech/start", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ model,
+ language: options.language || "auto",
+ threads: options.threads,
+ }),
+ });
+ return await readJsonResponse(res, "The local server returned an invalid speech start response.");
+}
+
+export async function stopSpeech() {
+ const res = await fetch("/api/speech/stop", { method: "POST" });
+ return await readJsonResponse(res, "The local server returned an invalid speech stop response.");
+}
+
+export async function transcribeSpeech(fileOrBlob, options = {}) {
+ const params = new URLSearchParams();
+ if (options.model) params.set("model", options.model);
+ if (options.language) params.set("language", options.language);
+ if (options.filename) params.set("filename", options.filename);
+ if (options.threads) params.set("threads", String(options.threads));
+
+ const res = await fetch(`/api/speech/transcribe?${params.toString()}`, {
+ method: "POST",
+ headers: { "Content-Type": "audio/wav" },
+ body: fileOrBlob,
+ signal: options.signal,
+ });
+ const data = await readJsonResponse(res, "The local server returned an invalid transcription response.");
+ return data.transcription;
+}
+
+export async function downloadSpeechModel(modelIdOrUrl, filename = null) {
+ const isUrl = /^https?:\/\//i.test(String(modelIdOrUrl || ""));
+ const res = await fetch("/api/speech/download-model", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ model: isUrl ? "" : modelIdOrUrl,
+ url: isUrl ? modelIdOrUrl : "",
+ filename,
+ }),
+ });
+ return await readJsonResponse(res, "The local server returned an invalid speech download response.");
+}
+
+export async function importSpeechModel(file, onProgress, signal) {
+ return await uploadModelFileToEndpoint(file, "/api/speech/import-model", onProgress, signal);
+}
+
+export async function deleteSpeechModel(filename) {
+ const res = await fetch("/api/speech/delete-model", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ filename }),
+ });
+ return await readJsonResponse(res, "The local server returned an invalid speech model delete response.");
+}
+
+export async function listSpeechTranscriptions() {
+ const res = await fetch("/api/speech/transcriptions");
+ const data = await readJsonResponse(res, "The local server returned invalid transcription history.");
+ return data.transcriptions || [];
+}
+
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 866b322..b58cd9b 100755
--- a/linux.sh
+++ b/linux.sh
@@ -133,12 +133,16 @@ LLM_ROCM_PATH="$APP_DIR/llm-backend/linux/rocm/llama-server"
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"
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
if [[ ! -x "$LLM_CUDA_PATH" && ! -x "$LLM_ROCM_PATH" && ! -x "$LLM_SYCL_PATH" && ! -x "$LLM_VULKAN_PATH" && ! -x "$LLM_CPU_PATH" ]]; then
SETUP_REASON="Linux llama.cpp text backend is missing or not executable."
fi
+if [[ ! -x "$SPEECH_BACKEND_PATH" ]]; then
+ SETUP_REASON="Linux whisper.cpp speech backend is missing or not executable."
+fi
if [[ -n "$SETUP_REASON" ]]; then
echo ""
@@ -216,6 +220,7 @@ 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: 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 7175b6c..cc75ef9 100755
--- a/mac.sh
+++ b/mac.sh
@@ -188,6 +188,7 @@ 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 ""
echo " Press Ctrl+C in this window to stop all services."
echo " ============================================================"
diff --git a/scripts/reset.ps1 b/scripts/reset.ps1
index fa1b449..725d08f 100644
--- a/scripts/reset.ps1
+++ b/scripts/reset.ps1
@@ -32,6 +32,13 @@ if (Test-Path $llmBackendDir) {
Remove-Item $llmBackendDir -Recurse -Force -ErrorAction SilentlyContinue
}
+# Delete whisper.cpp backend
+$speechBackendDir = Join-Path $appDir "speech-backend"
+if (Test-Path $speechBackendDir) {
+ Write-Host " >> Removing whisper.cpp speech backend binaries..." -ForegroundColor Cyan
+ Remove-Item $speechBackendDir -Recurse -Force -ErrorAction SilentlyContinue
+}
+
# Delete dist
$distDir = Join-Path $appDir "dist"
if (Test-Path $distDir) {
@@ -51,6 +58,18 @@ if (Test-Path $llmModelsDir) {
Write-Host " >> Preserving text models in app/llm-models." -ForegroundColor Cyan
}
+# Preserve speech models
+$speechModelsDir = Join-Path $appDir "speech-models"
+if (Test-Path $speechModelsDir) {
+ Write-Host " >> Preserving speech models in app/speech-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 OpenVINO models
$openVinoModelsDir = Join-Path $appDir "openvino-models"
if (Test-Path $openVinoModelsDir) {
@@ -82,7 +101,7 @@ if (Test-Path $lockFile) {
Write-Host ""
Write-Host " ============================================================" -ForegroundColor Green
-Write-Host " Reset complete. Image models, text models, OpenVINO models, and generated outputs were preserved." -ForegroundColor Green
+Write-Host " Reset complete. Image models, text models, speech models, transcripts, 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 59c0f9d..3262728 100755
--- a/scripts/reset.sh
+++ b/scripts/reset.sh
@@ -34,6 +34,12 @@ if [[ -d "$APP_DIR/llm-backend" ]]; then
rm -rf "$APP_DIR/llm-backend"
fi
+# Delete whisper.cpp backend
+if [[ -d "$APP_DIR/speech-backend" ]]; then
+ echo " >> Removing whisper.cpp speech backend binaries..."
+ rm -rf "$APP_DIR/speech-backend"
+fi
+
# Delete dist
if [[ -d "$APP_DIR/dist" ]]; then
echo " >> Removing dist/ build folder..."
@@ -50,6 +56,16 @@ if [[ -d "$APP_DIR/llm-models" ]]; then
echo " >> Preserving text models in app/llm-models."
fi
+# Preserve speech models
+if [[ -d "$APP_DIR/speech-models" ]]; then
+ echo " >> Preserving speech models in app/speech-models."
+fi
+
+# Preserve transcriptions
+if [[ -d "$APP_DIR/transcriptions" ]]; then
+ echo " >> Preserving speech transcripts in app/transcriptions."
+fi
+
# Preserve OpenVINO models
if [[ -d "$APP_DIR/openvino-models" ]]; then
echo " >> Preserving OpenVINO models in app/openvino-models."
@@ -76,7 +92,7 @@ fi
echo ""
echo " ============================================================"
-echo " Reset complete. Image models, text models, OpenVINO models, and generated outputs were preserved."
+echo " Reset complete. Image models, text models, speech models, transcripts, 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 867dc9f..300d274 100644
--- a/scripts/serve.cjs
+++ b/scripts/serve.cjs
@@ -30,8 +30,10 @@ function readPort(value, fallback) {
const PORT_FRONTEND = readPort(process.env.PORT || process.env.FRONTEND_PORT, 1420);
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);
let PORT_BACKEND = PREFERRED_BACKEND_PORT;
let PORT_LLM = PREFERRED_LLM_PORT;
+let PORT_SPEECH = PREFERRED_SPEECH_PORT;
const MAX_JSON_BODY_BYTES = 64 * 1024 * 1024;
const SERVER_BUILD = "text-image-v1";
const ROOT = path.join(__dirname, "..");
@@ -105,6 +107,34 @@ const LLM_BENCHMARK_PATH = path.join(LLM_CONFIG_DIR, "llm-benchmarks.json");
if (!fs.existsSync(LLM_CONFIG_DIR)) {
fs.mkdirSync(LLM_CONFIG_DIR, { recursive: true });
}
+const SPEECH_MODELS = path.join(ROOT, "app", "speech-models");
+if (!fs.existsSync(SPEECH_MODELS)) {
+ fs.mkdirSync(SPEECH_MODELS, { recursive: true });
+}
+const TRANSCRIPTIONS = path.join(ROOT, "app", "transcriptions");
+if (!fs.existsSync(TRANSCRIPTIONS)) {
+ fs.mkdirSync(TRANSCRIPTIONS, { recursive: true });
+}
+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"),
+ linuxCli: path.join(ROOT, "app", "speech-backend", "linux", "whisper-cli"),
+ linuxServer: path.join(ROOT, "app", "speech-backend", "linux", "whisper-server"),
+ macCli: path.join(ROOT, "app", "speech-backend", "mac", "whisper-cli"),
+ macServer: path.join(ROOT, "app", "speech-backend", "mac", "whisper-server"),
+};
+const SPEECH_MODEL_CATALOG = [
+ { id: "tiny.en", name: "Whisper Tiny English", filename: "ggml-tiny.en.bin", size: "75 MB", language: "English", recommended: false },
+ { id: "tiny.en-q5_1", name: "Whisper Tiny English Q5", filename: "ggml-tiny.en-q5_1.bin", size: "31 MB", language: "English", recommended: false },
+ { id: "base.en", name: "Whisper Base English", filename: "ggml-base.en.bin", size: "142 MB", language: "English", recommended: true },
+ { id: "base.en-q5_1", name: "Whisper Base English Q5", filename: "ggml-base.en-q5_1.bin", size: "57 MB", language: "English", recommended: true },
+ { id: "small.en", name: "Whisper Small English", filename: "ggml-small.en.bin", size: "466 MB", language: "English", recommended: false },
+ { id: "small.en-q5_1", name: "Whisper Small English Q5", filename: "ggml-small.en-q5_1.bin", size: "181 MB", language: "English", recommended: false },
+ { id: "tiny", name: "Whisper Tiny Multilingual", filename: "ggml-tiny.bin", size: "75 MB", language: "Multilingual", recommended: false },
+ { id: "base", name: "Whisper Base Multilingual", filename: "ggml-base.bin", size: "142 MB", language: "Multilingual", recommended: false },
+ { 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 OPENVINO_MODELS = path.join(ROOT, "app", "openvino-models");
if (!fs.existsSync(OPENVINO_MODELS)) {
fs.mkdirSync(OPENVINO_MODELS, { recursive: true });
@@ -142,6 +172,23 @@ let llmProcSeq = 0;
let llmReady = false;
let llmError = null;
let llmOperationQueue = Promise.resolve();
+let speechReady = false;
+let speechError = null;
+let speechOperationQueue = Promise.resolve();
+let speechSettings = {
+ model: null,
+ language: "auto",
+ threads: Math.max(1, Math.min(8, os.cpus().length || 4)),
+ backendBinary: "",
+ backendMode: "",
+};
+let speechTranscriptionState = {
+ active: false,
+ phase: "",
+ progress: 0,
+ model: "",
+ filename: "",
+};
let llmSettings = {
model: null,
threads: Math.max(1, Math.min(16, os.cpus().length || 4)),
@@ -1396,6 +1443,21 @@ async function findAvailableLlmPort() {
throw new Error(`No free LLM port found. Tried ${PREFERRED_LLM_PORT} and 28121-28160.`);
}
+async function findAvailableSpeechPort() {
+ const preferred = await checkPort(PREFERRED_SPEECH_PORT);
+ if (preferred.available) return PREFERRED_SPEECH_PORT;
+
+ for (let port = 28161; port <= 28190; port += 1) {
+ const candidate = await checkPort(port);
+ if (candidate.available) {
+ console.log(` [speech] Preferred port ${PREFERRED_SPEECH_PORT} is busy; using ${port} instead.`);
+ return port;
+ }
+ }
+
+ throw new Error(`No free speech port found. Tried ${PREFERRED_SPEECH_PORT} and 28161-28190.`);
+}
+
function getLlmBackend() {
return getLlmBackendCandidates()[0] || null;
}
@@ -1537,6 +1599,283 @@ function getLlmModels() {
}
}
+function findExistingFile(candidates) {
+ return candidates.find((candidate) => candidate && fs.existsSync(candidate)) || "";
+}
+
+function getSpeechBackend() {
+ if (osPlatform === "win32") {
+ return {
+ cli: findExistingFile([SPEECH_BACKEND_PATHS.winCli, path.join(ROOT, "app", "speech-backend", "win", "main.exe")]),
+ server: findExistingFile([SPEECH_BACKEND_PATHS.winServer, path.join(ROOT, "app", "speech-backend", "win", "server.exe")]),
+ mode: "whisper.cpp CPU",
+ };
+ }
+ if (osPlatform === "darwin") {
+ return {
+ cli: findExistingFile([SPEECH_BACKEND_PATHS.macCli, path.join(ROOT, "app", "speech-backend", "mac", "main")]),
+ server: findExistingFile([SPEECH_BACKEND_PATHS.macServer, path.join(ROOT, "app", "speech-backend", "mac", "server")]),
+ mode: "whisper.cpp Metal/CPU",
+ };
+ }
+ return {
+ cli: findExistingFile([SPEECH_BACKEND_PATHS.linuxCli, path.join(ROOT, "app", "speech-backend", "linux", "main")]),
+ server: findExistingFile([SPEECH_BACKEND_PATHS.linuxServer, path.join(ROOT, "app", "speech-backend", "linux", "server")]),
+ mode: "whisper.cpp CPU",
+ };
+}
+
+function getSpeechModels() {
+ const catalog = SPEECH_MODEL_CATALOG.map((model) => {
+ const modelPath = path.join(SPEECH_MODELS, model.filename);
+ const installed = fs.existsSync(modelPath);
+ return {
+ ...model,
+ installed,
+ sizeBytes: installed ? getPathSize(modelPath) : 0,
+ localSize: installed ? formatBytes(getPathSize(modelPath)) : "",
+ url: `https://huggingface.co/ggerganov/whisper.cpp/resolve/main/${model.filename}`,
+ };
+ });
+
+ const known = new Set(catalog.map((model) => model.filename.toLowerCase()));
+ let custom = [];
+ try {
+ custom = fs.readdirSync(SPEECH_MODELS)
+ .filter((filename) => filename.toLowerCase().endsWith(".bin") && !known.has(filename.toLowerCase()))
+ .map((filename) => {
+ const sizeBytes = getPathSize(path.join(SPEECH_MODELS, filename));
+ return {
+ id: filename,
+ name: filename,
+ filename,
+ size: formatBytes(sizeBytes),
+ localSize: formatBytes(sizeBytes),
+ sizeBytes,
+ language: "Custom",
+ installed: true,
+ recommended: false,
+ custom: true,
+ };
+ });
+ } catch (_) {}
+ return [...catalog, ...custom];
+}
+
+function resolveSpeechModel(value) {
+ const raw = String(value || "").trim();
+ const model = getSpeechModels().find((item) => item.id === raw || item.filename === raw) ||
+ getSpeechModels().find((item) => item.installed);
+ if (!model) throw new Error("Download or import a Whisper model first.");
+ const modelPath = path.join(SPEECH_MODELS, path.basename(model.filename));
+ if (!pathInside(modelPath, SPEECH_MODELS) || !fs.existsSync(modelPath)) {
+ throw new Error(`Speech model is not installed: ${model.filename}`);
+ }
+ return { ...model, path: modelPath };
+}
+
+function runExclusiveSpeechOperation(operation) {
+ const run = speechOperationQueue.catch(() => {}).then(operation);
+ speechOperationQueue = run.catch(() => {});
+ return run;
+}
+
+async function startSpeech(settings = {}) {
+ const backend = getSpeechBackend();
+ if (!backend.cli) {
+ throw new Error("whisper.cpp is not installed. Run the platform setup script to install the speech backend.");
+ }
+ const model = resolveSpeechModel(settings.model);
+ await killBackend();
+ await killOpenVinoWorker();
+ await killLlm();
+ PORT_SPEECH = await findAvailableSpeechPort();
+ speechError = null;
+ speechReady = true;
+ speechSettings = {
+ ...speechSettings,
+ model: model.filename,
+ language: settings.language || speechSettings.language || "auto",
+ threads: Math.max(1, Math.min(32, Number(settings.threads) || speechSettings.threads || 4)),
+ backendBinary: path.basename(backend.cli),
+ backendMode: backend.mode,
+ };
+}
+
+async function stopSpeech() {
+ speechReady = false;
+ speechError = null;
+ speechTranscriptionState = { active: false, phase: "", progress: 0, model: "", filename: "" };
+}
+
+function readBinaryBody(req, limitBytes = 250 * 1024 * 1024) {
+ return new Promise((resolve, reject) => {
+ const chunks = [];
+ let bytes = 0;
+ req.on("data", (chunk) => {
+ bytes += chunk.length;
+ if (bytes > limitBytes) {
+ reject(new Error(`Audio file is too large. Limit is ${Math.round(limitBytes / (1024 * 1024))} MB.`));
+ req.destroy();
+ return;
+ }
+ chunks.push(chunk);
+ });
+ req.on("end", () => resolve(Buffer.concat(chunks)));
+ req.on("error", reject);
+ });
+}
+
+function isWaveBuffer(buffer) {
+ return buffer?.length > 44 &&
+ buffer.toString("ascii", 0, 4) === "RIFF" &&
+ buffer.toString("ascii", 8, 12) === "WAVE";
+}
+
+function saveTranscript(result) {
+ const createdAt = new Date().toISOString();
+ const stamp = createdAt.replace(/[:.]/g, "-");
+ const base = `transcript-${stamp}-${safeOutputName(result.sourceFilename || "audio")}`;
+ const textFilename = `${base}.txt`;
+ const jsonFilename = `${base}.json`;
+ fs.writeFileSync(path.join(TRANSCRIPTIONS, textFilename), result.text || "", "utf8");
+ const metadata = {
+ ...result,
+ createdAt,
+ textFile: textFilename,
+ metadata: jsonFilename,
+ };
+ fs.writeFileSync(path.join(TRANSCRIPTIONS, jsonFilename), JSON.stringify(metadata, null, 2), "utf8");
+ return metadata;
+}
+
+function listTranscriptions() {
+ try {
+ return fs.readdirSync(TRANSCRIPTIONS)
+ .filter((file) => file.toLowerCase().endsWith(".json"))
+ .map((file) => {
+ const filePath = path.join(TRANSCRIPTIONS, file);
+ try {
+ const metadata = JSON.parse(fs.readFileSync(filePath, "utf8"));
+ const stat = fs.statSync(filePath);
+ return {
+ ...metadata,
+ filename: file,
+ displayName: metadata.sourceFilename || metadata.textFile || file,
+ sizeBytes: stat.size,
+ size: formatBytes(stat.size),
+ modifiedAt: stat.mtime.toISOString(),
+ createdAt: metadata.createdAt || stat.mtime.toISOString(),
+ url: `/transcriptions/${encodeURIComponent(file)}`,
+ textUrl: metadata.textFile ? `/transcriptions/${encodeURIComponent(metadata.textFile)}` : "",
+ };
+ } catch (_) {
+ return null;
+ }
+ })
+ .filter(Boolean)
+ .sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || "")));
+ } catch (_) {
+ return [];
+ }
+}
+
+function transcribeWavBuffer(buffer, options = {}) {
+ return new Promise((resolve, reject) => {
+ if (!isWaveBuffer(buffer)) {
+ reject(new Error("Speech transcription currently accepts WAV audio only."));
+ return;
+ }
+ const backend = getSpeechBackend();
+ if (!backend.cli) {
+ reject(new Error("whisper.cpp CLI backend is not installed."));
+ return;
+ }
+ const model = resolveSpeechModel(options.model || speechSettings.model);
+ const language = String(options.language || speechSettings.language || "auto");
+ const sourceFilename = safeOutputName(options.filename || "recording.wav") || "recording.wav";
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
+ const tempBase = path.join(TRANSCRIPTIONS, `.speech-${stamp}-${sourceFilename.replace(/\.[^.]+$/, "")}`);
+ const wavPath = `${tempBase}.wav`;
+ const outBase = `${tempBase}-out`;
+ fs.writeFileSync(wavPath, buffer);
+
+ speechTranscriptionState = {
+ active: true,
+ phase: "Transcribing audio...",
+ progress: -1,
+ model: model.filename,
+ filename: sourceFilename,
+ };
+
+ const args = [
+ "-m", model.path,
+ "-f", wavPath,
+ "-otxt",
+ "-oj",
+ "-of", outBase,
+ "-t", String(Math.max(1, Math.min(32, Number(options.threads) || speechSettings.threads || 4))),
+ ];
+ if (language && language !== "auto") {
+ args.push("-l", language);
+ } else {
+ args.push("-l", "auto");
+ }
+
+ const startedAt = Date.now();
+ console.log(" [speech] Starting:", backend.cli, args.join(" "));
+ const proc = spawn(backend.cli, args, {
+ stdio: "pipe",
+ windowsHide: true,
+ env: {
+ ...process.env,
+ ...(osPlatform === "linux" ? { LD_LIBRARY_PATH: path.dirname(backend.cli) + (process.env.LD_LIBRARY_PATH ? `:${process.env.LD_LIBRARY_PATH}` : "") } : {}),
+ ...(osPlatform === "darwin" ? { DYLD_LIBRARY_PATH: path.dirname(backend.cli) + (process.env.DYLD_LIBRARY_PATH ? `:${process.env.DYLD_LIBRARY_PATH}` : "") } : {}),
+ },
+ });
+ let stdout = "";
+ let stderr = "";
+ proc.stdout.on("data", (data) => {
+ stdout += data.toString();
+ process.stdout.write(" [speech] " + data.toString());
+ });
+ proc.stderr.on("data", (data) => {
+ stderr += data.toString();
+ process.stderr.write(" [speech-err] " + data.toString());
+ });
+ proc.on("exit", (code) => {
+ try { fs.unlinkSync(wavPath); } catch (_) {}
+ speechTranscriptionState = { active: false, phase: code === 0 ? "Complete" : "Failed", progress: code === 0 ? 100 : 0, model: model.filename, filename: sourceFilename };
+ if (code !== 0) {
+ const message = (stderr || stdout || `whisper.cpp exited with code ${code}`).trim().slice(-1200);
+ speechError = message;
+ reject(new Error(message));
+ return;
+ }
+ const txtPath = `${outBase}.txt`;
+ const jsonPath = `${outBase}.json`;
+ const text = fs.existsSync(txtPath) ? fs.readFileSync(txtPath, "utf8").trim() : stdout.trim();
+ let raw = null;
+ try {
+ raw = fs.existsSync(jsonPath) ? JSON.parse(fs.readFileSync(jsonPath, "utf8")) : null;
+ } catch (_) {}
+ try { fs.unlinkSync(txtPath); } catch (_) {}
+ try { fs.unlinkSync(jsonPath); } catch (_) {}
+ const saved = saveTranscript({
+ text,
+ model: model.filename,
+ modelName: model.name,
+ language,
+ sourceFilename,
+ durationMs: Date.now() - startedAt,
+ raw,
+ });
+ speechError = null;
+ resolve(saved);
+ });
+ });
+}
+
function pingLlmReady(expectedModel = "") {
return new Promise((resolve) => {
const req = http.get(`http://127.0.0.1:${PORT_LLM}/v1/models`, (res) => {
@@ -3730,7 +4069,7 @@ function cancelModelDownload() {
try { fs.unlinkSync(activeDownload.tempPath || activeDownload.destPath); } catch (_) {}
}
} else if (filename) {
- const targetDir = downloadState.kind === "text" ? LLM_MODELS : MODELS;
+ const targetDir = downloadState.kind === "text" ? LLM_MODELS : downloadState.kind === "speech" ? SPEECH_MODELS : MODELS;
try { fs.unlinkSync(`${path.join(targetDir, filename)}.part`); } catch (_) {}
}
activeDownload = null;
@@ -3754,7 +4093,7 @@ const MIME = {
".html": "text/html", ".js": "application/javascript",
".css": "text/css", ".png": "image/png",
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".svg": "image/svg+xml",
- ".ico": "image/x-icon", ".json": "application/json",
+ ".ico": "image/x-icon", ".json": "application/json", ".wav": "audio/wav", ".txt": "text/plain",
".woff2":"font/woff2", ".woff": "font/woff", ".ttf": "font/ttf",
};
@@ -4047,12 +4386,17 @@ function deleteGeneratedOutputs(outputs = []) {
return deleted;
}
-function streamModelUpload(req, filename, targetDir = MODELS, textOnly = false) {
+function streamModelUpload(req, filename, targetDir = MODELS, mode = "image") {
return new Promise((resolve, reject) => {
const safeFilename = path.basename(filename || "");
const lowerName = safeFilename.toLowerCase();
- if (!safeFilename || (textOnly ? !lowerName.endsWith(".gguf") : !isModelFile(lowerName))) {
- reject(new Error(textOnly ? "Filename must end with .gguf" : "Filename must end with .gguf, .safetensors, or .ckpt"));
+ const valid = mode === "text"
+ ? lowerName.endsWith(".gguf")
+ : mode === "speech"
+ ? lowerName.endsWith(".bin")
+ : 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"));
return;
}
@@ -4090,9 +4434,9 @@ function streamModelUpload(req, filename, targetDir = MODELS, textOnly = false)
finished = true;
fs.renameSync(tempPath, destPath);
console.log(` [api] Imported model file: ${safeFilename}`);
- if (textOnly) {
+ if (mode === "text" || mode === "speech") {
const stats = fs.statSync(destPath);
- resolve({ filename: safeFilename, name: safeFilename, sizeBytes: stats.size, size: formatBytes(stats.size), format: "GGUF" });
+ resolve({ filename: safeFilename, name: safeFilename, sizeBytes: stats.size, size: formatBytes(stats.size), format: mode === "speech" ? "Whisper GGML" : "GGUF" });
} else {
resolve(getModelInfo(safeFilename));
}
@@ -4139,6 +4483,13 @@ const server = http.createServer(async (req, res) => {
settings: llmSettings,
port: PORT_LLM,
},
+ speech: {
+ ready: speechReady,
+ error: speechError,
+ settings: speechSettings,
+ backend: getSpeechBackend(),
+ transcription: speechTranscriptionState,
+ },
hardware: getHardwareSpecs(),
telemetry: getTelemetry(),
});
@@ -4278,6 +4629,109 @@ const server = http.createServer(async (req, res) => {
}
}
+ if (req.url === "/api/speech/status" && req.method === "GET") {
+ const backend = getSpeechBackend();
+ return json(res, 200, {
+ ok: true,
+ ready: speechReady,
+ running: speechReady,
+ port: PORT_SPEECH,
+ preferredPort: PREFERRED_SPEECH_PORT,
+ backendInstalled: Boolean(backend.cli),
+ backendPath: backend.cli || "",
+ serverPath: backend.server || "",
+ backendMode: backend.mode,
+ error: speechError,
+ settings: speechSettings,
+ transcription: speechTranscriptionState,
+ });
+ }
+
+ if (req.url === "/api/speech/models" && req.method === "GET") {
+ return json(res, 200, { ok: true, models: getSpeechModels() });
+ }
+
+ if (req.url === "/api/speech/start" && req.method === "POST") {
+ const body = await readJsonBody(req, res);
+ if (!body) return;
+ try {
+ await runExclusiveSpeechOperation(() => startSpeech(body));
+ return json(res, 200, { ok: true, ready: speechReady, settings: speechSettings, port: PORT_SPEECH });
+ } catch (err) {
+ speechError = err.message || String(err);
+ return json(res, 500, { ok: false, error: speechError });
+ }
+ }
+
+ if (req.url === "/api/speech/stop" && req.method === "POST") {
+ await runExclusiveSpeechOperation(() => stopSpeech());
+ return json(res, 200, { ok: true });
+ }
+
+ if (req.url.startsWith("/api/speech/transcribe") && req.method === "POST") {
+ const parsed = new URL(req.url, `http://${req.headers.host || "localhost"}`);
+ try {
+ const audio = await readBinaryBody(req);
+ const result = await runExclusiveSpeechOperation(() => transcribeWavBuffer(audio, {
+ model: parsed.searchParams.get("model") || speechSettings.model,
+ language: parsed.searchParams.get("language") || "auto",
+ filename: parsed.searchParams.get("filename") || req.headers["x-filename"] || "recording.wav",
+ threads: parsed.searchParams.get("threads") || speechSettings.threads,
+ }));
+ return json(res, 200, { ok: true, transcription: result });
+ } catch (err) {
+ speechError = err.message || String(err);
+ return json(res, err.message?.includes("too large") ? 413 : 500, { ok: false, error: speechError });
+ }
+ }
+
+ if (req.url === "/api/speech/download-model" && req.method === "POST") {
+ const body = await readJsonBody(req, res);
+ if (!body) return;
+ if (downloadState.active) return json(res, 409, { ok: false, error: "Another model download is already active." });
+ const modelId = String(body.modelId || body.model_id || body.model || "");
+ const catalogModel = SPEECH_MODEL_CATALOG.find((model) => model.id === modelId || model.filename === modelId);
+ const url = body.url ? String(body.url) : (catalogModel ? `https://huggingface.co/ggerganov/whisper.cpp/resolve/main/${catalogModel.filename}` : "");
+ const filename = path.basename(String(body.filename || catalogModel?.filename || ""));
+ if (!url || !filename || !filename.toLowerCase().endsWith(".bin")) {
+ return json(res, 400, { ok: false, error: "A speech model .bin URL or catalog model id is required." });
+ }
+ startModelDownload(url, filename, SPEECH_MODELS, "speech");
+ return json(res, 200, { ok: true, message: "Speech model download started", filename });
+ }
+
+ if (req.url.startsWith("/api/speech/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"), SPEECH_MODELS, "speech");
+ return json(res, 200, { ok: true, message: `Imported ${result.filename}`, model: result });
+ } catch (err) {
+ console.error(" [api] Failed to import speech model:", err);
+ return json(res, 500, { ok: false, error: err.message });
+ }
+ }
+
+ if (req.url === "/api/speech/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(SPEECH_MODELS, filename);
+ if (!filename || !pathInside(modelPath, SPEECH_MODELS)) {
+ return json(res, 400, { ok: false, error: "Invalid filename" });
+ }
+ if (speechSettings.model === filename) await stopSpeech();
+ 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" ? "Speech model not found" : err.message });
+ }
+ }
+
+ if (req.url === "/api/speech/transcriptions" && req.method === "GET") {
+ return json(res, 200, { ok: true, transcriptions: listTranscriptions() });
+ }
+
function isOomError(msg) {
if (!msg) return false;
const lower = String(msg).toLowerCase();
@@ -4855,7 +5309,7 @@ async function getLlmfitRecommendations(useCase = "chat", limit = 10) {
if (req.url.startsWith("/api/llm/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"), LLM_MODELS, true);
+ const result = await streamModelUpload(req, parsed.searchParams.get("filename"), LLM_MODELS, "text");
return json(res, 200, { ok: true, message: `Imported ${result.filename}`, model: result });
} catch (err) {
console.error(" [api] Failed to import text model:", err);
@@ -4942,6 +5396,7 @@ server.listen(PORT_FRONTEND, "0.0.0.0", () => {
console.log(" Frontend : http://localhost:" + PORT_FRONTEND);
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(" ============================================================");
console.log("");
@@ -4950,5 +5405,5 @@ server.listen(PORT_FRONTEND, "0.0.0.0", () => {
});
// Graceful shutdown
-process.on("SIGINT", async () => { await killBackend(); await killOpenVinoWorker(); await killLlm(); process.exit(0); });
-process.on("SIGTERM", async () => { await killBackend(); await killOpenVinoWorker(); await killLlm(); process.exit(0); });
+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); });
diff --git a/scripts/setup-whisper.ps1 b/scripts/setup-whisper.ps1
new file mode 100644
index 0000000..72a84b7
--- /dev/null
+++ b/scripts/setup-whisper.ps1
@@ -0,0 +1,83 @@
+param([string]$Release = "v1.9.1")
+
+$ErrorActionPreference = "Stop"
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
+$rootDir = Split-Path -Parent $scriptDir
+$appDir = Join-Path $rootDir "app"
+$toolsDir = Join-Path $appDir "tools"
+$speechRoot = Join-Path $appDir "speech-backend\win"
+
+function Enable-Tls12 {
+ try {
+ $tls12 = [Enum]::ToObject([Net.SecurityProtocolType], 3072)
+ [Net.ServicePointManager]::SecurityProtocol =
+ [Net.ServicePointManager]::SecurityProtocol -bor $tls12
+ } catch {
+ throw "TLS 1.2 could not be enabled: $($_.Exception.Message)"
+ }
+}
+
+function Download-File {
+ param([string]$Url, [string]$DestPath, [string]$Label)
+ Write-Host " >> Downloading $Label..."
+ Enable-Tls12
+ $client = New-Object System.Net.WebClient
+ $client.Headers.Add("User-Agent", "Local-AI-Image-Generator")
+ try {
+ $client.DownloadFile($Url, $DestPath)
+ } finally {
+ $client.Dispose()
+ }
+}
+
+function Find-FirstFile {
+ param([string]$Root, [string[]]$Names)
+ foreach ($name in $Names) {
+ $found = Get-ChildItem $Root -Recurse -File -ErrorAction SilentlyContinue |
+ Where-Object { $_.Name -ieq $name } |
+ Select-Object -First 1
+ if ($found) { return $found.FullName }
+ }
+ return $null
+}
+
+New-Item -ItemType Directory -Force -Path $toolsDir, $speechRoot | Out-Null
+
+$cliPath = Join-Path $speechRoot "whisper-cli.exe"
+if (Test-Path $cliPath) {
+ Write-Host " OK whisper.cpp speech backend already ready."
+ exit 0
+}
+
+$asset = "whisper-bin-x64.zip"
+$archive = Join-Path $toolsDir $asset
+$extract = Join-Path $toolsDir "whisper-extract"
+$url = "https://github.com/ggml-org/whisper.cpp/releases/download/$Release/$asset"
+
+Remove-Item $archive, $extract -Recurse -Force -ErrorAction SilentlyContinue
+Download-File -Url $url -DestPath $archive -Label "whisper.cpp speech backend ($Release)"
+
+Write-Host " >> Extracting whisper.cpp speech backend..."
+Expand-Archive -Path $archive -DestinationPath $extract -Force
+
+$cli = Find-FirstFile -Root $extract -Names @("whisper-cli.exe", "main.exe")
+$server = Find-FirstFile -Root $extract -Names @("whisper-server.exe", "server.exe")
+if (-not $cli) {
+ throw "whisper-cli.exe was not found after extracting $asset"
+}
+
+Get-ChildItem $extract -Recurse -File | ForEach-Object {
+ Copy-Item $_.FullName (Join-Path $speechRoot $_.Name) -Force
+}
+if ((Split-Path -Leaf $cli) -ine "whisper-cli.exe") {
+ Copy-Item $cli $cliPath -Force
+}
+if ($server -and (Split-Path -Leaf $server) -ine "whisper-server.exe") {
+ Copy-Item $server (Join-Path $speechRoot "whisper-server.exe") -Force
+}
+
+Remove-Item $archive, $extract -Recurse -Force -ErrorAction SilentlyContinue
+if (-not (Test-Path $cliPath)) {
+ throw "whisper-cli.exe was not installed."
+}
+Write-Host " OK whisper.cpp speech backend installed."
diff --git a/scripts/setup-whisper.sh b/scripts/setup-whisper.sh
new file mode 100644
index 0000000..59d43df
--- /dev/null
+++ b/scripts/setup-whisper.sh
@@ -0,0 +1,64 @@
+#!/usr/bin/env bash
+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"
+RELEASE="${WHISPER_RELEASE:-v1.9.1}"
+PLATFORM="$(uname -s)"
+ARCH="$(uname -m)"
+
+download_and_extract() {
+ local asset="$1"
+ local dest="$2"
+ local archive="$TOOLS_DIR/$asset"
+ local url="https://github.com/ggml-org/whisper.cpp/releases/download/$RELEASE/$asset"
+
+ if [[ -x "$dest/whisper-cli" ]]; then
+ echo " OK whisper.cpp speech backend already ready: $dest"
+ return 0
+ fi
+
+ mkdir -p "$TOOLS_DIR" "$dest"
+ rm -f "$archive" "$archive.part"
+ echo " >> Downloading $asset"
+ curl -fSL --progress-bar "$url" -o "$archive.part"
+ mv "$archive.part" "$archive"
+ tar -xzf "$archive" -C "$dest" --strip-components=1
+ rm -f "$archive"
+ chmod +x "$dest"/whisper-* "$dest"/main "$dest"/server 2>/dev/null || true
+
+ if [[ ! -x "$dest/whisper-cli" && -x "$dest/main" ]]; then
+ cp "$dest/main" "$dest/whisper-cli"
+ chmod +x "$dest/whisper-cli"
+ fi
+ if [[ ! -x "$dest/whisper-server" && -x "$dest/server" ]]; then
+ cp "$dest/server" "$dest/whisper-server"
+ chmod +x "$dest/whisper-server"
+ fi
+
+ if [[ ! -x "$dest/whisper-cli" ]]; then
+ echo " XX whisper-cli was not found after extracting $asset" >&2
+ return 1
+ fi
+}
+
+if [[ "$PLATFORM" == "Linux" ]]; then
+ if [[ "$ARCH" == "aarch64" || "$ARCH" == "arm64" ]]; then
+ download_and_extract "whisper-bin-ubuntu-arm64.tar.gz" "$APP_DIR/speech-backend/linux"
+ else
+ download_and_extract "whisper-bin-ubuntu-x64.tar.gz" "$APP_DIR/speech-backend/linux"
+ fi
+elif [[ "$PLATFORM" == "Darwin" ]]; then
+ mkdir -p "$APP_DIR/speech-backend/mac"
+ if [[ -x "$APP_DIR/speech-backend/mac/whisper-cli" ]]; then
+ echo " OK whisper.cpp macOS speech backend already ready."
+ else
+ echo " !! No official portable macOS whisper.cpp CLI archive is available for this setup script yet."
+ echo " Build whisper.cpp manually and copy whisper-cli to app/speech-backend/mac/whisper-cli."
+ fi
+else
+ echo "Unsupported platform: $PLATFORM" >&2
+ exit 1
+fi
diff --git a/scripts/setup.ps1 b/scripts/setup.ps1
index 2d871d5..5eacced 100644
--- a/scripts/setup.ps1
+++ b/scripts/setup.ps1
@@ -158,7 +158,7 @@ function Expand-WithProgress {
# ══════════════════════════════════════════════════════════════════════════════
Print-Header
-$steps = 5
+$steps = 6
# ── Step 1: Portable Node.js ──────────────────────────────────────────────────
Print-Step 1 $steps "Setting up portable Node.js (app/tools/node-win/)"
@@ -427,7 +427,14 @@ if (-not $?) {
Read-Host; exit 1
}
-Print-Step 4 $steps "Installing frontend dependencies (app/frontend/)"
+Print-Step 4 $steps "Setting up whisper.cpp speech backend"
+& (Join-Path $scriptDir "setup-whisper.ps1")
+if (-not $?) {
+ Print-Fail "whisper.cpp setup failed."
+ Read-Host; exit 1
+}
+
+Print-Step 5 $steps "Installing frontend dependencies (app/frontend/)"
Write-Host ""
if (-not (Test-Path $npmCmd)) {
@@ -481,7 +488,7 @@ try {
Print-OK "Dependencies installed!"
# ── Step 4: Build frontend ────────────────────────────────────────────────
- Print-Step 5 $steps "Building frontend -> app/dist/"
+ Print-Step 6 $steps "Building frontend -> app/dist/"
Write-Host ""
& $npmCmd run build 2>&1
diff --git a/scripts/setup.sh b/scripts/setup.sh
index 1f76668..54411c2 100755
--- a/scripts/setup.sh
+++ b/scripts/setup.sh
@@ -258,7 +258,7 @@ if [[ "$PLATFORM" == "Linux" ]]; then
check_glibc
fi
-TOTAL_STEPS=5
+TOTAL_STEPS=6
# ── Step 1: Portable Node.js ────────────────────────────────────────────────
print_step 1 $TOTAL_STEPS "Setting up portable Node.js ($NODE_DIR/)"
@@ -544,7 +544,10 @@ fi
print_step 3 $TOTAL_STEPS "Setting up llama.cpp text backend"
bash "$SCRIPT_DIR/setup-llama.sh"
-print_step 4 $TOTAL_STEPS "Installing frontend dependencies (app/frontend/)"
+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/)"
if [[ ! -x "$NPM_BIN" ]]; then
print_fail "Portable npm was not found at $NPM_BIN"
@@ -642,7 +645,7 @@ else
fi
# ── Step 4: Build frontend ──────────────────────────────────────────────────
-print_step 5 $TOTAL_STEPS "Building frontend -> app/dist/"
+print_step 6 $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/windows.bat b/windows.bat
index b63d6fb..0467ed5 100644
--- a/windows.bat
+++ b/windows.bat
@@ -14,6 +14,7 @@ set LLM_HIP_BACKEND=%APP%\llm-backend\win\hip\llama-server.exe
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 SERVE=%~dp0scripts\serve.cjs
if "%FRONTEND_PORT%"=="" set FRONTEND_PORT=1420
if "%LLM_PORT%"=="" set LLM_PORT=10086
@@ -38,6 +39,10 @@ if not exist "%LLM_CUDA_BACKEND%" if not exist "%LLM_HIP_BACKEND%" if not exist
set SETUP_REASON=llama.cpp text backend is missing.
goto :run_setup
)
+if not exist "%SPEECH_BACKEND%" (
+ set SETUP_REASON=whisper.cpp speech backend 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.
@@ -101,6 +106,7 @@ 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: Managed locally by the app
echo.
echo Press Ctrl+C in this window to stop all services.
echo ============================================================