diff --git a/app/frontend/src/App.css b/app/frontend/src/App.css
index b5c1fec..364617e 100644
--- a/app/frontend/src/App.css
+++ b/app/frontend/src/App.css
@@ -3349,6 +3349,14 @@ html[data-theme^="dark"] .settings-section-header:hover {
transition: all 0.3s ease;
}
+.settings-option-desc {
+ font-size: 0.75rem;
+ color: var(--md-sys-color-outline);
+ font-weight: 300;
+ opacity: 0.85;
+ line-height: 1.4;
+}
+
.settings-subsection:hover {
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.08);
border-color: color-mix(in srgb, var(--md-sys-color-primary) 15%, var(--md-sys-color-card-border));
@@ -3516,3 +3524,45 @@ html[data-theme^="dark"] .settings-subsection:hover {
opacity: 0.85;
}
+.sidebar-history-delete {
+ transition: color 0.2s, background-color 0.2s, opacity 0.2s;
+ border-radius: 4px;
+ opacity: 0.6;
+}
+
+.sidebar-history-delete:hover {
+ color: var(--md-sys-color-error) !important;
+ background-color: rgba(239, 68, 68, 0.1) !important;
+ opacity: 1 !important;
+}
+
+.sidebar-history-item:hover .sidebar-history-delete {
+ opacity: 1;
+}
+
+/* Global modern scrollbars */
+::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+::-webkit-scrollbar-thumb {
+ background: color-mix(in srgb, var(--md-sys-color-outline) 25%, transparent);
+ border-radius: 99px;
+}
+::-webkit-scrollbar-thumb:hover {
+ background: color-mix(in srgb, var(--md-sys-color-outline) 45%, transparent);
+}
+
+/* Firefox */
+* {
+ scrollbar-width: thin;
+ scrollbar-color: color-mix(in srgb, var(--md-sys-color-outline) 25%, transparent) transparent;
+}
+
+.settings-expanded-content {
+ animation: fadeInSlide 0.3s var(--md-transition-easing);
+}
+
diff --git a/app/frontend/src/App.jsx b/app/frontend/src/App.jsx
index 11412fe..39b6607 100644
--- a/app/frontend/src/App.jsx
+++ b/app/frontend/src/App.jsx
@@ -6,7 +6,7 @@ import ModelManager from "./components/ModelManager";
import Settings from "./components/Settings";
import TextChat from "./components/TextChat";
import SpeechTranscriber from "./components/SpeechTranscriber";
-import { cleanupCandidates, formatBytes, getCleanupCandidates, getDiagnostics, getHardwareSpecs, getHealth, getTelemetry, getBackendOptions, getBackendStatus, listGeneratedOutputs, listSpeechTranscriptions, stopServer } from "./services/api";
+import { cleanupCandidates, formatBytes, getCleanupCandidates, getDiagnostics, getHardwareSpecs, getHealth, getTelemetry, getBackendOptions, getBackendStatus, listGeneratedOutputs, listSpeechTranscriptions, deleteSpeechTranscription, stopServer } from "./services/api";
import "./App.css";
function App() {
@@ -160,6 +160,29 @@ function App() {
localStorage.setItem("textSettings", JSON.stringify(textSettings));
}, [textSettings]);
+ const [speechSettings, setSpeechSettings] = useState(() => {
+ const saved = localStorage.getItem("speechSettings");
+ if (saved) {
+ try {
+ const parsed = JSON.parse(saved);
+ return {
+ language: parsed.language || "auto",
+ threads: Math.max(1, Math.min(32, Number(parsed.threads) || Math.max(1, Math.min(8, navigator.hardwareConcurrency || 4)))),
+ translate: parsed.translate === true,
+ };
+ } catch (_) {}
+ }
+ return {
+ language: "auto",
+ threads: Math.max(1, Math.min(8, navigator.hardwareConcurrency || 4)),
+ translate: false,
+ };
+ });
+
+ useEffect(() => {
+ localStorage.setItem("speechSettings", JSON.stringify(speechSettings));
+ }, [speechSettings]);
+
// Lifted Chat History States
const [conversations, setConversations] = useState([]);
const [activeConversationId, setActiveConversationId] = useState(null);
@@ -257,6 +280,37 @@ function App() {
}
}, []);
+ const handleDeleteSpeechTranscription = useCallback(async (item, e) => {
+ if (e) e.stopPropagation();
+ const itemId = item.filename || item.metadata || item.textFile;
+ if (!itemId) return;
+
+ const ok = await showConfirm({
+ title: "Delete Transcription",
+ message: `Are you sure you want to delete "${item.displayName || item.sourceFilename || "this transcription"}"? This action cannot be undone.`,
+ confirmLabel: "Delete",
+ danger: true
+ });
+ if (!ok) return;
+
+ try {
+ await deleteSpeechTranscription(itemId);
+
+ setSelectedSpeechTranscript((prev) => {
+ if (prev) {
+ const prevId = prev.filename || prev.metadata || prev.textFile;
+ if (prevId === itemId) return null;
+ }
+ return prev;
+ });
+
+ refreshSpeechTranscriptions();
+ } catch (err) {
+ console.error("Failed to delete speech transcription:", err);
+ showAlert({ title: "Delete Failed", message: err.message || String(err), danger: true });
+ }
+ }, [showConfirm, showAlert, setSelectedSpeechTranscript, refreshSpeechTranscriptions]);
+
useEffect(() => {
refreshSpeechTranscriptions();
}, [refreshSpeechTranscriptions]);
@@ -510,8 +564,9 @@ function App() {
setSelectedSpeechTranscript={setSelectedSpeechTranscript}
showSpeechHistory={showSpeechHistory}
setShowSpeechHistory={setShowSpeechHistory}
+ onDeleteSpeechTranscription={handleDeleteSpeechTranscription}
/>
- ), [activeTab, specs, conversations, activeConversationId, showHistory, handleDeleteConversation, speechTranscriptions, selectedSpeechTranscript, showSpeechHistory]);
+ ), [activeTab, specs, conversations, activeConversationId, showHistory, handleDeleteConversation, speechTranscriptions, selectedSpeechTranscript, showSpeechHistory, handleDeleteSpeechTranscription]);
const handleStopServer = useCallback(async () => {
if (!serverRunning || isStoppingServer) return;
@@ -613,6 +668,8 @@ function App() {
showConfirm={showConfirm}
selectedTranscript={selectedSpeechTranscript}
onTranscriptionsChanged={refreshSpeechTranscriptions}
+ speechSettings={speechSettings}
+ setSpeechSettings={setSpeechSettings}
/>
@@ -630,6 +687,8 @@ function App() {
showConfirm={showConfirm}
textSettings={textSettings}
setTextSettings={setTextSettings}
+ speechSettings={speechSettings}
+ setSpeechSettings={setSpeechSettings}
health={health}
cleanupItems={cleanupItems}
isReadinessBusy={isReadinessBusy}
diff --git a/app/frontend/src/components/Settings.jsx b/app/frontend/src/components/Settings.jsx
index 02e4c72..0597fac 100644
--- a/app/frontend/src/components/Settings.jsx
+++ b/app/frontend/src/components/Settings.jsx
@@ -3,7 +3,7 @@ import {
Crop, Sliders, Cpu, Info, MessageSquare, SlidersHorizontal, Zap,
ChevronDown, Image, Type, Settings2, Gauge, Brain, Sparkles,
Monitor, HardDrive, MemoryStick, Thermometer, Hash, Layers,
- ChevronRight, Box, Wand2, Lightbulb, RotateCcw, Check, Palette
+ ChevronRight, Box, Wand2, Lightbulb, RotateCcw, Check, Palette, Volume2
} from "lucide-react";
import { stopServer, formatBytes, getLlmBackends, getLlmStats, getLlmStatus, benchmarkLlm, startLlm, stopLlm } from "../services/api";
import { THEMES } from "../themes";
@@ -146,18 +146,49 @@ function PremiumToggle({ checked, onChange, label, description }) {
}
// ─── Section Header Component ───
-function SectionHeader({ icon: Icon, title, count, color }) {
+function SectionHeader({ icon: Icon, title, count, color, isExpanded, onToggle }) {
return (
-
-
-
-
-
+
{
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ onToggle();
+ }
+ }}
+ aria-expanded={isExpanded}
+ >
+
- {count && (
-
{count} settings
- )}
+
+ {count && (
+ {count} settings
+ )}
+
+
);
}
@@ -174,6 +205,8 @@ function Settings({
setActiveModel,
textSettings,
setTextSettings,
+ speechSettings,
+ setSpeechSettings,
showAlert = async ({ message }) => window.alert(message),
showConfirm = async ({ message }) => window.confirm(message),
health,
@@ -191,6 +224,23 @@ function Settings({
const [llmStats, setLlmStats] = useState({ benchmarks: [] });
const [benchmarkBusy, setBenchmarkBusy] = useState(false);
+ const [expandedSections, setExpandedSections] = useState(() => {
+ return {
+ appearance: localStorage.getItem("settings_section_appearance") === "true",
+ image: localStorage.getItem("settings_section_image") === "true",
+ text: localStorage.getItem("settings_section_text") === "true",
+ speech: localStorage.getItem("settings_section_speech") === "true",
+ };
+ });
+
+ const toggleSection = (section) => {
+ setExpandedSections((prev) => {
+ const nextState = !prev[section];
+ localStorage.setItem(`settings_section_${section}`, String(nextState));
+ return { ...prev, [section]: nextState };
+ });
+ };
+
useEffect(() => {
let cancelled = false;
const fetchLlmStatus = async () => {
@@ -240,6 +290,13 @@ function Settings({
}));
};
+ const updateSpeechSetting = (key, value) => {
+ setSpeechSettings((prev) => ({
+ ...prev,
+ [key]: value
+ }));
+ };
+
const buildTextStartOptions = (settings) => ({
threads: settings?.threads || specs?.cpu_cores_physical || 4,
contextSize: settings?.contextSize ?? 0,
@@ -400,16 +457,20 @@ function Settings({
};
// ─── Image Settings ───
- const ImageSettings = () => (
+ const renderImageSettings = () => (
<>
toggleSection("image")}
/>
-
+ {expandedSections.image && (
+
+
{/* Left Column */}
{/* Size & Shape */}
@@ -524,7 +585,7 @@ function Settings({
min="1"
max={isOpenVinoNpu ? "8" : "60"}
/>
-
+
{isOpenVinoNpu
? "LCM OpenVINO: 1-8 fast steps"
: "More steps = sharper details, longer time"}
@@ -611,21 +672,27 @@ function Settings({
-
+
+
+ )}
>
);
// ─── Text Settings ───
- const TextSettings = () => (
+ const renderTextSettings = () => (
<>
toggleSection("text")}
/>
-
+ {expandedSections.text && (
+
+
{/* Left Column */}
{/* Model & Context */}
@@ -638,13 +705,16 @@ function Settings({
System Prompt
@@ -661,8 +731,8 @@ function Settings({
max="32768"
step="512"
/>
-
- 0 = Auto (model default)
+
+ Model memory limit. 0 uses default limit. (Recommended: 0)
@@ -689,39 +759,11 @@ function Settings({
max="2"
step="0.1"
/>
+
+ Controls creativity. Lower = focused & factual, Higher = creative & diverse. (Recommended: 0.7)
+
-
-
- Top P
- {textSettings.topP}
-
-
updateTextSetting("topP", parseFloat(e.target.value))}
- min="0"
- max="1"
- step="0.05"
- />
-
-
-
-
- Top K
- {textSettings.topK}
-
-
updateTextSetting("topK", parseInt(e.target.value))}
- min="1"
- max="100"
- step="1"
- />
-
@@ -744,55 +786,30 @@ function Settings({
))}
-
updateTextSetting("maxTokens", parseInt(e.target.value))}
- min="64"
- max="4096"
- step="64"
- disabled={(textSettings.responseTokenMode || "auto") === "auto"}
- />
-
- Auto uses remaining context with a safety buffer. Manual uses the slider value.
-
-
-
-
-
- Min P
- {textSettings.minP}
-
-
updateTextSetting("minP", parseFloat(e.target.value))}
- min="0"
- max="1"
- step="0.01"
- />
-
-
-
-
- Repeat Penalty
- {textSettings.repeatPenalty}
-
-
updateTextSetting("repeatPenalty", parseFloat(e.target.value))}
- min="1"
- max="2"
- step="0.05"
- />
+ {(textSettings.responseTokenMode || "auto") === "manual" ? (
+ <>
+
updateTextSetting("maxTokens", parseInt(e.target.value))}
+ min="64"
+ max="4096"
+ step="64"
+ />
+
+ Manual uses the slider value.
+
+ >
+ ) : (
+
+ Auto adjusts length dynamically based on context window. (Recommended)
+
+ )}
- Seed (-1 = Random)
+ Seed
updateTextSetting("seed", parseInt(e.target.value) || -1)}
placeholder="-1"
/>
+
+ Controls repeatability. Use -1 for random, positive integer for identical replies. (Recommended: -1)
+
@@ -862,7 +882,7 @@ function Settings({
min="0"
max="50"
/>
-
+
50 = All layers on GPU
@@ -905,105 +925,132 @@ function Settings({
- {/* Thinking & Reasoning */}
-
-
-
- Thinking & Reasoning
-
-
-
- {!supportsThinking && (
-
-
- Current model does not support thinking. Load a reasoning model to enable.
-
- )}
-
-
- {/* Text Backend */}
-
-
-
- Text Backend
-
-
-
-
-
-
- {llmStatus.ready ? "Running" : "Stopped"}
-
-
- {llmStatus.ready && (
-
- Model: {llmStatus.settings?.model || "Unknown"}
- Backend: {llmStatus.settings?.backendMode || "Unknown"}
- Threads: {llmStatus.settings?.threads || "-"}
- GPU Layers: {llmStatus.settings?.gpuLayers === -1 ? "All" : llmStatus.settings?.gpuLayers}
-
- )}
-
- Available: {(llmBackends.available || []).map((backend) => backend.key.toUpperCase()).join(", ") || "None"}
- Fastest saved: {(() => {
- const model = llmStatus.settings?.model;
- const winner = (llmStats.benchmarks || []).find((item) => item.ok && (!model || item.model === model));
- return winner
- ? `${winner.backendMode} (${Number(winner.predicted_per_second || 0).toFixed(1)} tok/s)`
- : "Not benchmarked";
- })()}
-
-
-
- {benchmarkBusy ? "Benchmarking..." : "Benchmark Text Backends"}
-
-
-
+
-
+ )}
>
);
+ // ─── Speech Settings ───
+ const renderSpeechSettings = () => {
+ const SPEECH_LANGUAGES = [
+ { value: "auto", label: "Auto detect" },
+ { value: "en", label: "English" },
+ { value: "es", label: "Spanish" },
+ { value: "fr", label: "French" },
+ { value: "de", label: "German" },
+ { value: "it", label: "Italian" },
+ { value: "pt", label: "Portuguese" },
+ { value: "hi", label: "Hindi" },
+ { value: "ja", label: "Japanese" },
+ { value: "ko", label: "Korean" },
+ { value: "zh", label: "Chinese" },
+ ];
+
+ return (
+ <>
+ toggleSection("speech")}
+ />
+
+ {expandedSections.speech && (
+
+
+ {/* Left Column */}
+
+ {/* Speech Parameters */}
+
+
+
+ Transcription Settings
+
+
+
+ Default Language
+ updateSpeechSetting("language", e.target.value)}
+ >
+ {SPEECH_LANGUAGES.map((item) => (
+
+ {item.label}
+
+ ))}
+
+
+ Default language for transcribing audio. (Recommended: Auto detect)
+
+
+
+
+
+ CPU Threads
+ {speechSettings.threads || 4}
+
+
updateSpeechSetting("threads", parseInt(e.target.value))}
+ min="1"
+ max={specs?.cpu_cores_logical || 16}
+ />
+
+ Number of threads to allocate for transcription. (Recommended: 4)
+
+
+
+
+
+
+ {/* Right Column */}
+
+ {/* Translation options */}
+
+
+
+ Translation Settings
+
+
+
updateSpeechSetting("translate", val)}
+ label="Translate to English"
+ description="Auto-translate foreign languages to English during transcription"
+ />
+
+
+
+
+
+ )}
+ >
+ );
+ };
+
// ─── Appearance Settings ───
- const AppearanceSettings = () => (
+ const renderAppearanceSettings = () => (
<>
toggleSection("appearance")}
/>
-
+ {expandedSections.appearance && (
+
+
Color Themes
@@ -1070,35 +1117,33 @@ function Settings({
})}
+
+ )}
>
);
return (
{/* Page Header */}
-
-
-
-
- Settings & Parameters
-
-
- Configure your AI models for optimal performance
-
-
+
+
Settings & Parameters
+
+ Configure your AI models for optimal performance.
+
- {/* Hardware Tier Badge */}
-
{/* Appearance & Themes Section */}
-
+ {renderAppearanceSettings()}
{/* Image Settings Section */}
-
+ {renderImageSettings()}
{/* Text Settings Section */}
-
+ {renderTextSettings()}
+
+ {/* Speech Settings Section */}
+ {renderSpeechSettings()}
);
}
diff --git a/app/frontend/src/components/Sidebar.jsx b/app/frontend/src/components/Sidebar.jsx
index 4b27604..5dbf93f 100644
--- a/app/frontend/src/components/Sidebar.jsx
+++ b/app/frontend/src/components/Sidebar.jsx
@@ -4,7 +4,9 @@ import { Image, FolderDown, MessageSquare, Mic, Settings, Shield, Terminal, Chev
function formatSidebarDate(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
- return date.toLocaleDateString();
+ const dateStr = date.toLocaleDateString();
+ const timeStr = date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', hour12: true });
+ return `${dateStr} ${timeStr}`;
}
function Sidebar({
@@ -21,7 +23,8 @@ function Sidebar({
selectedSpeechTranscript,
setSelectedSpeechTranscript,
showSpeechHistory,
- setShowSpeechHistory
+ setShowSpeechHistory,
+ onDeleteSpeechTranscription
}) {
return (
@@ -227,8 +230,9 @@ function Sidebar({
}}
style={{
display: "flex",
- flexDirection: "column",
- gap: "2px",
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
padding: "7px 8px 7px 10px",
borderRadius: "var(--md-shape-corner-small)",
fontSize: "0.78rem",
@@ -241,14 +245,36 @@ function Sidebar({
className="sidebar-history-item"
title={title}
>
-
- {title}
-
- {date && (
-
- {date}
+
+
+ {title}
- )}
+ {date && (
+
+ {date}
+
+ )}
+
+ {
+ 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"
+ >
+
+
);
})
diff --git a/app/frontend/src/components/SpeechTranscriber.jsx b/app/frontend/src/components/SpeechTranscriber.jsx
index d72aee3..1eecf15 100644
--- a/app/frontend/src/components/SpeechTranscriber.jsx
+++ b/app/frontend/src/components/SpeechTranscriber.jsx
@@ -115,27 +115,41 @@ function normalizeTimestamp(value) {
return "";
}
-function formatTranscriptWithTimes(transcription) {
+function formatTranscriptText(transcription, showTimestamps) {
const segments = transcription?.raw?.transcription;
if (!Array.isArray(segments) || segments.length === 0) {
return transcription?.text || "";
}
return segments
.map((segment) => {
- const time = normalizeTimestamp(segment.timestamps);
const text = String(segment.text || "").trim();
+ if (!showTimestamps) return text;
+ const time = normalizeTimestamp(segment.timestamps);
return time ? `[${time}] ${text}` : text;
})
.filter(Boolean)
.join("\n");
}
-export default function SpeechTranscriber({ showAlert, showConfirm, selectedTranscript, onTranscriptionsChanged }) {
+export default function SpeechTranscriber({
+ showAlert,
+ showConfirm,
+ selectedTranscript,
+ onTranscriptionsChanged,
+ speechSettings,
+ setSpeechSettings,
+}) {
const [status, setStatus] = useState({ ready: false, running: false, backendInstalled: false, settings: {} });
const [models, setModels] = useState([]);
const [selectedModel, setSelectedModel] = useState("");
- const [language, setLanguage] = useState("auto");
- const [threads, setThreads] = useState(Math.max(1, Math.min(8, navigator.hardwareConcurrency || 4)));
+
+ const language = speechSettings?.language || "auto";
+ const threads = speechSettings?.threads || 4;
+ const translate = speechSettings?.translate === true;
+
+ const setLanguage = (val) => setSpeechSettings((prev) => ({ ...prev, language: val }));
+ const setThreads = (val) => setSpeechSettings((prev) => ({ ...prev, threads: val }));
+
const [audioBlob, setAudioBlob] = useState(null);
const [audioName, setAudioName] = useState("");
const [audioUrl, setAudioUrl] = useState("");
@@ -143,12 +157,19 @@ export default function SpeechTranscriber({ showAlert, showConfirm, selectedTran
const [isTranscribing, setIsTranscribing] = useState(false);
const [result, setResult] = useState(null);
const [resultText, setResultText] = useState("");
+ const [showTimestamps, setShowTimestamps] = useState(false);
const [isLoadingModel, setIsLoadingModel] = useState(false);
const recorderRef = useRef(null);
const abortRef = useRef(null);
const installedModels = useMemo(() => models.filter((model) => model.installed), [models]);
+ const isEnglishOnly = useMemo(() => {
+ if (!selectedModel) return false;
+ const model = models.find((m) => m.filename === selectedModel);
+ return model?.language === "English" || selectedModel.toLowerCase().includes(".en");
+ }, [selectedModel, models]);
+
const refresh = useCallback(async () => {
const [nextStatus, nextModels] = await Promise.all([
getSpeechStatus(),
@@ -169,14 +190,22 @@ export default function SpeechTranscriber({ showAlert, showConfirm, selectedTran
useEffect(() => {
refresh().catch((err) => showAlert?.({ title: "Speech Status Failed", message: err.message || String(err), danger: true }));
+ const interval = setInterval(() => {
+ refresh().catch(() => {});
+ }, 2000);
+ return () => clearInterval(interval);
}, [refresh, showAlert]);
useEffect(() => {
if (!selectedTranscript) return;
setResult(selectedTranscript);
- setResultText(formatTranscriptWithTimes(selectedTranscript));
}, [selectedTranscript]);
+ useEffect(() => {
+ if (!result) return;
+ setResultText(formatTranscriptText(result, showTimestamps));
+ }, [result, showTimestamps]);
+
useEffect(() => {
return () => {
if (audioUrl) URL.revokeObjectURL(audioUrl);
@@ -272,19 +301,33 @@ export default function SpeechTranscriber({ showAlert, showConfirm, selectedTran
abortRef.current = controller;
try {
if (!status.ready || status.settings?.model !== selectedModel) {
- await startSpeech(selectedModel, { language, threads });
+ setIsLoadingModel(true);
+ try {
+ await startSpeech(selectedModel, { language, threads });
+ await refresh();
+ } finally {
+ setIsLoadingModel(false);
+ }
}
const transcription = await transcribeSpeech(audioBlob, {
model: selectedModel,
language,
threads,
+ translate,
filename: audioName || "audio.wav",
signal: controller.signal,
});
setResult(transcription);
- setResultText(formatTranscriptWithTimes(transcription));
await refresh();
await onTranscriptionsChanged?.();
+
+ if (transcription?.text?.toLowerCase().includes("foreign language")) {
+ showAlert?.({
+ title: "Foreign Language Detected",
+ message: "The loaded model is English-only and cannot transcribe non-English speech. Please download and load a Multilingual model (e.g. Whisper Base Multilingual) from the Model Manager to transcribe this audio.",
+ danger: false,
+ });
+ }
} catch (err) {
if (err.name !== "AbortError") {
showAlert?.({ title: "Transcription Failed", message: err.message || String(err), danger: true });
@@ -361,12 +404,23 @@ export default function SpeechTranscriber({ showAlert, showConfirm, selectedTran
/>
+
+
+ setSpeechSettings((prev) => ({ ...prev, translate: event.target.checked }))}
+ style={{ cursor: "pointer", width: "15px", height: "15px" }}
+ />
+ Translate to English
+
+
{isLoadingModel ? : }
{status.ready && status.settings?.model === selectedModel ? "Loaded" : isLoadingModel ? "Loading" : "Load"}
@@ -450,6 +504,15 @@ export default function SpeechTranscriber({ showAlert, showConfirm, selectedTran
Transcript
+
+ setShowTimestamps(e.target.checked)}
+ style={{ width: "14px", height: "14px", cursor: "pointer" }}
+ />
+ Show Timestamps
+
TXT
diff --git a/app/frontend/src/services/api.js b/app/frontend/src/services/api.js
index 2a9744e..9c267c1 100644
--- a/app/frontend/src/services/api.js
+++ b/app/frontend/src/services/api.js
@@ -609,6 +609,7 @@ export async function transcribeSpeech(fileOrBlob, options = {}) {
if (options.language) params.set("language", options.language);
if (options.filename) params.set("filename", options.filename);
if (options.threads) params.set("threads", String(options.threads));
+ if (options.translate) params.set("translate", "true");
const res = await fetch(`/api/speech/transcribe?${params.toString()}`, {
method: "POST",
@@ -653,6 +654,15 @@ export async function listSpeechTranscriptions() {
return data.transcriptions || [];
}
+export async function deleteSpeechTranscription(filename) {
+ const res = await fetch("/api/speech/delete-transcription", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ filename }),
+ });
+ return await readJsonResponse(res, "The local server returned an invalid speech transcription delete response.");
+}
+
export async function getLlmRecommendations(useCase = "chat", limit = 10) {
try {
const res = await fetch(`/api/llm/recommend?useCase=${encodeURIComponent(useCase)}&limit=${limit}`);
diff --git a/scripts/serve.cjs b/scripts/serve.cjs
index 300d274..9055b68 100644
--- a/scripts/serve.cjs
+++ b/scripts/serve.cjs
@@ -1821,6 +1821,9 @@ function transcribeWavBuffer(buffer, options = {}) {
} else {
args.push("-l", "auto");
}
+ if (options.translate) {
+ args.push("-tr");
+ }
const startedAt = Date.now();
console.log(" [speech] Starting:", backend.cli, args.join(" "));
@@ -4664,7 +4667,7 @@ const server = http.createServer(async (req, res) => {
}
if (req.url === "/api/speech/stop" && req.method === "POST") {
- await runExclusiveSpeechOperation(() => stopSpeech());
+ await stopSpeech();
return json(res, 200, { ok: true });
}
@@ -4677,6 +4680,7 @@ const server = http.createServer(async (req, res) => {
language: parsed.searchParams.get("language") || "auto",
filename: parsed.searchParams.get("filename") || req.headers["x-filename"] || "recording.wav",
threads: parsed.searchParams.get("threads") || speechSettings.threads,
+ translate: parsed.searchParams.get("translate") === "true",
}));
return json(res, 200, { ok: true, transcription: result });
} catch (err) {
@@ -4732,6 +4736,41 @@ const server = http.createServer(async (req, res) => {
return json(res, 200, { ok: true, transcriptions: listTranscriptions() });
}
+ if (req.url === "/api/speech/delete-transcription" && req.method === "POST") {
+ const body = await readJsonBody(req, res);
+ if (!body) return;
+ const filename = path.basename(String(body.filename || ""));
+ if (!filename || !filename.endsWith(".json")) {
+ return json(res, 400, { ok: false, error: "Invalid filename" });
+ }
+ const jsonPath = path.join(TRANSCRIPTIONS, filename);
+ if (!pathInside(jsonPath, TRANSCRIPTIONS)) {
+ return json(res, 400, { ok: false, error: "Invalid path" });
+ }
+ try {
+ if (!fs.existsSync(jsonPath)) {
+ return json(res, 404, { ok: false, error: "Transcription metadata not found" });
+ }
+
+ const metadata = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
+ const textFile = metadata.textFile ? path.basename(String(metadata.textFile)) : "";
+
+ fs.unlinkSync(jsonPath);
+
+ if (textFile) {
+ const textPath = path.join(TRANSCRIPTIONS, textFile);
+ if (pathInside(textPath, TRANSCRIPTIONS) && fs.existsSync(textPath)) {
+ fs.unlinkSync(textPath);
+ }
+ }
+
+ return json(res, 200, { ok: true });
+ } catch (err) {
+ console.error(" [api] Failed to delete transcription:", err);
+ return json(res, 500, { ok: false, error: err.message });
+ }
+ }
+
function isOomError(msg) {
if (!msg) return false;
const lower = String(msg).toLowerCase();