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", {
+134 -3
View File
@@ -2425,7 +2425,7 @@ async function stopSpeech() {
speechTranscriptionState = { active: false, phase: "", progress: 0, model: "", filename: "" };
}
function readBinaryBody(req, limitBytes = 250 * 1024 * 1024) {
function readBinaryBody(req, limitBytes = 1024 * 1024 * 1024) {
return new Promise((resolve, reject) => {
const chunks = [];
let bytes = 0;
@@ -2449,6 +2449,24 @@ function isWaveBuffer(buffer) {
buffer.toString("ascii", 8, 12) === "WAVE";
}
function isAudioFile(buffer) {
try {
if (!buffer || buffer.length < 12) return false;
if (isWaveBuffer(buffer)) return true;
const magic = buffer.toString("ascii", 0, 3);
if (magic === "ID3") return true; // MP3 with ID3 tag
const ftyp = buffer.toString("ascii", 4, 8);
if (ftyp === "ftyp") return true; // MP4/M4A container
const ogg = buffer.toString("ascii", 0, 4);
if (ogg === "OggS") return true; // OGG/OPUS
const flac = buffer.toString("ascii", 0, 4);
if (flac === "fLaC") return true; // FLAC
return false;
} catch (_) {
return false;
}
}
function saveTranscript(result) {
const createdAt = new Date().toISOString();
const stamp = createdAt.replace(/[:.]/g, "-");
@@ -2600,6 +2618,62 @@ function transcribeWavBuffer(buffer, options = {}) {
});
}
function transcribeAudioBuffer(buffer, options = {}) {
if (isWaveBuffer(buffer)) {
return transcribeWavBuffer(buffer, options);
}
return new Promise((resolve, reject) => {
const backendPreference = normalizeSpeechBackendPreference(options.backendPreference || speechSettings.backendPreference);
const backend = getSpeechBackend(backendPreference);
if (!backend.cli) {
reject(new Error(`${backend.label || "Selected"} whisper.cpp CLI backend is not installed.`));
return;
}
const model = resolveSpeechModel(options.model || speechSettings.model);
const language = String(options.language || speechSettings.language || "auto");
const sourceFilename = safeOutputName(options.filename || "recording") || "recording";
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const tempBase = path.join(TRANSCRIPTIONS, `.speech-${stamp}-${sourceFilename.replace(/\.[^.]+$/, "")}`);
const inputPath = `${tempBase}-input`;
const wavPath = `${tempBase}.wav`;
const outBase = `${tempBase}-out`;
fs.writeFileSync(inputPath, buffer);
speechTranscriptionState = {
active: true,
phase: "Converting audio...",
progress: -1,
model: model.filename,
filename: sourceFilename,
};
const ffmpegArgs = ["-y", "-i", inputPath, "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", wavPath];
console.log(" [speech] Converting with ffmpeg:", ffmpegArgs.join(" "));
const ffmpegProc = spawn("ffmpeg", ffmpegArgs, {
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
let ffmpegStderr = "";
ffmpegProc.stderr.on("data", (data) => { ffmpegStderr += data.toString(); });
ffmpegProc.on("exit", (code) => {
try { fs.unlinkSync(inputPath); } catch (_) {}
if (code !== 0) {
const message = (ffmpegStderr || `ffmpeg exited with code ${code}`).trim().slice(-1200);
speechError = message;
try { fs.unlinkSync(wavPath); } catch (_) {}
reject(new Error("Audio conversion failed: " + message));
return;
}
const wavBuffer = fs.readFileSync(wavPath);
try { fs.unlinkSync(wavPath); } catch (_) {}
transcribeWavBuffer(wavBuffer, options).then(resolve).catch((err) => {
try { fs.unlinkSync(wavPath); } catch (_) {}
reject(err);
});
});
});
}
function pingLlmReady(expectedModel = "") {
return new Promise((resolve) => {
const req = http.get(`http://127.0.0.1:${PORT_LLM}/v1/models`, (res) => {
@@ -5956,10 +6030,10 @@ const server = http.createServer(async (req, res) => {
const parsed = new URL(req.url, `http://${req.headers.host || "localhost"}`);
try {
const audio = await readBinaryBody(req);
const result = await runExclusiveSpeechOperation(() => transcribeWavBuffer(audio, {
const result = await runExclusiveSpeechOperation(() => transcribeAudioBuffer(audio, {
model: parsed.searchParams.get("model") || speechSettings.model,
language: parsed.searchParams.get("language") || "auto",
filename: parsed.searchParams.get("filename") || req.headers["x-filename"] || "recording.wav",
filename: parsed.searchParams.get("filename") || req.headers["x-filename"] || "recording",
threads: parsed.searchParams.get("threads") || speechSettings.threads,
backendPreference: parsed.searchParams.get("backendPreference") || speechSettings.backendPreference,
translate: parsed.searchParams.get("translate") === "true",
@@ -5971,6 +6045,63 @@ const server = http.createServer(async (req, res) => {
}
}
// API endpoint that returns transcription as downloadable file (txt, json, md)
if (req.url.startsWith("/api/speech/transcribe-file") && req.method === "POST") {
const parsed = new URL(req.url, `http://${req.headers.host || "localhost"}`);
try {
const audio = await readBinaryBody(req);
const outputFormat = parsed.searchParams.get("format") || "txt";
const result = await runExclusiveSpeechOperation(() => transcribeAudioBuffer(audio, {
model: parsed.searchParams.get("model") || speechSettings.model,
language: parsed.searchParams.get("language") || "auto",
filename: parsed.searchParams.get("filename") || req.headers["x-filename"] || "recording",
threads: parsed.searchParams.get("threads") || speechSettings.threads,
backendPreference: parsed.searchParams.get("backendPreference") || speechSettings.backendPreference,
translate: parsed.searchParams.get("translate") === "true",
}));
const filename = parsed.searchParams.get("output_filename") || `transcription-${Date.now()}`;
let fileContent = "";
let contentType = "text/plain;charset=utf-8";
let extension = "txt";
if (outputFormat === "json") {
fileContent = JSON.stringify({
text: result.text,
model: result.model,
modelName: result.modelName,
language: result.language,
backend: result.backend,
backendMode: result.backendMode,
sourceFilename: result.sourceFilename,
durationMs: result.durationMs,
raw: result.raw,
createdAt: result.createdAt
}, null, 2);
contentType = "application/json;charset=utf-8";
extension = "json";
} else if (outputFormat === "md") {
fileContent = `# Trascrizione\n\n${result.text}\n\n---\n*Modello: ${result.modelName || result.model || "unknown"}\n*Lingua: ${result.language || "auto"}\n*Backend: ${result.backend || "unknown"} (${result.backendMode || ""})\n*File sorgente: ${result.sourceFilename || "unknown"}\n*Durata elaborazione: ${result.durationMs ? (result.durationMs / 1000).toFixed(1) : "?"}s\n*Generato il: ${new Date().toISOString()}`;
contentType = "text/markdown;charset=utf-8";
extension = "md";
} else {
fileContent = result.text;
contentType = "text/plain;charset=utf-8";
extension = "txt";
}
res.writeHead(200, {
"Content-Type": contentType,
"Content-Disposition": `attachment; filename="${filename}.${extension}"`,
"Content-Length": Buffer.byteLength(fileContent, "utf8")
});
res.end(fileContent);
} catch (err) {
speechError = err.message || String(err);
return json(res, err.message?.includes("too large") ? 413 : 500, { ok: false, error: speechError });
}
}
if (req.url === "/api/speech/download-model" && req.method === "POST") {
const body = await readJsonBody(req, res);
if (!body) return;
+19 -40
View File
@@ -1,5 +1,5 @@
@echo off
title Uncensored AI Studio
title Speech Transcriber
cd /d "%~dp0"
set APP=%~dp0app
@@ -7,18 +7,11 @@ set NODE=%APP%\tools\node-win\node.exe
set NPM=%APP%\tools\node-win\npm.cmd
set DIST=%APP%\dist\index.html
set SETUP=%~dp0scripts\setup\setup.ps1
set CUDA_BACKEND=%APP%\backend\win\cuda\sd-cuda.exe
set VULKAN_BACKEND=%APP%\backend\win\vulkan\sd-vulkan.exe
set LLM_CUDA_BACKEND=%APP%\llm-backend\win\cuda\llama-server.exe
set LLM_HIP_BACKEND=%APP%\llm-backend\win\hip\llama-server.exe
set LLM_VULKAN_BACKEND=%APP%\llm-backend\win\vulkan\llama-server.exe
set LLM_SYCL_BACKEND=%APP%\llm-backend\win\sycl\llama-server.exe
set LLM_CPU_BACKEND=%APP%\llm-backend\win\cpu\llama-server.exe
set SPEECH_BACKEND=%APP%\speech-backend\win\cpu\whisper-cli.exe
set TTS_RUNTIME=%APP%\tts-runtime\node_modules\kokoro-js
set SPEECH_BACKEND_VULKAN=%APP%\speech-backend\win\vulkan\whisper-cli.exe
set SPEECH_BACKEND_CUDA=%APP%\speech-backend\win\cuda\whisper-cli.exe
set SERVE=%~dp0scripts\server\serve.cjs
if "%FRONTEND_PORT%"=="" set FRONTEND_PORT=1420
if "%LLM_PORT%"=="" set LLM_PORT=10086
set SETUP_REASON=
set SETUP_MODE=Repair
@@ -36,44 +29,35 @@ if not exist "%DIST%" (
set SETUP_REASON=Frontend build is missing.
goto :run_setup
)
if not exist "%LLM_CUDA_BACKEND%" if not exist "%LLM_HIP_BACKEND%" if not exist "%LLM_VULKAN_BACKEND%" if not exist "%LLM_SYCL_BACKEND%" if not exist "%LLM_CPU_BACKEND%" (
set SETUP_REASON=llama.cpp text backend is missing.
goto :run_setup
)
if not exist "%SPEECH_BACKEND%" (
set SETUP_REASON=whisper.cpp speech backend is missing.
goto :run_setup
if not exist "%SPEECH_BACKEND_VULKAN%" (
if not exist "%SPEECH_BACKEND_CUDA%" (
set SETUP_REASON=whisper.cpp speech backend is missing.
goto :run_setup
)
)
)
if not exist "%TTS_RUNTIME%" (
set SETUP_REASON=Kokoro text-to-speech runtime is missing.
goto :run_setup
)
if exist "%CUDA_BACKEND%" goto :launch
if exist "%VULKAN_BACKEND%" goto :launch
set SETUP_REASON=No backend binary is installed.
goto :run_setup
goto :launch
:run_setup
echo.
echo ============================================================
echo UNCENSORED AI STUDIO ^| %SETUP_MODE%
echo SPEECH TRANSCRIBER ^| %SETUP_MODE%
echo ============================================================
echo.
if "%SETUP_MODE%"=="First-Time Setup" (
echo This looks like your first run. Setting up automatically...
) else (
echo Uncensored AI Studio needs a quick repair before launch.
echo Speech Transcriber needs a quick repair before launch.
)
if not "%SETUP_REASON%"=="" echo Reason: %SETUP_REASON%
echo Models are not downloaded during setup. Download or import them in the app.
echo Whisper models are not downloaded during setup. Download or import them in the app.
echo.
echo Press any key to continue, or Ctrl+C to cancel.
pause >nul
:: Clear old frontend and backend server processes before setup so app/tools/node-win can be replaced
for /f "tokens=5" %%a in ('netstat -aon 2^>nul ^| findstr ":%FRONTEND_PORT% "') do taskkill /f /pid %%a >nul 2>nul
for /f "tokens=5" %%a in ('netstat -aon 2^>nul ^| findstr ":8080 "') do taskkill /f /pid %%a >nul 2>nul
for /f "tokens=5" %%a in ('netstat -aon 2^>nul ^| findstr ":%LLM_PORT% "') do taskkill /f /pid %%a >nul 2>nul
powershell -ExecutionPolicy Bypass -File "%SETUP%"
if errorlevel 1 (
@@ -90,18 +74,16 @@ goto :launch
:launch
echo.
echo ============================================================
echo UNCENSORED AI STUDIO ^| Launching...
echo SPEECH TRANSCRIBER ^| Launching...
echo ============================================================
echo.
:: Clear frontend and backend ports to prevent address conflicts.
echo Clearing frontend port %FRONTEND_PORT%, backend port 8080, and text port %LLM_PORT%...
:: Clear frontend port to prevent address conflicts.
echo Clearing frontend port %FRONTEND_PORT%...
for /f "tokens=5" %%a in ('netstat -aon 2^>nul ^| findstr ":%FRONTEND_PORT% "') do taskkill /f /pid %%a >nul 2>nul
for /f "tokens=5" %%a in ('netstat -aon 2^>nul ^| findstr ":8080 "') do taskkill /f /pid %%a >nul 2>nul
for /f "tokens=5" %%a in ('netstat -aon 2^>nul ^| findstr ":%LLM_PORT% "') do taskkill /f /pid %%a >nul 2>nul
:: Start frontend server + backend manager (serve.cjs manages sd-vulkan.exe)
echo Starting Uncensored AI Studio...
:: Start frontend server + backend manager
echo Starting Speech Transcriber...
echo Opening browser at http://localhost:%FRONTEND_PORT%...
start /b cmd /c "timeout /t 2 >nul && start http://localhost:%FRONTEND_PORT%"
@@ -109,10 +91,7 @@ echo.
echo ============================================================
echo Running!
echo Web UI: http://localhost:%FRONTEND_PORT%
echo GPU API: Auto-selected by the app (starts at 8080)
echo Text API: Starts when a GGUF model is loaded (port %LLM_PORT%)
echo Speech: Managed locally by the app
echo TTS: Managed locally by the app
echo Speech: Managed locally by the app (whisper.cpp)
echo.
echo Press Ctrl+C in this window to stop all services.
echo ============================================================