Refactor app to speech-only transcription tool

Remove image generation, text-to-speech, and chat features.
Keep speech transcription with Whisper.cpp, model manager, and settings.
Support multiple audio formats (WAV, MP3, M4A, OGG, FLAC, WebM, AAC)
via ffmpeg transcoding. Single-call API: upload → transcribe → download
as txt/json/md.
This commit is contained in:
truetype committed 2026-09-16 20:17:09 +02:00
1 parent 313b994c9b
commit 76946f8594
14 files changed
+671 -10753

No files matched your search

+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<title>Uncensored AI Studio</title>
<title>Speech Transcriber</title>
</head>
<body>
+211 -3732
View File
File diff suppressed because it is too large. Load diff
+85 -676
View File
@@ -1,13 +1,8 @@
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import Sidebar from "./components/Sidebar";
import TopStatusBar from "./components/TopStatusBar";
import Generator from "./components/Generator";
import { useState, useEffect, useRef, useCallback } from "react";
import SpeechTranscriber from "./components/SpeechTranscriber";
import ModelManager from "./components/ModelManager";
import Settings from "./components/Settings";
import TextChat from "./components/TextChat";
import SpeechTranscriber from "./components/SpeechTranscriber";
import TextToSpeech from "./components/TextToSpeech";
import { cleanupCandidates, formatBytes, getCleanupCandidates, getDiagnostics, getHardwareSpecs, getHealth, getTelemetry, getBackendOptions, getBackendStatus, listGeneratedOutputs, listLlmConversations, saveLlmConversation, deleteLlmConversation, listSpeechTranscriptions, deleteSpeechTranscription, listTtsOutputs, deleteTtsOutput, stopServer } from "./services/api";
import { getHardwareSpecs, getTelemetry, listSpeechTranscriptions, deleteSpeechTranscription } from "./services/api";
import "./App.css";
function App() {
@@ -47,122 +42,10 @@ function App() {
localStorage.setItem("theme", theme);
}, [theme]);
// Navigation
const [activeTab, setActiveTab] = useState("generator");
// Prompts
const [prompt, setPrompt] = useState("");
const [negativePrompt, setNegativePrompt] = useState("");
// Model & Server Status
const [activeModel, setActiveModel] = useState(null);
const [serverRunning, setServerRunning] = useState(false);
const [isStoppingServer, setIsStoppingServer] = useState(false);
const [isLlmLoaded, setIsLlmLoaded] = useState(false);
const [backendOptions, setBackendOptions] = useState({
options: [{ id: "cpu", label: "CPU", available: true }],
cudaAvailable: false,
vulkanAvailable: false,
defaultBackendType: "cpu",
});
const [health, setHealth] = useState(null);
const [cleanupItems, setCleanupItems] = useState([]);
const [isReadinessBusy, setIsReadinessBusy] = useState(false);
const [diagnosticsCopied, setDiagnosticsCopied] = useState(false);
// Gallery History
const [generatedImages, setGeneratedImages] = useState([]);
// Generation status
const [isGenerating, setIsGenerating] = useState(false);
const [generationProgress, setGenerationProgress] = useState(0);
// System Specifications & Telemetry
const [specs, setSpecs] = useState({
os_name: "Loading Specs...",
cpu_name: "Loading Specs...",
cpu_cores_physical: 4,
cpu_cores_logical: 8,
ram_total_gb: 0,
gpu_name: "Loading Specs...",
});
const [telemetry, setTelemetry] = useState({
cpu_usage: 0,
ram_used_gb: 0,
ram_total_gb: 0,
gpu_name: "Detecting...",
vram_used_gb: 0,
vram_total_gb: 0,
});
// Default Image Constraints
const [constraints, setConstraints] = useState({
width: 1024,
height: 1024,
steps: 4, // Recommended default steps for Flux.1 Schnell/Lightning
npuSteps: 4,
standardSteps: 20,
cfgScale: 1.0, // Recommended default CFG for Flux.1 Schnell
sampler: "euler_a",
seed: -1,
denoisingStrength: 0.7,
useGpu: true,
useTaesd: true,
useFlashAttn: true,
useTiling: false,
vaeTiling: true,
vaeOnCpu: false,
threads: 4,
backendType: "auto",
});
const [textSettings, setTextSettings] = useState(() => {
const saved = localStorage.getItem("textSettings");
if (saved) {
try {
const parsed = JSON.parse(saved);
return {
...parsed,
enableThinking: parsed.enableThinking === true,
maxTokens: !parsed.maxTokens || parsed.maxTokens === 384 ? 1024 : parsed.maxTokens,
responseTokenMode: parsed.responseTokenMode || "auto",
};
} catch (_) {}
}
return {
contextSize: 0,
temperature: 0.7,
systemPrompt: "You are a helpful local AI assistant.",
threads: Math.max(4, Math.min(16, (navigator.hardwareConcurrency || 4) - 2)),
enableThinking: false,
// New performance settings
gpuLayers: -1,
maxTokens: 1024,
responseTokenMode: "auto",
topP: 0.95,
topK: 40,
minP: 0.05,
repeatPenalty: 1.1,
seed: null,
performanceProfile: "balanced",
flashAttn: true,
cacheTypeK: "q8_0",
cacheTypeV: "q8_0",
mlock: false,
mmap: true,
cachePrompt: true,
defragThold: 0.1,
batchSize: 512,
ubatchSize: 512,
preferredBackend: "auto",
};
});
useEffect(() => {
localStorage.setItem("textSettings", JSON.stringify(textSettings));
}, [textSettings]);
// Navigation - only speech-related tabs
const [activeTab, setActiveTab] = useState("speech");
// Speech settings
const [speechSettings, setSpeechSettings] = useState(() => {
const saved = localStorage.getItem("speechSettings");
if (saved) {
@@ -188,173 +71,28 @@ function App() {
localStorage.setItem("speechSettings", JSON.stringify(speechSettings));
}, [speechSettings]);
const [ttsSettings, setTtsSettings] = useState(() => {
const saved = localStorage.getItem("ttsSettings");
if (saved) {
try {
const parsed = JSON.parse(saved);
return {
model: parsed.model || "",
voice: parsed.voice || "af_heart",
speed: Math.max(0.5, Math.min(2, Number(parsed.speed) || 1)),
};
} catch (_) {}
}
return {
model: "",
voice: "af_heart",
speed: 1,
};
// System Specifications & Telemetry
const [specs, setSpecs] = useState({
os_name: "Loading Specs...",
cpu_name: "Loading Specs...",
cpu_cores_physical: 4,
cpu_cores_logical: 8,
ram_total_gb: 0,
gpu_name: "Loading Specs...",
});
useEffect(() => {
localStorage.setItem("ttsSettings", JSON.stringify(ttsSettings));
}, [ttsSettings]);
// Lifted Chat History States
const [conversations, setConversations] = useState([]);
const [activeConversationId, setActiveConversationId] = useState(null);
const [showHistory, setShowHistory] = useState(false); // Default hide
const [sidebarVisible, setSidebarVisible] = useState(() => {
const saved = localStorage.getItem("sidebarVisible");
return saved !== "false";
const [telemetry, setTelemetry] = useState({
cpu_usage: 0,
ram_used_gb: 0,
ram_total_gb: 0,
gpu_name: "Detecting...",
vram_used_gb: 0,
vram_total_gb: 0,
});
useEffect(() => {
localStorage.setItem("sidebarVisible", String(sidebarVisible));
}, [sidebarVisible]);
const [fontSize, setFontSize] = useState(() => {
const saved = localStorage.getItem("fontSize");
return saved ? Number(saved) : 16;
});
useEffect(() => {
document.documentElement.style.setProperty("--app-font-size", `${fontSize}px`);
localStorage.setItem("fontSize", String(fontSize));
}, [fontSize]);
// Speech transcriptions history
const [speechTranscriptions, setSpeechTranscriptions] = useState([]);
const [selectedSpeechTranscript, setSelectedSpeechTranscript] = useState(null);
const [showSpeechHistory, setShowSpeechHistory] = useState(false);
const [ttsOutputs, setTtsOutputs] = useState([]);
const [selectedTtsOutput, setSelectedTtsOutput] = useState(null);
const [showTtsHistory, setShowTtsHistory] = useState(false);
const sanitizeMessageForStorage = (message) => {
if (!Array.isArray(message?.content)) return message;
return {
...message,
content: message.content.map((item) => {
if (item?.type !== "image_url") return item;
return {
type: "text",
text: "[Attached image omitted from saved chat history]",
};
}),
};
};
const sanitizeConversationForStorage = (conversation) => ({
...conversation,
messages: Array.isArray(conversation.messages)
? conversation.messages.map(sanitizeMessageForStorage)
: [],
});
const persistConversation = (conversation) => {
const compactConversation = sanitizeConversationForStorage(conversation);
try {
saveLlmConversation(compactConversation).catch((err) => {
console.warn("Could not save chat history to app folder:", err);
});
} catch (err) {
console.warn("Could not save chat history:", err);
}
};
useEffect(() => {
let cancelled = false;
async function loadConversations() {
let legacyConversations = [];
const legacySaved = localStorage.getItem("chat_conversations");
if (legacySaved) {
try {
legacyConversations = JSON.parse(legacySaved);
} catch (_) {
localStorage.removeItem("chat_conversations");
}
}
try {
const diskConversations = await listLlmConversations();
const diskIds = new Set(diskConversations.map((conversation) => conversation.id));
const missingLegacy = legacyConversations
.map(sanitizeConversationForStorage)
.filter((conversation) => conversation.id && !diskIds.has(conversation.id));
if (missingLegacy.length > 0) {
await Promise.all(missingLegacy.map((conversation) => saveLlmConversation(conversation)));
}
const merged = [...diskConversations, ...missingLegacy]
.sort((a, b) => Number(b.timestamp || 0) - Number(a.timestamp || 0));
if (!cancelled) setConversations(merged);
if (legacySaved) localStorage.removeItem("chat_conversations");
} catch (err) {
console.warn("Could not load chat history from app folder:", err);
if (!cancelled && legacyConversations.length > 0) {
setConversations(legacyConversations.map(sanitizeConversationForStorage));
}
}
}
loadConversations();
return () => {
cancelled = true;
};
}, []);
const saveConversationState = useCallback((id, msgs, modelName, newTitle = null) => {
setConversations((prev) => {
const list = [...prev];
const idx = list.findIndex(c => c.id === id);
let conversation;
if (idx !== -1) {
conversation = {
...list[idx],
messages: msgs,
timestamp: Date.now(),
model: modelName,
...(newTitle ? { title: newTitle } : {})
};
list[idx] = conversation;
} else {
conversation = {
id,
title: newTitle || "Chat Session",
model: modelName,
messages: msgs,
timestamp: Date.now()
};
list.unshift(conversation);
}
persistConversation(conversation);
return list;
});
}, []);
const handleDeleteConversation = useCallback((id, e) => {
if (e) e.stopPropagation();
setConversations((prev) => {
const filtered = prev.filter((c) => c.id !== id);
return filtered;
});
deleteLlmConversation(id).catch((err) => {
console.warn("Could not delete chat history from app folder:", err);
});
if (activeConversationId === id) {
setActiveConversationId(null);
}
}, [activeConversationId]);
const refreshSpeechTranscriptions = useCallback(async () => {
try {
@@ -401,65 +139,13 @@ function App() {
refreshSpeechTranscriptions();
}, [refreshSpeechTranscriptions]);
const refreshTtsOutputs = useCallback(async () => {
try {
const list = await listTtsOutputs();
setTtsOutputs(list);
} catch (err) {
console.warn("Could not load TTS outputs:", err);
setTtsOutputs([]);
}
}, []);
const handleDeleteTtsOutput = useCallback(async (item, e) => {
if (e) e.stopPropagation();
const itemId = item.filename || item.metadata;
if (!itemId) return;
const ok = await showConfirm({
title: "Delete TTS Output",
message: `Are you sure you want to delete "${item.displayName || item.audioFile || "this audio"}"? This action cannot be undone.`,
confirmLabel: "Delete",
danger: true
});
if (!ok) return;
try {
await deleteTtsOutput(itemId);
setSelectedTtsOutput((prev) => {
if (prev) {
const prevId = prev.filename || prev.metadata;
if (prevId === itemId) return null;
}
return prev;
});
refreshTtsOutputs();
} catch (err) {
console.error("Failed to delete TTS output:", err);
showAlert({ title: "Delete Failed", message: err.message || String(err), danger: true });
}
}, [showConfirm, showAlert, refreshTtsOutputs]);
useEffect(() => {
refreshTtsOutputs();
}, [refreshTtsOutputs]);
// Load hardware specifications on mount
useEffect(() => {
async function loadSpecs() {
try {
const hardware = await getHardwareSpecs();
const backendInfo = await getBackendOptions();
setBackendOptions(backendInfo);
setSpecs(hardware);
// Default CPU threads count to match physical cores
setConstraints((prev) => ({
...prev,
threads: hardware.cpu_cores_physical || 4,
backendType: prev.backendType === "auto" ? backendInfo.defaultBackendType || "cpu" : prev.backendType,
useGpu: (prev.backendType === "auto" ? backendInfo.defaultBackendType : prev.backendType) !== "cpu",
}));
setTextSettings((prev) => ({
setSpeechSettings((prev) => ({
...prev,
threads: prev.threads || hardware.cpu_cores_physical || 4
}));
@@ -470,120 +156,6 @@ function App() {
loadSpecs();
}, []);
const refreshReadiness = useCallback(async () => {
setIsReadinessBusy(true);
try {
const [healthInfo, candidates] = await Promise.all([
getHealth(),
getCleanupCandidates().catch(() => []),
]);
setHealth(healthInfo);
setCleanupItems(candidates);
} finally {
setIsReadinessBusy(false);
}
}, []);
useEffect(() => {
refreshReadiness();
}, [refreshReadiness]);
const copyDiagnostics = useCallback(async () => {
try {
const diagnostics = await getDiagnostics();
await navigator.clipboard.writeText(JSON.stringify(diagnostics, null, 2));
setDiagnosticsCopied(true);
setTimeout(() => setDiagnosticsCopied(false), 1800);
} catch (err) {
showAlert({ title: "Diagnostics Failed", message: err.message || String(err), danger: true });
}
}, [showAlert]);
const cleanupSafeItems = useCallback(async () => {
if (cleanupItems.length === 0) return;
const totalBytes = cleanupItems.reduce((sum, item) => sum + Number(item.sizeBytes || 0), 0);
const ok = await showConfirm({
title: "Clean Safe Files?",
message: `Delete ${cleanupItems.length} temporary/cache item${cleanupItems.length === 1 ? "" : "s"} and free about ${formatBytes(totalBytes)}?\n\nModels and generated outputs are not included.`,
confirmLabel: "Clean",
danger: true,
});
if (!ok) return;
setIsReadinessBusy(true);
try {
await cleanupCandidates(cleanupItems.map((item) => item.id));
await refreshReadiness();
} catch (err) {
showAlert({ title: "Cleanup Failed", message: err.message || String(err), danger: true });
} finally {
setIsReadinessBusy(false);
}
}, [cleanupItems, refreshReadiness, showAlert, showConfirm]);
useEffect(() => {
async function loadSavedOutputs() {
const outputs = await listGeneratedOutputs();
setGeneratedImages(outputs);
}
loadSavedOutputs();
}, []);
// Save active model configuration to localStorage for persistence across reloads
useEffect(() => {
if (activeModel) {
localStorage.setItem("active-model", activeModel);
} else {
localStorage.removeItem("active-model");
}
}, [activeModel]);
// Check if server is already running on mount (handles page refresh and startup lag)
useEffect(() => {
let checkInterval = null;
async function checkServerRunning() {
const status = await getBackendStatus();
const isRunning = Boolean(status.ready);
if (isRunning) {
if (checkInterval) clearInterval(checkInterval);
const savedModel = localStorage.getItem("active-model");
if (savedModel) {
setActiveModel(savedModel);
setServerRunning(true);
} else {
// Query the active model from status if possible, otherwise use fallback
try {
if (status && status.settings && status.settings.model) {
const modelName = status.settings.model.split(/[\\/]/).pop();
setActiveModel(modelName);
}
} catch (_) {}
setServerRunning(true);
}
} else {
// If server is not running, check if they had a simulated model active in browser mode
const isTauriDesktop = typeof window !== "undefined" && window.__TAURI_INTERNALS__ !== undefined;
if (!isTauriDesktop) {
const savedModel = localStorage.getItem("active-model");
if (savedModel) {
setActiveModel(savedModel);
setServerRunning(false);
}
}
}
}
checkServerRunning();
// Poll every 2 seconds to auto-detect when the backend becomes ready
checkInterval = setInterval(checkServerRunning, 2000);
return () => {
if (checkInterval) clearInterval(checkInterval);
};
}, []);
// Poll system telemetry usage statistics on interval
useEffect(() => {
async function updateTelemetry() {
@@ -605,204 +177,62 @@ function App() {
}
updateTelemetry();
const interval = setInterval(updateTelemetry, 1500); // Poll every 1.5 seconds
const interval = setInterval(updateTelemetry, 1500);
return () => clearInterval(interval);
}, []);
// Sync active model settings default parameters
useEffect(() => {
if (activeModel) {
const name = activeModel.toLowerCase();
if (name.includes("lcm-dreamshaper-v7-fp16")) {
setConstraints((prev) => ({
...prev,
steps: 4,
npuSteps: 4,
cfgScale: 1.0,
width: 512,
height: 512,
backendType: "openvino-npu",
useGpu: true,
}));
} else if (name.includes("flux") || name.includes("schnell")) {
setConstraints((prev) => ({
...prev,
steps: 4,
standardSteps: 4,
cfgScale: 1.0,
width: 1024,
height: 1024,
}));
} else if (name.includes("lightning") || name.includes("turbo")) {
setConstraints((prev) => ({
...prev,
steps: 4,
standardSteps: 4,
cfgScale: 1.5,
width: 1024,
height: 1024,
}));
} else if (name.includes("sd15")) {
setConstraints((prev) => ({
...prev,
steps: 25,
standardSteps: 25,
cfgScale: 7.0,
width: 512,
height: 512,
}));
} else if (name.includes("sd35")) {
setConstraints((prev) => ({
...prev,
steps: 20,
standardSteps: 20,
cfgScale: 4.5,
width: 1024,
height: 1024,
}));
} else {
// Fallback for custom or SD 1.x models (like CyberRealistic or arbitrary safetensors)
setConstraints((prev) => ({
...prev,
steps: 20,
standardSteps: 20,
cfgScale: 7.0,
width: 512,
height: 512,
}));
}
}
}, [activeModel]);
// Tab contents are kept mounted to preserve loading/generation state when switching tabs
const sidebarContent = useMemo(() => (
<Sidebar
collapsed={!sidebarVisible}
activeTab={activeTab}
setActiveTab={setActiveTab}
specs={specs}
conversations={conversations}
activeConversationId={activeConversationId}
setActiveConversationId={setActiveConversationId}
showHistory={showHistory}
setShowHistory={setShowHistory}
onDeleteConversation={handleDeleteConversation}
speechTranscriptions={speechTranscriptions}
selectedSpeechTranscript={selectedSpeechTranscript}
setSelectedSpeechTranscript={setSelectedSpeechTranscript}
showSpeechHistory={showSpeechHistory}
setShowSpeechHistory={setShowSpeechHistory}
onDeleteSpeechTranscription={handleDeleteSpeechTranscription}
ttsOutputs={ttsOutputs}
selectedTtsOutput={selectedTtsOutput}
setSelectedTtsOutput={setSelectedTtsOutput}
showTtsHistory={showTtsHistory}
setShowTtsHistory={setShowTtsHistory}
onDeleteTtsOutput={handleDeleteTtsOutput}
/>
), [sidebarVisible, activeTab, specs, conversations, activeConversationId, showHistory, handleDeleteConversation, speechTranscriptions, selectedSpeechTranscript, showSpeechHistory, handleDeleteSpeechTranscription, ttsOutputs, selectedTtsOutput, showTtsHistory, handleDeleteTtsOutput]);
const handleStopServer = useCallback(async () => {
if (!serverRunning || isStoppingServer) return;
setIsStoppingServer(true);
try {
await stopServer();
setServerRunning(false);
setActiveModel(null);
} catch (err) {
console.error("Failed to stop server:", err);
showAlert({ title: "Stop Server Failed", message: err.message || String(err), danger: true });
} finally {
setIsStoppingServer(false);
}
}, [serverRunning, isStoppingServer, showAlert]);
return (
<div className="app-container">
{/* Sidebar with Navigation Rail & Specs */}
{sidebarContent}
{/* Main Panel */}
<div className="main-content">
{/* Top telemetry specs chips bar */}
<TopStatusBar
telemetry={telemetry}
serverRunning={serverRunning}
activeModel={activeModel}
isLlmLoaded={isLlmLoaded}
onStopServer={handleStopServer}
isStoppingServer={isStoppingServer}
theme={theme}
setTheme={setTheme}
sidebarVisible={sidebarVisible}
onToggleSidebar={() => setSidebarVisible(!sidebarVisible)}
/>
{/* Dynamic Workspace Container */}
<div style={{ display: activeTab === "generator" ? "flex" : "none", flex: 1, flexDirection: "column", overflow: "hidden" }}>
<Generator
prompt={prompt}
setPrompt={setPrompt}
negativePrompt={negativePrompt}
setNegativePrompt={setNegativePrompt}
constraints={constraints}
setConstraints={setConstraints}
activeModel={activeModel}
generatedImages={generatedImages}
setGeneratedImages={setGeneratedImages}
isGenerating={isGenerating}
setIsGenerating={setIsGenerating}
generationProgress={generationProgress}
setGenerationProgress={setGenerationProgress}
setActiveTab={setActiveTab}
showAlert={showAlert}
showConfirm={showConfirm}
specs={specs}
textSettings={textSettings}
/>
{/* Simple Header */}
<header className="app-header">
<div className="app-header-left">
<h1 className="app-title">🎙️ Speech Transcriber</h1>
<p className="app-subtitle">Local AI audio transcription with Whisper</p>
</div>
<div style={{ display: activeTab === "models" ? "flex" : "none", flex: 1, flexDirection: "column", overflow: "hidden" }}>
<ModelManager
activeModel={activeModel}
setActiveModel={setActiveModel}
serverRunning={serverRunning}
setServerRunning={setServerRunning}
constraints={constraints}
setConstraints={setConstraints}
telemetry={telemetry}
backendOptions={backendOptions}
showAlert={showAlert}
showConfirm={showConfirm}
activeTab={activeTab}
specs={specs}
textSettings={textSettings}
/>
<div className="app-header-right">
<div className="telemetry-chips">
<span className="chip">CPU: {telemetry.cpu_usage}%</span>
<span className="chip">RAM: {telemetry.ram_used_gb.toFixed(1)}/{telemetry.ram_total_gb.toFixed(1)} GB</span>
{telemetry.gpu_name !== "Detecting..." && telemetry.gpu_name !== "Unavailable" && (
<span className="chip">GPU: {telemetry.gpu_name}</span>
)}
</div>
<button
className="theme-toggle"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
title="Toggle theme"
>
{theme === "dark" ? "☀️" : "🌙"}
</button>
</div>
</header>
<div style={{ display: activeTab === "chat" ? "flex" : "none", flex: 1, flexDirection: "column", overflow: "hidden" }}>
<TextChat
specs={specs}
showAlert={showAlert}
showConfirm={showConfirm}
textSettings={textSettings}
setTextSettings={setTextSettings}
setActiveModel={setActiveModel}
setServerRunning={setServerRunning}
conversations={conversations}
setConversations={setConversations}
activeConversationId={activeConversationId}
setActiveConversationId={setActiveConversationId}
showHistory={showHistory}
setShowHistory={setShowHistory}
saveConversationState={saveConversationState}
setIsLlmLoaded={setIsLlmLoaded}
/>
</div>
{/* Navigation Tabs */}
<nav className="app-nav">
<button
className={`nav-tab ${activeTab === "speech" ? "active" : ""}`}
onClick={() => setActiveTab("speech")}
>
🎙️ Transcriber
</button>
<button
className={`nav-tab ${activeTab === "models" ? "active" : ""}`}
onClick={() => setActiveTab("models")}
>
📁 Models
</button>
<button
className={`nav-tab ${activeTab === "settings" ? "active" : ""}`}
onClick={() => setActiveTab("settings")}
>
⚙️ Settings
</button>
</nav>
<div style={{ display: activeTab === "speech" ? "flex" : "none", flex: 1, flexDirection: "column", overflow: "hidden" }}>
{/* Main Content */}
<main className="app-main">
{activeTab === "speech" && (
<SpeechTranscriber
showAlert={showAlert}
showConfirm={showConfirm}
@@ -811,53 +241,32 @@ function App() {
speechSettings={speechSettings}
setSpeechSettings={setSpeechSettings}
/>
</div>
)}
<div style={{ display: activeTab === "tts" ? "flex" : "none", flex: 1, flexDirection: "column", overflow: "hidden" }}>
<TextToSpeech
{activeTab === "models" && (
<ModelManager
showAlert={showAlert}
showConfirm={showConfirm}
specs={specs}
speechSettings={speechSettings}
setSpeechSettings={setSpeechSettings}
/>
)}
{activeTab === "settings" && (
<Settings
showAlert={showAlert}
showConfirm={showConfirm}
selectedOutput={selectedTtsOutput}
onOutputsChanged={refreshTtsOutputs}
ttsSettings={ttsSettings}
setTtsSettings={setTtsSettings}
/>
</div>
<div style={{ display: activeTab === "settings" ? "flex" : "none", flex: 1, flexDirection: "column", overflow: "hidden" }}>
<Settings
constraints={constraints}
setConstraints={setConstraints}
activeModel={activeModel}
specs={specs}
backendOptions={backendOptions}
setBackendOptions={setBackendOptions}
serverRunning={serverRunning}
setServerRunning={setServerRunning}
setActiveModel={setActiveModel}
showAlert={showAlert}
showConfirm={showConfirm}
textSettings={textSettings}
setTextSettings={setTextSettings}
speechSettings={speechSettings}
setSpeechSettings={setSpeechSettings}
ttsSettings={ttsSettings}
setTtsSettings={setTtsSettings}
health={health}
cleanupItems={cleanupItems}
isReadinessBusy={isReadinessBusy}
refreshReadiness={refreshReadiness}
copyDiagnostics={copyDiagnostics}
cleanupSafeItems={cleanupSafeItems}
diagnosticsCopied={diagnosticsCopied}
theme={theme}
setTheme={setTheme}
fontSize={fontSize}
setFontSize={setFontSize}
/>
</div>
</div>
)}
</main>
{/* Dialog */}
{dialog && (
<div className="app-dialog-backdrop" role="presentation">
<div className="app-dialog" role="dialog" aria-modal="true" aria-labelledby="app-dialog-title">
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-455
View File
@@ -1,455 +0,0 @@
import React, { memo } from "react";
import { Image, FolderDown, MessageSquare, Mic, Settings, Shield, Terminal, ChevronDown, ChevronUp, Trash2, Volume2 } from "lucide-react";
function formatSidebarDate(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
const dateStr = date.toLocaleDateString();
const timeStr = date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', hour12: true });
return `${dateStr} ${timeStr}`;
}
function Sidebar({
collapsed = false,
activeTab,
setActiveTab,
specs,
conversations = [],
activeConversationId,
setActiveConversationId,
showHistory,
setShowHistory,
onDeleteConversation,
speechTranscriptions = [],
selectedSpeechTranscript,
setSelectedSpeechTranscript,
showSpeechHistory,
setShowSpeechHistory,
onDeleteSpeechTranscription,
ttsOutputs = [],
selectedTtsOutput,
setSelectedTtsOutput,
showTtsHistory,
setShowTtsHistory,
onDeleteTtsOutput
}) {
return (
<div className={`sidebar ${collapsed ? "collapsed" : ""}`}>
<div>
{/* Sidebar Header */}
<div className="sidebar-logo">
<Shield className="sidebar-logo-icon" />
<span className="sidebar-logo-text">Local AI Studio</span>
</div>
{/* Sidebar Navigation Links (Material 3 style) */}
<div className="nav-list">
<div
className={`nav-item ${activeTab === "generator" ? "active" : ""}`}
onClick={() => setActiveTab("generator")}
>
<Image size={20} />
<span>Image Generator</span>
</div>
<div className="nav-item-wrapper" style={{ display: "flex", flexDirection: "column" }}>
<div
className={`nav-item ${activeTab === "chat" ? "active" : ""}`}
onClick={() => setActiveTab("chat")}
style={{ display: "flex", justifyContent: "space-between", alignItems: "center", width: "100%", boxSizing: "border-box" }}
>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<MessageSquare size={20} />
<span>Text Chat</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
setShowHistory(!showHistory);
}}
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={showHistory ? "Hide Chat History" : "Show Chat History"}
>
{showHistory ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
</div>
{/* Sidebar Chat History List */}
{showHistory && (
<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)"
}}
>
{conversations.length === 0 ? (
<div style={{ padding: "8px 12px", fontSize: "0.78rem", color: "var(--md-sys-color-outline)", opacity: 0.8 }}>
No saved chats
</div>
) : (
conversations.map((conv) => {
const isActive = activeConversationId === conv.id;
return (
<div
key={conv.id}
onClick={() => {
setActiveConversationId(conv.id);
setActiveTab("chat");
}}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "6px 8px 6px 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={conv.title}
>
<span style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: 1,
fontWeight: isActive ? 600 : 400
}}>
{conv.title}
</span>
<button
onClick={(e) => {
e.stopPropagation();
onDeleteConversation(conv.id, e);
}}
style={{
background: "none",
border: "none",
color: "var(--md-sys-color-outline)",
cursor: "pointer",
padding: "2px",
marginLeft: "6px",
display: "flex",
alignItems: "center"
}}
className="sidebar-history-delete"
title="Delete Conversation"
>
<Trash2 size={12} />
</button>
</div>
);
})
)}
</div>
)}
</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: "row",
alignItems: "center",
justifyContent: "space-between",
padding: "7px 8px 7px 10px",
borderRadius: "var(--md-shape-corner-small)",
fontSize: "0.78rem",
cursor: "pointer",
background: isActive ? "var(--md-sys-color-secondary-container)" : "transparent",
color: isActive ? "var(--md-sys-color-on-secondary-container)" : "var(--md-sys-color-on-surface-variant)",
border: isActive ? "1px solid var(--md-sys-color-outline-variant)" : "1px solid transparent",
transition: "background 0.2s"
}}
className="sidebar-history-item"
title={title}
>
<div style={{ display: "flex", flexDirection: "column", gap: "2px", flex: 1, minWidth: 0 }}>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: isActive ? 600 : 400 }}>
{title}
</span>
{date && (
<span style={{ fontSize: "0.68rem", opacity: 0.72, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{date}
</span>
)}
</div>
<button
onClick={(e) => {
e.stopPropagation();
onDeleteSpeechTranscription?.(item, e);
}}
style={{
background: "none",
border: "none",
color: "var(--md-sys-color-outline)",
cursor: "pointer",
padding: "2px",
marginLeft: "6px",
display: "flex",
alignItems: "center"
}}
className="sidebar-history-delete"
title="Delete Transcription"
>
<Trash2 size={12} />
</button>
</div>
);
})
)}
</div>
)}
</div>
<div className="nav-item-wrapper" style={{ display: "flex", flexDirection: "column" }}>
<div
className={`nav-item ${activeTab === "tts" ? "active" : ""}`}
onClick={() => setActiveTab("tts")}
style={{ display: "flex", justifyContent: "space-between", alignItems: "center", width: "100%", boxSizing: "border-box" }}
>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<Volume2 size={20} />
<span>Text to Speech</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
setShowTtsHistory(!showTtsHistory);
}}
style={{
background: "none",
border: "none",
color: "inherit",
cursor: "pointer",
padding: "4px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "50%",
transition: "background-color 0.2s"
}}
className="history-toggle-arrow"
title={showTtsHistory ? "Hide TTS Outputs" : "Show TTS Outputs"}
>
{showTtsHistory ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
</div>
{showTtsHistory && (
<div
className="sidebar-history-list"
style={{
paddingLeft: "14px",
marginTop: "6px",
marginBottom: "6px",
display: "flex",
flexDirection: "column",
gap: "4px",
maxHeight: "220px",
overflowY: "auto",
borderLeft: "2px solid var(--border-color)"
}}
>
{ttsOutputs.length === 0 ? (
<div style={{ padding: "8px 12px", fontSize: "0.78rem", color: "var(--md-sys-color-outline)", opacity: 0.8 }}>
No saved audio
</div>
) : (
ttsOutputs.map((item) => {
const itemId = item.filename || item.metadata || item.audioFile;
const isActive = selectedTtsOutput && (selectedTtsOutput.filename || selectedTtsOutput.metadata || selectedTtsOutput.audioFile) === itemId;
const title = item.displayName || item.text || item.audioFile || item.filename || "TTS Output";
const date = formatSidebarDate(item.modifiedAt || item.createdAt);
return (
<div
key={itemId}
onClick={() => {
setSelectedTtsOutput(item);
setActiveTab("tts");
}}
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: "7px 8px 7px 10px",
borderRadius: "var(--md-shape-corner-small)",
fontSize: "0.78rem",
cursor: "pointer",
background: isActive ? "var(--md-sys-color-secondary-container)" : "transparent",
color: isActive ? "var(--md-sys-color-on-secondary-container)" : "var(--md-sys-color-on-surface-variant)",
border: isActive ? "1px solid var(--md-sys-color-outline-variant)" : "1px solid transparent",
transition: "background 0.2s"
}}
className="sidebar-history-item"
title={title}
>
<div style={{ display: "flex", flexDirection: "column", gap: "2px", flex: 1, minWidth: 0 }}>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: isActive ? 600 : 400 }}>
{title}
</span>
{date && (
<span style={{ fontSize: "0.68rem", opacity: 0.72, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{date}
</span>
)}
</div>
<button
onClick={(e) => {
e.stopPropagation();
onDeleteTtsOutput?.(item, e);
}}
style={{
background: "none",
border: "none",
color: "var(--md-sys-color-outline)",
cursor: "pointer",
padding: "2px",
marginLeft: "6px",
display: "flex",
alignItems: "center"
}}
className="sidebar-history-delete"
title="Delete TTS Output"
>
<Trash2 size={12} />
</button>
</div>
);
})
)}
</div>
)}
</div>
<div
className={`nav-item ${activeTab === "models" ? "active" : ""}`}
onClick={() => setActiveTab("models")}
>
<FolderDown size={20} />
<span>Model Manager</span>
</div>
<div
className={`nav-item ${activeTab === "settings" ? "active" : ""}`}
onClick={() => setActiveTab("settings")}
>
<Settings size={20} />
<span>Settings</span>
</div>
</div>
</div>
{/* Sidebar Footer with Host Telemetry System Specs */}
<div className="sidebar-footer">
<div className="sidebar-specs-header">
<Terminal size={12} />
<span>Host Specifications</span>
</div>
<div className="sidebar-specs-item" title={specs.cpu_name}>
CPU: {specs.cpu_name}
</div>
<div className="sidebar-specs-item" title={specs.gpu_name}>
GPU: {specs.gpu_name}
</div>
<div className="sidebar-specs-item">
Memory: {specs.ram_total_gb.toFixed(0)} GB RAM ({specs.cpu_cores_physical} Cores)
</div>
<div className="sidebar-specs-os">
OS: {specs.os_name}
</div>
</div>
</div>
);
}
export default memo(Sidebar);
@@ -290,8 +290,11 @@ export default function SpeechTranscriber({
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 });
const name = file.name.toLowerCase();
const allowedExt = [".wav", ".mp3", ".m4a", ".ogg", ".flac", ".webm", ".aac", ".m4b", ".opus", ".wma"];
const isAllowed = allowedExt.some(ext => name.endsWith(ext));
if (!isAllowed) {
showAlert?.({ title: "Unsupported Audio", message: "Supported formats: WAV, MP3, M4A, OGG, FLAC, WebM, AAC, M4B, OPUS, WMA", danger: true });
return;
}
setCurrentAudio(file, file.name);
@@ -349,14 +352,57 @@ export default function SpeechTranscriber({
setIsTranscribing(false);
};
const saveText = () => {
if (!resultText.trim()) return;
downloadBlob("transcription.txt", resultText, "text/plain;charset=utf-8");
const saveText = async () => {
if (!audioBlob) return;
await downloadTranscription("txt");
};
const saveJson = () => {
const payload = { ...(result || {}), text: resultText };
downloadBlob("transcription.json", JSON.stringify(payload, null, 2), "application/json;charset=utf-8");
const saveJson = async () => {
if (!audioBlob) return;
await downloadTranscription("json");
};
const saveMarkdown = async () => {
if (!audioBlob) return;
await downloadTranscription("md");
};
const downloadTranscription = async (format) => {
if (!audioBlob) return;
try {
const params = new URLSearchParams();
if (selectedModel) params.set("model", selectedModel);
params.set("language", speechSettings?.language || "auto");
params.set("format", format);
params.set("threads", String(speechSettings?.threads || 4));
params.set("backendPreference", speechSettings?.backendPreference || "auto");
if (speechSettings?.translate) params.set("translate", "true");
params.set("filename", audioName || "audio.wav");
params.set("output_filename", `transcription-${Date.now()}`);
const res = await fetch(`/api/speech/transcribe-file?${params.toString()}`, {
method: "POST",
headers: { "Content-Type": "audio/wav" },
body: audioBlob,
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || `Download failed (HTTP ${res.status})`);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `transcription-${Date.now()}.${format}`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
} catch (err) {
showAlert?.({ title: "Download Failed", message: err.message || String(err), danger: true });
}
};
return (
@@ -364,7 +410,7 @@ export default function SpeechTranscriber({
<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.
Record microphone audio or upload audio (WAV, MP3, M4A, OGG, FLAC, WebM, AAC, etc.), then transcribe locally with whisper.cpp.
</p>
</div>
@@ -464,8 +510,8 @@ export default function SpeechTranscriber({
<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>
<strong>{audioName || "Record or upload audio (WAV, MP3, M4A, OGG, FLAC, WebM, etc.)"}</strong>
<p>Audio files are converted to 16 kHz mono PCM16 WAV before transcription.</p>
</div>
</div>
@@ -485,8 +531,8 @@ export default function SpeechTranscriber({
)}
<label className="m3-btn m3-btn-outlined">
<Upload size={14} />
<span>Upload WAV</span>
<input type="file" accept=".wav,audio/wav" onChange={handleUploadAudio} hidden />
<span>Upload Audio</span>
<input type="file" accept=".wav,.mp3,.m4a,.ogg,.flac,.webm,.aac,.m4b,.opus,.wma,audio/*" onChange={handleUploadAudio} hidden />
</label>
{audioBlob && (
<button className="m3-btn m3-btn-error" onClick={clearCurrentAudio} disabled={isTranscribing || isRecording}>
@@ -509,7 +555,7 @@ export default function SpeechTranscriber({
disabled={!audioBlob || !selectedModel || !status.backendInstalled}
>
<Mic size={14} />
<span>Transcribe</span>
<span>Convert</span>
</button>
)}
{isTranscribing && <span className="text-progress"><LoaderCircle className="progress-spinner" size={14} /> Transcribing locally...</span>}
@@ -538,6 +584,10 @@ export default function SpeechTranscriber({
<Save size={14} />
<span>JSON</span>
</button>
<button className="m3-btn m3-btn-outlined" onClick={saveMarkdown} disabled={!resultText.trim()}>
<Save size={14} />
<span>MD</span>
</button>
{status.ready && (
<button className="m3-btn m3-btn-error" onClick={() => stopSpeech().then(refresh)}>
<Square size={14} />
File diff suppressed because it is too large. Load diff
@@ -1,317 +0,0 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
LoaderCircle,
Play,
RefreshCw,
Save,
Square,
Volume2,
} from "lucide-react";
import {
getTtsStatus,
listTtsModels,
speakTts,
startTts,
stopTts,
} from "../services/api";
const FALLBACK_VOICES = [
{ id: "af_heart", name: "Heart", language: "en-us", gender: "Female" },
{ id: "af_bella", name: "Bella", language: "en-us", gender: "Female" },
{ id: "am_michael", name: "Michael", language: "en-us", gender: "Male" },
{ id: "bf_emma", name: "Emma", language: "en-gb", gender: "Female" },
{ id: "bm_george", name: "George", language: "en-gb", gender: "Male" },
];
function downloadUrl(url, filename) {
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
}
export default function TextToSpeech({
showAlert,
selectedOutput,
onOutputsChanged,
ttsSettings,
setTtsSettings,
}) {
const [status, setStatus] = useState({ ready: false, running: false, runtimeInstalled: false, settings: {}, voices: [] });
const [models, setModels] = useState([]);
const [selectedModel, setSelectedModel] = useState("");
const [text, setText] = useState("Hello from Local AI Studio text to speech.");
const [isLoadingModel, setIsLoadingModel] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const [output, setOutput] = useState(null);
const abortRef = useRef(null);
const installedModels = useMemo(() => models.filter((model) => model.installed), [models]);
const voices = status.voices?.length ? status.voices : FALLBACK_VOICES;
const selectedVoice = ttsSettings?.voice || "af_heart";
const speed = ttsSettings?.speed || 1;
const updateTtsSetting = (key, value) => {
setTtsSettings((prev) => ({ ...prev, [key]: value }));
};
const refresh = useCallback(async () => {
const [nextStatus, nextModels] = await Promise.all([
getTtsStatus(),
listTtsModels(),
]);
setStatus(nextStatus);
setModels(nextModels);
setSelectedModel((current) => {
const installed = nextModels.filter((model) => model.installed);
if (current && installed.some((model) => model.filename === current || model.id === current)) return current;
const active = nextStatus.settings?.model;
if (active && installed.some((model) => model.filename === active)) return active;
const preferred = ttsSettings?.model;
if (preferred && installed.some((model) => model.filename === preferred)) return preferred;
return installed.find((model) => model.recommended)?.filename || installed[0]?.filename || "";
});
}, [ttsSettings?.model]);
useEffect(() => {
refresh().catch((err) => showAlert?.({ title: "TTS Status Failed", message: err.message || String(err), danger: true }));
const interval = setInterval(() => {
refresh().catch(() => {});
}, 2500);
return () => clearInterval(interval);
}, [refresh, showAlert]);
useEffect(() => {
if (!selectedOutput) return;
setOutput(selectedOutput);
setText(selectedOutput.text || "");
if (selectedOutput.voice) updateTtsSetting("voice", selectedOutput.voice);
if (selectedOutput.speed) updateTtsSetting("speed", selectedOutput.speed);
if (selectedOutput.model) setSelectedModel(selectedOutput.model);
}, [selectedOutput]);
useEffect(() => {
return () => abortRef.current?.abort();
}, []);
const handleLoadModel = async () => {
if (!selectedModel) return;
setIsLoadingModel(true);
try {
await startTts(selectedModel, { voice: selectedVoice, speed });
updateTtsSetting("model", selectedModel);
await refresh();
} catch (err) {
showAlert?.({ title: "TTS Model Load Failed", message: err.message || String(err), danger: true });
} finally {
setIsLoadingModel(false);
}
};
const handleStop = async () => {
try {
await stopTts();
await refresh();
} catch (err) {
showAlert?.({ title: "Stop TTS Failed", message: err.message || String(err), danger: true });
}
};
const handleGenerate = async () => {
if (!text.trim() || !selectedModel) return;
setIsGenerating(true);
const controller = new AbortController();
abortRef.current = controller;
try {
if (!status.ready || status.settings?.model !== selectedModel) {
setIsLoadingModel(true);
try {
await startTts(selectedModel, { voice: selectedVoice, speed });
updateTtsSetting("model", selectedModel);
await refresh();
} finally {
setIsLoadingModel(false);
}
}
const generated = await speakTts(text, {
model: selectedModel,
voice: selectedVoice,
speed,
signal: controller.signal,
});
setOutput(generated);
await refresh();
await onOutputsChanged?.();
} catch (err) {
if (err.name !== "AbortError") {
showAlert?.({ title: "TTS Generation Failed", message: err.message || String(err), danger: true });
}
} finally {
abortRef.current = null;
setIsGenerating(false);
}
};
const handleCancel = () => {
abortRef.current?.abort();
setIsGenerating(false);
};
const isLoaded = status.ready && status.settings?.model === selectedModel;
return (
<div className="workspace-area speech-workspace">
<div className="workspace-title-section">
<h2 className="workspace-title">Text to Speech</h2>
<p className="workspace-subtitle">
Generate local WAV narration with Kokoro ONNX.
</p>
</div>
<div className="speech-grid">
<section className="m3-card speech-panel">
<div className="speech-panel-header">
<h3>Runtime</h3>
<span className={`status-chip ${status.runtimeInstalled ? "" : "offline"}`}>
{status.runtimeInstalled ? (status.ready ? "Ready" : "Installed") : "Runtime missing"}
</span>
</div>
{status.error && <div className="text-progress error">{status.error}</div>}
<label className="speech-label">
Kokoro model
<select className="m3-input" value={selectedModel} onChange={(event) => setSelectedModel(event.target.value)}>
{installedModels.length === 0 && <option value="">No downloaded TTS models</option>}
{installedModels.map((model) => (
<option key={model.id || model.filename} value={model.filename}>
{model.name || model.filename}
</option>
))}
</select>
</label>
<div className="speech-controls-row">
<label className="speech-label">
Voice
<select className="m3-input" value={selectedVoice} onChange={(event) => updateTtsSetting("voice", event.target.value)}>
{voices.map((voice) => (
<option key={voice.id} value={voice.id}>
{voice.name} ({voice.gender}, {voice.language})
</option>
))}
</select>
</label>
<label className="speech-label">
Speed
<input
className="m3-input"
type="number"
min="0.5"
max="2"
step="0.05"
value={speed}
onChange={(event) => updateTtsSetting("speed", Math.max(0.5, Math.min(2, Number(event.target.value) || 1)))}
/>
</label>
</div>
<div className="speech-button-row">
<button
className="m3-btn m3-btn-filled"
onClick={handleLoadModel}
disabled={!selectedModel || isLoadingModel || isGenerating || isLoaded}
>
{isLoadingModel ? <LoaderCircle className="progress-spinner" size={14} /> : <Play size={14} />}
<span>{isLoaded ? "Loaded" : isLoadingModel ? "Loading" : "Load"}</span>
</button>
<button className="m3-btn m3-btn-outlined" onClick={refresh}>
<RefreshCw size={14} />
<span>Refresh</span>
</button>
{status.ready && (
<button className="m3-btn m3-btn-error" onClick={handleStop}>
<Square size={14} />
<span>Stop Runtime</span>
</button>
)}
</div>
{installedModels.length === 0 && (
<div className="text-progress">
Download a Kokoro model from Model Manager, TTS Models first.
</div>
)}
</section>
<section className="m3-card speech-panel">
<div className="speech-panel-header">
<h3>Input</h3>
<span className="status-chip">{text.trim().length} chars</span>
</div>
<textarea
className="m3-input"
value={text}
onChange={(event) => setText(event.target.value)}
rows={10}
maxLength={5000}
style={{ width: "100%", resize: "vertical", minHeight: "220px", lineHeight: 1.45 }}
placeholder="Type text to turn into speech..."
/>
<div className="speech-button-row">
{isGenerating ? (
<button className="m3-btn m3-btn-error" onClick={handleCancel}>
<Square size={14} />
<span>Cancel</span>
</button>
) : (
<button
className="m3-btn m3-btn-filled"
onClick={handleGenerate}
disabled={!text.trim() || !selectedModel || !status.runtimeInstalled}
>
<Volume2 size={14} />
<span>Generate WAV</span>
</button>
)}
{isGenerating && <span className="text-progress"><LoaderCircle className="progress-spinner" size={14} /> Generating locally...</span>}
</div>
</section>
</div>
<section className="m3-card speech-result-panel">
<div className="speech-panel-header">
<h3>Output</h3>
<div className="speech-button-row">
<button className="m3-btn m3-btn-outlined" onClick={() => output?.url && downloadUrl(output.url, output.audioFile || "tts.wav")} disabled={!output?.url}>
<Save size={14} />
<span>WAV</span>
</button>
</div>
</div>
{output?.url ? (
<div style={{ display: "flex", flexDirection: "column", gap: "14px" }}>
<audio controls src={output.url} style={{ width: "100%" }} />
<div className="text-progress">
{output.voiceName || output.voice} &bull; {output.modelName || output.model} &bull; {new Date(output.createdAt).toLocaleString()}
</div>
<textarea
className="m3-input"
value={output.text || text}
onChange={(event) => setOutput((prev) => ({ ...(prev || {}), text: event.target.value }))}
rows={5}
style={{ width: "100%", resize: "vertical", minHeight: "120px", lineHeight: 1.45 }}
/>
</div>
) : (
<div className="empty-state" style={{ padding: "32px 0" }}>
<Volume2 size={42} />
<p>Generated speech will appear here.</p>
</div>
)}
</section>
</div>
);
}
@@ -1,178 +0,0 @@
import React, { memo, useState, useRef, useEffect } from "react";
import { Square, RefreshCw, Sun, Moon, Palette, Check, PanelLeftClose, PanelLeft } from "lucide-react";
import { THEMES } from "../themes";
const truncateModelName = (name) => {
if (!name || typeof name !== "string") return "";
let cleanName = name;
if (name.includes("--")) {
const parts = name.split("--");
cleanName = parts[parts.length - 1];
}
if (cleanName.length > 25) {
return cleanName.substring(0, 22) + "...";
}
return cleanName;
};
function TopStatusBar({
telemetry,
serverRunning,
activeModel,
isLlmLoaded = false,
onStopServer,
isStoppingServer = false,
theme,
setTheme,
sidebarVisible,
onToggleSidebar
}) {
const [showThemeMenu, setShowThemeMenu] = useState(false);
const menuRef = useRef(null);
useEffect(() => {
function handleClickOutside(event) {
if (menuRef.current && !menuRef.current.contains(event.target)) {
setShowThemeMenu(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const formatGb = (value, { allowZero = false } = {}) => {
const number = Number(value);
if (!Number.isFinite(number) || number < 0) return "--";
if (number === 0 && !allowZero) return "--";
return number.toFixed(number >= 10 ? 0 : 1);
};
const getStatusText = () => {
if (isLlmLoaded) return "Model Loaded (Text)";
if (activeModel) return "Model Loaded (Image)";
if (serverRunning) return "Server Active";
return "Local Mode";
};
const getStatusClass = () => {
if (activeModel || isLlmLoaded) return "status-indicator";
if (serverRunning) return "status-indicator busy";
return "status-indicator offline";
};
const isDark = (theme || "dark").startsWith("dark");
return (
<div className="top-status-bar">
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
<button
onClick={onToggleSidebar}
className="sidebar-toggle-btn"
title={sidebarVisible ? "Collapse Sidebar" : "Expand Sidebar"}
>
{sidebarVisible ? <PanelLeftClose size={18} /> : <PanelLeft size={18} />}
</button>
<div className="current-model-info">
<div className={getStatusClass()}></div>
<span style={{ fontWeight: 600, fontSize: "0.95rem" }}>
{getStatusText()}
</span>
{(activeModel || (typeof isLlmLoaded === "string" && isLlmLoaded)) && (
<>
<span style={{ color: "var(--md-sys-color-outline-variant)" }}>|</span>
<span
style={{ color: "var(--md-sys-color-primary)", fontWeight: 700 }}
title={activeModel || isLlmLoaded}
>
{truncateModelName(activeModel || isLlmLoaded)}
</span>
</>
)}
</div>
</div>
<div className="telemetry-group" style={{ position: "relative" }}>
<button
className="theme-toggle-btn"
onClick={() => setTheme(isDark ? "light" : "dark")}
title={`Switch to ${isDark ? "light" : "dark"} theme`}
>
{isDark ? <Sun size={18} /> : <Moon size={18} />}
</button>
<div ref={menuRef} style={{ position: "relative", display: "inline-block" }}>
<button
className={`theme-toggle-btn ${showThemeMenu ? "active" : ""}`}
onClick={() => setShowThemeMenu(!showThemeMenu)}
title="Choose a custom color theme"
style={{ marginRight: "12px" }}
>
<Palette size={18} />
</button>
{showThemeMenu && (
<div className="theme-dropdown-menu">
<div className="theme-dropdown-header">Select Theme</div>
<div className="theme-dropdown-divider"></div>
{THEMES.map((t) => {
const isActive = theme === t.id;
return (
<button
key={t.id}
className={`theme-dropdown-item ${isActive ? "active" : ""}`}
onClick={() => {
setTheme(t.id);
setShowThemeMenu(false);
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "8px", width: "100%" }}>
<div style={{ display: "flex", gap: "2px" }}>
<div style={{ width: "8px", height: "8px", borderRadius: "50%", background: t.primary }} />
<div style={{ width: "8px", height: "8px", borderRadius: "50%", background: t.secondary }} />
</div>
<span className="theme-name-text">{t.name}</span>
{isActive && <Check size={12} style={{ marginLeft: "auto", color: "var(--md-sys-color-primary)" }} />}
</div>
</button>
);
})}
</div>
)}
</div>
{serverRunning && (
<button
className="m3-btn m3-btn-error"
style={{ height: "34px", padding: "0 14px" }}
onClick={onStopServer}
disabled={isStoppingServer}
title="Stop local model server"
>
{isStoppingServer ? <RefreshCw className="progress-spinner" size={14} /> : <Square size={14} />}
<span>{isStoppingServer ? "Stopping" : "Stop Server"}</span>
</button>
)}
{/* CPU Telemetry Chip */}
<div className="telemetry-chip" title="CPU Utilization">
<span>CPU: {Number.isFinite(Number(telemetry.cpu_usage)) ? telemetry.cpu_usage : "--"}%</span>
</div>
{/* RAM Telemetry Chip */}
<div className="telemetry-chip" title="System Memory Usage">
<span>RAM: {formatGb(telemetry.ram_used_gb)} / {formatGb(telemetry.ram_total_gb)} GB</span>
</div>
{/* GPU VRAM Telemetry Chip */}
{telemetry.vram_total_gb > 0 && (
<div className="telemetry-chip" title={`${telemetry.gpu_name} VRAM`}>
<span>VRAM: {formatGb(telemetry.vram_used_gb, { allowZero: true })} / {formatGb(telemetry.vram_total_gb)} GB</span>
</div>
)}
</div>
</div>
);
}
export default memo(TopStatusBar);
+8 -2
View File
@@ -683,16 +683,22 @@ export async function transcribeSpeech(fileOrBlob, options = {}) {
if (options.backendPreference) params.set("backendPreference", options.backendPreference);
if (options.translate) params.set("translate", "true");
let contentType = "audio/wav";
if (fileOrBlob instanceof File && fileOrBlob.name) {
const ext = fileOrBlob.name.toLowerCase().split(".").pop();
const mimeTypes = {wav: "audio/wav", mp3: "audio/mpeg", m4a: "audio/mp4", ogg: "audio/ogg", flac: "audio/flac", webm: "audio/webm", aac: "audio/aac", m4b: "audio/mp4", opus: "audio/opus", wma: "audio/x-ms-wma"};
contentType = mimeTypes[ext] || "audio/wav";
}
const res = await fetch(`/api/speech/transcribe?${params.toString()}`, {
method: "POST",
headers: { "Content-Type": "audio/wav" },
headers: { "Content-Type": contentType },
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", {