implement backend service manager and speech transcription pipeline with supporting setup scripts

This commit is contained in:
techjarves committed 2026-06-21 19:23:38 +05:30
1 parent 20b81926b6
commit b71b4ecafa
17 files changed
+1680 -46

No files matched your search

+130
View File
@@ -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);
+34 -2
View File
@@ -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() {
/>
</div>
<div style={{ display: activeTab === "speech" ? "flex" : "none", flex: 1, flexDirection: "column", overflow: "hidden" }}>
<SpeechTranscriber
showAlert={showAlert}
showConfirm={showConfirm}
selectedTranscript={selectedSpeechTranscript}
onTranscriptionsChanged={refreshSpeechTranscriptions}
/>
</div>
<div style={{ display: activeTab === "settings" ? "flex" : "none", flex: 1, flexDirection: "column", overflow: "hidden" }}>
<Settings
constraints={constraints}
+164 -24
View File
@@ -21,7 +21,14 @@ import {
deleteLlmModel,
importLlmModel,
getLlmStatus,
searchHuggingFaceModels
searchHuggingFaceModels,
listSpeechModels,
startSpeech,
stopSpeech,
downloadSpeechModel,
deleteSpeechModel,
importSpeechModel,
getSpeechStatus
} from "../services/api";
const MODEL_FILTERS = [
@@ -220,7 +227,7 @@ function ModelManager({
specs,
textSettings,
}) {
const [activeModelType, setActiveModelType] = useState("image"); // "image" or "text"
const [activeModelType, setActiveModelType] = useState("image"); // "image", "text", or "speech"
const [localModels, setLocalModels] = useState([]);
const [isLoadingModels, setIsLoadingModels] = useState(true);
const [downloadingModelId, setDownloadingModelId] = useState(null);
@@ -239,6 +246,8 @@ function ModelManager({
const [backendInfo, setBackendInfo] = useState({ backendMode: "", backendBinary: "", backendDevice: "" });
const [activeLlmModel, setActiveLlmModel] = useState(null);
const [llmRunning, setLlmRunning] = useState(false);
const [activeSpeechModel, setActiveSpeechModel] = useState(null);
const [speechRunning, setSpeechRunning] = useState(false);
const [modelSearch, setModelSearch] = useState("");
const [selectedFilters, setSelectedFilters] = useState([]);
const [huggingFaceModels, setHuggingFaceModels] = useState([]);
@@ -262,9 +271,15 @@ function ModelManager({
}
};
const normalizedLocalModels = localModels.map(normalizeModel).filter((model) => 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)
</button>
<button
className={`m3-btn ${activeModelType === "speech" ? "m3-btn-filled" : "m3-btn-outlined"}`}
onClick={() => setActiveModelType("speech")}
style={{ height: "40px", padding: "0 20px" }}
>
Speech Models (Whisper)
</button>
</div>
@@ -1054,6 +1170,26 @@ function ModelManager({
</div>
)}
{activeModelType === "speech" && activeSpeechModel && (
<div className="m3-card" style={{ borderLeft: "4px solid var(--md-sys-color-primary)", background: "var(--md-sys-color-primary-container)", color: "var(--md-sys-color-on-primary-container)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div>
<h4 style={{ fontWeight: 700 }}>Active Speech Model: {activeSpeechModel}</h4>
<p style={{ fontSize: "0.85rem", marginTop: "2px", opacity: 0.9 }}>
The local whisper.cpp runtime is ready. Speech Transcriber can process WAV recordings and uploads.
</p>
{backendInfo.speechBackendMode && (
<div style={{ display: "flex", gap: "8px", marginTop: "8px", flexWrap: "wrap" }}>
<span className="status-chip" style={{ cursor: "default", background: "rgba(255,255,255,0.15)", color: "inherit", border: "1px solid rgba(255,255,255,0.2)" }}>
<span>{backendInfo.speechBackendMode}</span>
</span>
</div>
)}
</div>
</div>
</div>
)}
{/* Loading Progress Bar */}
{modelLoadProgress && (
<div className="m3-card" style={{ borderLeft: "4px solid var(--md-sys-color-primary)", marginTop: "24px" }}>
@@ -1105,7 +1241,7 @@ function ModelManager({
<div className="m3-card" style={{ marginTop: "24px" }}>
<h3 className="m3-card-title">
<Database size={18} style={{ color: "var(--md-sys-color-primary)" }} />
Local {activeModelType === "image" ? "Image" : "Text"} Models ({displayedLocalModels.length})
Local {activeModelType === "image" ? "Image" : activeModelType === "text" ? "Text" : "Speech"} Models ({displayedLocalModels.length})
</h3>
{isLoadingModels ? (
@@ -1115,15 +1251,17 @@ function ModelManager({
</div>
) : displayedLocalModels.length === 0 ? (
<p style={{ fontSize: "0.9rem", color: "var(--md-sys-color-outline)", textAlign: "center", padding: "16px 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."}
</p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
{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 (
<div
@@ -1144,7 +1282,9 @@ function ModelManager({
<span style={{ fontSize: "0.75rem", opacity: 0.8 }}>
{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)}
</span>
</div>
@@ -1192,7 +1332,7 @@ function ModelManager({
<div className="workspace-title-section" style={{ marginTop: "32px", marginBottom: "16px" }}>
<h3 className="m3-card-title">
<Library size={20} style={{ color: "var(--md-sys-color-primary)" }} />
{activeModelType === "text" ? "Hugging Face Model Library" : "Model Library"}
{activeModelType === "text" ? "Hugging Face Model Library" : activeModelType === "speech" ? "Speech Model Library" : "Model Library"}
</h3>
</div>
@@ -1401,14 +1541,14 @@ function ModelManager({
<input
type="file"
style={{ display: "none" }}
accept={activeModelType === "image" ? ".safetensors,.ckpt" : ".gguf"}
accept={activeModelType === "image" ? ".safetensors,.ckpt" : activeModelType === "text" ? ".gguf" : ".bin"}
onChange={handleImportFile}
disabled={importProgress !== null}
/>
<FolderOpen className="import-icon" />
<span style={{ fontWeight: 600 }}>Choose weights file</span>
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", textAlign: "center" }}>
Select {activeModelType === "image" ? "`.safetensors` or `.ckpt` weights." : "`.gguf` weights."}
Select {activeModelType === "image" ? "`.safetensors` or `.ckpt` weights." : activeModelType === "text" ? "`.gguf` weights." : "`whisper.cpp .bin` weights."}
</span>
</label>
@@ -1418,7 +1558,7 @@ function ModelManager({
Download Model from URL
</h4>
<p style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", marginBottom: "12px" }}>
Download any {activeModelType === "image" ? "Safetensors" : "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.
</p>
{isUrlDownloading ? (
<div className="model-progress-section" style={{ marginTop: "0px" }}>
+111 -2
View File
@@ -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 (
<div className="sidebar">
@@ -148,6 +159,104 @@ function Sidebar({
)}
</div>
<div className="nav-item-wrapper" style={{ display: "flex", flexDirection: "column" }}>
<div
className={`nav-item ${activeTab === "speech" ? "active" : ""}`}
onClick={() => setActiveTab("speech")}
style={{ display: "flex", justifyContent: "space-between", alignItems: "center", width: "100%", boxSizing: "border-box" }}
>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<Mic size={20} />
<span>Speech Transcriber</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
setShowSpeechHistory(!showSpeechHistory);
}}
style={{
background: "none",
border: "none",
color: "inherit",
cursor: "pointer",
padding: "4px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "50%",
transition: "background-color 0.2s"
}}
className="history-toggle-arrow"
title={showSpeechHistory ? "Hide Transcriptions" : "Show Transcriptions"}
>
{showSpeechHistory ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
</div>
{showSpeechHistory && (
<div
className="sidebar-history-list"
style={{
paddingLeft: "14px",
marginTop: "6px",
marginBottom: "6px",
display: "flex",
flexDirection: "column",
gap: "4px",
maxHeight: "220px",
overflowY: "auto",
borderLeft: "2px solid var(--border-color)"
}}
>
{speechTranscriptions.length === 0 ? (
<div style={{ padding: "8px 12px", fontSize: "0.78rem", color: "var(--md-sys-color-outline)", opacity: 0.8 }}>
No saved transcriptions
</div>
) : (
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 (
<div
key={itemId}
onClick={() => {
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}
>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: isActive ? 600 : 400 }}>
{title}
</span>
{date && (
<span style={{ fontSize: "0.68rem", opacity: 0.72, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{date}
</span>
)}
</div>
);
})
)}
</div>
)}
</div>
<div
className={`nav-item ${activeTab === "models" ? "active" : ""}`}
onClick={() => setActiveTab("models")}
@@ -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 (
<div className="workspace-area speech-workspace">
<div className="workspace-title-section">
<h2 className="workspace-title">Speech Transcriber</h2>
<p className="workspace-subtitle">
Record microphone audio or upload a WAV file, then transcribe locally with whisper.cpp.
</p>
</div>
<div className="speech-grid">
<section className="m3-card speech-panel">
<div className="speech-panel-header">
<h3>Runtime</h3>
<span className={`status-chip ${status.backendInstalled ? "" : "offline"}`}>
{status.backendInstalled ? (status.ready ? "Ready" : "Installed") : "Backend missing"}
</span>
</div>
{status.error && <div className="text-progress error">{status.error}</div>}
<label className="speech-label">
Whisper model
<select className="m3-input" value={selectedModel} onChange={(event) => setSelectedModel(event.target.value)}>
{installedModels.length === 0 && <option value="">No downloaded speech models</option>}
{installedModels.map((model) => (
<option key={model.id || model.filename} value={model.filename}>
{model.name || model.filename}
</option>
))}
</select>
</label>
<div className="speech-controls-row">
<label className="speech-label">
Language
<select className="m3-input" value={language} onChange={(event) => setLanguage(event.target.value)}>
{LANGUAGE_OPTIONS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
</select>
</label>
<label className="speech-label">
Threads
<input
className="m3-input"
type="number"
min="1"
max="32"
value={threads}
onChange={(event) => setThreads(Math.max(1, Math.min(32, Number(event.target.value) || 1)))}
/>
</label>
</div>
<div className="speech-button-row">
<button
className="m3-btn m3-btn-filled"
onClick={handleLoadSpeechModel}
disabled={!selectedModel || isLoadingModel || (status.ready && status.settings?.model === selectedModel)}
>
{isLoadingModel ? <LoaderCircle className="progress-spinner" size={14} /> : <Play size={14} />}
<span>{status.ready && status.settings?.model === selectedModel ? "Loaded" : isLoadingModel ? "Loading" : "Load"}</span>
</button>
<button className="m3-btn m3-btn-outlined" onClick={refresh}>
<Play size={14} />
<span>Refresh</span>
</button>
</div>
{installedModels.length === 0 && (
<div className="text-progress">
Download a Whisper model from Model Manager, Speech Models first.
</div>
)}
</section>
<section className="m3-card speech-panel">
<div className="speech-panel-header">
<h3>Audio</h3>
<span className="status-chip">{audioName || "No audio selected"}</span>
</div>
<div className="speech-capture-zone">
<FileAudio size={42} />
<div>
<strong>{audioName || "Record or upload WAV"}</strong>
<p>Microphone recordings are converted to 16 kHz mono PCM16 WAV before transcription.</p>
</div>
</div>
{audioUrl && <audio controls src={audioUrl} style={{ width: "100%" }} />}
<div className="speech-button-row">
{isRecording ? (
<button className="m3-btn m3-btn-error" onClick={stopRecording}>
<Square size={14} />
<span>Stop Recording</span>
</button>
) : (
<button className="m3-btn m3-btn-filled" onClick={startRecording} disabled={isTranscribing}>
<Mic size={14} />
<span>Record</span>
</button>
)}
<label className="m3-btn m3-btn-outlined">
<Upload size={14} />
<span>Upload WAV</span>
<input type="file" accept=".wav,audio/wav" onChange={handleUploadAudio} hidden />
</label>
{audioBlob && (
<button className="m3-btn m3-btn-error" onClick={clearCurrentAudio} disabled={isTranscribing || isRecording}>
<X size={14} />
<span>Remove Audio</span>
</button>
)}
</div>
<div className="speech-button-row">
{isTranscribing ? (
<button className="m3-btn m3-btn-error" onClick={handleCancel}>
<Square size={14} />
<span>Cancel</span>
</button>
) : (
<button
className="m3-btn m3-btn-filled"
onClick={handleTranscribe}
disabled={!audioBlob || !selectedModel || !status.backendInstalled}
>
<Mic size={14} />
<span>Transcribe</span>
</button>
)}
{isTranscribing && <span className="text-progress"><LoaderCircle className="progress-spinner" size={14} /> Transcribing locally...</span>}
</div>
</section>
</div>
<section className="m3-card speech-result-panel">
<div className="speech-panel-header">
<h3>Transcript</h3>
<div className="speech-button-row">
<button className="m3-btn m3-btn-outlined" onClick={saveText} disabled={!resultText.trim()}>
<Save size={14} />
<span>TXT</span>
</button>
<button className="m3-btn m3-btn-outlined" onClick={saveJson} disabled={!resultText.trim()}>
<Save size={14} />
<span>JSON</span>
</button>
{status.ready && (
<button className="m3-btn m3-btn-error" onClick={() => stopSpeech().then(refresh)}>
<Square size={14} />
<span>Stop Runtime</span>
</button>
)}
</div>
</div>
<textarea
className="m3-textarea speech-result-textarea"
value={resultText}
onChange={(event) => setResultText(event.target.value)}
placeholder="Transcript output will appear here."
/>
</section>
</div>
);
}
+83
View File
@@ -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}`);