diff --git a/.gitignore b/.gitignore index 54e4ee9..f3e3a54 100644 --- a/.gitignore +++ b/.gitignore @@ -2,16 +2,19 @@ app/models/ app/openvino-models/ app/llm-models/ +app/speech-models/ # Downloaded portable runtimes/backends app/backend/ app/llm-backend/ +app/speech-backend/ app/tools/ # Generated files app/config/ app/dist/ app/outputs/ +app/transcriptions/ # Dependencies and platform-specific copies **/node_modules/ diff --git a/app/frontend/src/App.css b/app/frontend/src/App.css index 96f9fb8..b5c1fec 100644 --- a/app/frontend/src/App.css +++ b/app/frontend/src/App.css @@ -1303,6 +1303,136 @@ body { opacity: 0.8; } +.speech-workspace { + display: flex; + flex-direction: column; + gap: 18px; +} + +.speech-grid { + display: grid; + grid-template-columns: minmax(280px, 0.9fr) minmax(320px, 1.1fr); + gap: 18px; + align-items: stretch; +} + +.speech-panel, +.speech-result-panel { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px; +} + +.speech-panel-header, +.speech-button-row, +.speech-controls-row { + display: flex; + align-items: center; + gap: 10px; +} + +.speech-panel-header { + justify-content: space-between; +} + +.speech-panel-header h3 { + margin: 0; + font-size: 1rem; +} + +.speech-button-row { + flex-wrap: wrap; +} + +.speech-controls-row { + align-items: flex-end; +} + +.speech-label { + display: flex; + flex: 1; + min-width: 0; + flex-direction: column; + gap: 6px; + color: var(--md-sys-color-on-surface-variant); + font-size: 0.82rem; + font-weight: 600; +} + +.speech-starter-list { + display: grid; + grid-template-columns: 1fr; + gap: 8px; +} + +.speech-capture-zone { + display: flex; + align-items: center; + gap: 14px; + min-height: 120px; + border: 1px dashed var(--md-sys-color-outline-variant); + border-radius: var(--md-shape-corner-medium); + padding: 18px; + background: var(--md-sys-color-surface-container-low); +} + +.speech-capture-zone p { + margin: 4px 0 0 0; + color: var(--md-sys-color-on-surface-variant); + font-size: 0.86rem; + line-height: 1.4; +} + +.m3-textarea.speech-result-textarea { + min-height: 420px; + height: clamp(420px, 46vh, 720px); + resize: vertical; + line-height: 1.55; + font-size: 0.98rem; +} + +.speech-history-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.speech-history-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border: 1px solid var(--md-sys-color-outline-variant); + border-radius: var(--md-shape-corner-small); +} + +.speech-history-item div { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.speech-history-item strong, +.speech-history-item span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.speech-history-item span { + color: var(--md-sys-color-on-surface-variant); + font-size: 0.78rem; +} + +@media (max-width: 980px) { + .speech-grid { + grid-template-columns: 1fr; + } +} + /* Glassmorphic UI Cards */ .m3-card { background-color: var(--md-sys-color-surface); diff --git a/app/frontend/src/App.jsx b/app/frontend/src/App.jsx index 0449505..11412fe 100644 --- a/app/frontend/src/App.jsx +++ b/app/frontend/src/App.jsx @@ -5,7 +5,8 @@ import Generator from "./components/Generator"; import ModelManager from "./components/ModelManager"; import Settings from "./components/Settings"; import TextChat from "./components/TextChat"; -import { cleanupCandidates, formatBytes, getCleanupCandidates, getDiagnostics, getHardwareSpecs, getHealth, getTelemetry, getBackendOptions, getBackendStatus, listGeneratedOutputs, stopServer } from "./services/api"; +import SpeechTranscriber from "./components/SpeechTranscriber"; +import { cleanupCandidates, formatBytes, getCleanupCandidates, getDiagnostics, getHardwareSpecs, getHealth, getTelemetry, getBackendOptions, getBackendStatus, listGeneratedOutputs, listSpeechTranscriptions, stopServer } from "./services/api"; import "./App.css"; function App() { @@ -163,6 +164,9 @@ function App() { const [conversations, setConversations] = useState([]); const [activeConversationId, setActiveConversationId] = useState(null); const [showHistory, setShowHistory] = useState(false); // Default hide + const [speechTranscriptions, setSpeechTranscriptions] = useState([]); + const [selectedSpeechTranscript, setSelectedSpeechTranscript] = useState(null); + const [showSpeechHistory, setShowSpeechHistory] = useState(false); useEffect(() => { const saved = localStorage.getItem("chat_conversations"); @@ -243,6 +247,20 @@ function App() { } }, [activeConversationId]); + const refreshSpeechTranscriptions = useCallback(async () => { + try { + const list = await listSpeechTranscriptions(); + setSpeechTranscriptions(list); + } catch (err) { + console.warn("Could not load speech transcriptions:", err); + setSpeechTranscriptions([]); + } + }, []); + + useEffect(() => { + refreshSpeechTranscriptions(); + }, [refreshSpeechTranscriptions]); + // Load hardware specifications on mount useEffect(() => { async function loadSpecs() { @@ -487,8 +505,13 @@ function App() { showHistory={showHistory} setShowHistory={setShowHistory} onDeleteConversation={handleDeleteConversation} + speechTranscriptions={speechTranscriptions} + selectedSpeechTranscript={selectedSpeechTranscript} + setSelectedSpeechTranscript={setSelectedSpeechTranscript} + showSpeechHistory={showSpeechHistory} + setShowSpeechHistory={setShowSpeechHistory} /> - ), [activeTab, specs, conversations, activeConversationId, showHistory, handleDeleteConversation]); + ), [activeTab, specs, conversations, activeConversationId, showHistory, handleDeleteConversation, speechTranscriptions, selectedSpeechTranscript, showSpeechHistory]); const handleStopServer = useCallback(async () => { if (!serverRunning || isStoppingServer) return; @@ -584,6 +607,15 @@ function App() { /> +
+ +
+
model.filename); - const displayedLocalModels = normalizedLocalModels.filter((model) => !model.isProjector); - const allModelNames = normalizedLocalModels.map((model) => model.filename); + const normalizedLocalModels = activeModelType === "speech" + ? localModels.filter((model) => model.filename) + : localModels.map(normalizeModel).filter((model) => model.filename); + const displayedLocalModels = activeModelType === "speech" + ? normalizedLocalModels.filter((model) => model.installed) + : normalizedLocalModels.filter((model) => !model.isProjector); + const allModelNames = activeModelType === "speech" + ? displayedLocalModels.map((model) => model.filename) + : normalizedLocalModels.map((model) => model.filename); const modelNames = displayedLocalModels.map((model) => model.filename); const isBusy = loadingModelId !== null || isUnloading; @@ -286,11 +301,22 @@ function ModelManager({ if (appleNpuSupported) { visibleModelLibrary = [...visibleModelLibrary, ...COREML_MODEL_LIBRARY]; } - } else { + } else if (activeModelType === "text") { visibleModelLibrary = [{ group: "Recommended Text Models from Hugging Face", items: hasHuggingFaceResults ? displayedHuggingFaceModels : TEXT_MODEL_LIBRARY[0].items, }]; + } else { + visibleModelLibrary = [{ + group: "Whisper Speech Models", + items: normalizedLocalModels.filter((model) => model.url).map((model) => ({ + ...model, + format: "Whisper.cpp", + approxSize: model.size, + notes: `${model.language || "Speech"} transcription model. Stored in app/speech-models/.`, + recommendedTiers: model.recommended ? ["Low", "Mid", "High"] : [], + })), + }]; } useEffect(() => { @@ -364,9 +390,12 @@ function ModelManager({ if (activeModelType === "image") { const list = await listLocalModels(); setLocalModels(list.map(normalizeModel).filter((model) => model.filename)); - } else { + } else if (activeModelType === "text") { const list = await listLlmModels(); setLocalModels(list.map(normalizeModel).filter((model) => model.filename)); + } else { + const list = await listSpeechModels(); + setLocalModels(list.filter((model) => model.filename)); } } catch (e) { console.error(e); @@ -392,9 +421,10 @@ function ModelManager({ let cancelled = false; const updateBackendInfo = async () => { try { - const [sdStatus, llmStatus] = await Promise.all([ + const [sdStatus, llmStatus, speechStatus] = await Promise.all([ getBackendStatus(), - getLlmStatus() + getLlmStatus(), + getSpeechStatus() ]); if (cancelled) return; @@ -403,6 +433,7 @@ function ModelManager({ backendBinary: sdStatus.settings?.backendBinary || sdStatus.loading?.backendBinary || "", backendDevice: sdStatus.settings?.backendDevice || sdStatus.loading?.device || "", llmBackendMode: llmStatus.settings?.backendMode || llmStatus.settings?.backendBinary || "", + speechBackendMode: speechStatus.settings?.backendMode || speechStatus.backendMode || "", }); if (llmStatus.ready) { @@ -412,6 +443,13 @@ function ModelManager({ setActiveLlmModel(null); setLlmRunning(false); } + if (speechStatus.ready) { + setActiveSpeechModel(speechStatus.settings?.model || null); + setSpeechRunning(true); + } else { + setActiveSpeechModel(null); + setSpeechRunning(false); + } } catch (_) {} }; @@ -460,8 +498,10 @@ function ModelManager({ let res; if (activeModelType === "image") { res = await downloadModel(url); - } else { + } else if (activeModelType === "text") { res = await downloadLlmModel(url, expectedFilename, companion); + } else { + res = await downloadSpeechModel(url, expectedFilename); } if (res && res.ok) { if (!companion && res.projectorUrl && res.projectorFilename) { @@ -523,7 +563,7 @@ function ModelManager({ e.preventDefault(); if (!downloadUrl.trim()) return; - let modelName = "model.safetensors"; + let modelName = activeModelType === "image" ? "model.safetensors" : activeModelType === "text" ? "model.gguf" : "model.bin"; try { const parsed = new URL(downloadUrl.trim()); modelName = parsed.pathname.split("/").pop() || modelName; @@ -539,6 +579,18 @@ function ModelManager({ const handleLibraryDownload = async (model) => { if (downloadingModelId) return; + if (activeModelType === "speech") { + const confirmed = await showConfirm({ + title: "Download Speech Model?", + message: `Download "${model.name}" (${model.size || model.approxSize})?`, + confirmLabel: "Download", + }); + if (confirmed) { + await downloadByUrl(model.url, model.filename); + } + return; + } + if (model.backendType === "openvino-npu") { const confirmed = await showConfirm({ title: "Download OpenVINO Model?", @@ -639,7 +691,7 @@ function ModelManager({ return; } await checkVramAndLoad(modelId); - } else { + } else if (activeModelType === "text") { setLoadingModelId(modelId); setModelLoadProgress({ progress: 10, @@ -682,6 +734,35 @@ function ModelManager({ setModelLoadProgress(null); setLoadingModelId(null); } + } else { + setLoadingModelId(modelId); + setModelLoadProgress({ + progress: 30, + phase: "Starting whisper.cpp speech runtime...", + speed: "", + current: 0, + total: 0, + model: modelId, + backendMode: "", + backendBinary: "", + device: "", + }); + try { + await stopServer(); + await stopLlm(); + await startSpeech(modelId, { threads: textSettings?.threads || specs?.cpu_cores_physical || 4 }); + setActiveSpeechModel(modelId); + setSpeechRunning(true); + setActiveLlmModel(null); + setLlmRunning(false); + setActiveModel(null); + setServerRunning(false); + } catch (err) { + showAlert({ title: "Speech Model Load Failed", message: err.message || String(err), danger: true }); + } finally { + setModelLoadProgress(null); + setLoadingModelId(null); + } } }; @@ -872,7 +953,7 @@ function ModelManager({ setUnloadProgress({ progress: 0, phase: "" }); }, 500); } - } else { + } else if (activeModelType === "text") { setIsUnloading(true); setUnloadProgress({ progress: 50, phase: "Stopping llama.cpp backend process..." }); try { @@ -885,6 +966,19 @@ function ModelManager({ setIsUnloading(false); setUnloadProgress({ progress: 0, phase: "" }); } + } else { + setIsUnloading(true); + setUnloadProgress({ progress: 50, phase: "Stopping whisper.cpp speech runtime..." }); + try { + await stopSpeech(); + setActiveSpeechModel(null); + setSpeechRunning(false); + } catch (e) { + showAlert({ title: "Unload Failed", message: e.message || String(e), danger: true }); + } finally { + setIsUnloading(false); + setUnloadProgress({ progress: 0, phase: "" }); + } } }; @@ -901,11 +995,16 @@ function ModelManager({ if (activeModel === filename) { await handleUnloadModel(); } - } else { + } else if (activeModelType === "text") { await deleteLlmModel(filename); if (activeLlmModel === filename) { await handleUnloadModel(); } + } else { + await deleteSpeechModel(filename); + if (activeSpeechModel === filename) { + await handleUnloadModel(); + } } await fetchModels(); } catch (err) { @@ -937,7 +1036,7 @@ function ModelManager({ status: progressData.status }); }, controller.signal); - } else { + } else if (activeModelType === "text") { await importLlmModel(sourcePath, (progressData) => { setImportProgress(Math.round(progressData.progress)); setImportInfo({ @@ -947,6 +1046,16 @@ function ModelManager({ status: progressData.status }); }, controller.signal); + } else { + await importSpeechModel(sourcePath, (progressData) => { + setImportProgress(Math.round(progressData.progress)); + setImportInfo({ + filename: progressData.filename, + speed: progressData.speed_mb_s.toFixed(1), + eta: Math.round(progressData.eta_secs), + status: progressData.status + }); + }, controller.signal); } await fetchModels(); @@ -996,6 +1105,13 @@ function ModelManager({ > Text Models (GGUF) +
@@ -1054,6 +1170,26 @@ function ModelManager({ )} + {activeModelType === "speech" && activeSpeechModel && ( +
+
+
+

Active Speech Model: {activeSpeechModel}

+

+ The local whisper.cpp runtime is ready. Speech Transcriber can process WAV recordings and uploads. +

+ {backendInfo.speechBackendMode && ( +
+ + {backendInfo.speechBackendMode} + +
+ )} +
+
+
+ )} + {/* Loading Progress Bar */} {modelLoadProgress && (
@@ -1105,7 +1241,7 @@ function ModelManager({

- Local {activeModelType === "image" ? "Image" : "Text"} Models ({displayedLocalModels.length}) + Local {activeModelType === "image" ? "Image" : activeModelType === "text" ? "Text" : "Speech"} Models ({displayedLocalModels.length})

{isLoadingModels ? ( @@ -1115,15 +1251,17 @@ function ModelManager({
) : displayedLocalModels.length === 0 ? (

- {activeModelType === "image" + {activeModelType === "image" ? "No image models detected in app/models/. Download from the library below or import a file." - : "No text models detected in app/llm-models/. Download from the library below or import a file."} + : activeModelType === "text" + ? "No text models detected in app/llm-models/. Download from the library below or import a file." + : "No speech models detected in app/speech-models/. Download from the library below or import a .bin file."}

) : (
{displayedLocalModels.map((model) => { const filename = model.filename; - const isActive = activeModelType === "image" ? activeModel === filename : activeLlmModel === filename; + const isActive = activeModelType === "image" ? activeModel === filename : activeModelType === "text" ? activeLlmModel === filename : activeSpeechModel === filename; return (
{activeModelType === "image" ? (model.backendType === "openvino-npu" ? "OpenVINO NPU Model" : model.format || "Local Weights File") - : "llama.cpp GGUF Model" + : activeModelType === "text" + ? "llama.cpp GGUF Model" + : `${model.language || "Whisper"} Model` } • {model.size || formatBytes(model.sizeBytes)}
@@ -1192,7 +1332,7 @@ function ModelManager({

- {activeModelType === "text" ? "Hugging Face Model Library" : "Model Library"} + {activeModelType === "text" ? "Hugging Face Model Library" : activeModelType === "speech" ? "Speech Model Library" : "Model Library"}

@@ -1401,14 +1541,14 @@ function ModelManager({ Choose weights file - Select {activeModelType === "image" ? "`.safetensors` or `.ckpt` weights." : "`.gguf` weights."} + Select {activeModelType === "image" ? "`.safetensors` or `.ckpt` weights." : activeModelType === "text" ? "`.gguf` weights." : "`whisper.cpp .bin` weights."} @@ -1418,7 +1558,7 @@ function ModelManager({ Download Model from URL

- Download any {activeModelType === "image" ? "Safetensors" : "GGUF"} model from Hugging Face directly to your models folder. + Download any {activeModelType === "image" ? "Safetensors" : activeModelType === "text" ? "GGUF" : "Whisper .bin"} model from Hugging Face directly to your models folder.

{isUrlDownloading ? (
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 (
@@ -148,6 +159,104 @@ function Sidebar({ )}
+
+
setActiveTab("speech")} + style={{ display: "flex", justifyContent: "space-between", alignItems: "center", width: "100%", boxSizing: "border-box" }} + > +
+ + Speech Transcriber +
+ +
+ + {showSpeechHistory && ( +
+ {speechTranscriptions.length === 0 ? ( +
+ No saved transcriptions +
+ ) : ( + speechTranscriptions.map((item) => { + const itemId = item.filename || item.metadata || item.textFile; + const isActive = selectedSpeechTranscript && (selectedSpeechTranscript.filename || selectedSpeechTranscript.metadata || selectedSpeechTranscript.textFile) === itemId; + const title = item.displayName || item.sourceFilename || item.textFile || item.filename || "Transcript"; + const date = formatSidebarDate(item.modifiedAt || item.createdAt); + return ( +
{ + setSelectedSpeechTranscript(item); + setActiveTab("speech"); + }} + style={{ + display: "flex", + flexDirection: "column", + gap: "2px", + padding: "7px 8px 7px 10px", + borderRadius: "var(--md-shape-corner-small)", + fontSize: "0.78rem", + cursor: "pointer", + background: isActive ? "var(--md-sys-color-secondary-container)" : "transparent", + color: isActive ? "var(--md-sys-color-on-secondary-container)" : "var(--md-sys-color-on-surface-variant)", + border: isActive ? "1px solid var(--md-sys-color-outline-variant)" : "1px solid transparent", + transition: "background 0.2s" + }} + className="sidebar-history-item" + title={title} + > + + {title} + + {date && ( + + {date} + + )} +
+ ); + }) + )} +
+ )} +
+
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}
} + + + +
+ + +
+ +
+ + +
+ + {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 &&
+
+ +
+
+

Transcript

+
+ + + {status.ready && ( + + )} +
+
+