implement backend service manager and speech transcription pipeline with supporting setup scripts
This commit is contained in:
1 parent
20b81926b6
commit
b71b4ecafa
17 files changed
+1680
-46
No files matched your search
@@ -2,16 +2,19 @@
|
||||
app/models/
|
||||
app/openvino-models/
|
||||
app/llm-models/
|
||||
app/speech-models/
|
||||
|
||||
# Downloaded portable runtimes/backends
|
||||
app/backend/
|
||||
app/llm-backend/
|
||||
app/speech-backend/
|
||||
app/tools/
|
||||
|
||||
# Generated files
|
||||
app/config/
|
||||
app/dist/
|
||||
app/outputs/
|
||||
app/transcriptions/
|
||||
|
||||
# Dependencies and platform-specific copies
|
||||
**/node_modules/
|
||||
|
||||
@@ -1303,6 +1303,136 @@ body {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.speech-workspace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.speech-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 0.9fr) minmax(320px, 1.1fr);
|
||||
gap: 18px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.speech-panel,
|
||||
.speech-result-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.speech-panel-header,
|
||||
.speech-button-row,
|
||||
.speech-controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.speech-panel-header {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.speech-panel-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.speech-button-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.speech-controls-row {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.speech-label {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.speech-starter-list {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.speech-capture-zone {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-height: 120px;
|
||||
border: 1px dashed var(--md-sys-color-outline-variant);
|
||||
border-radius: var(--md-shape-corner-medium);
|
||||
padding: 18px;
|
||||
background: var(--md-sys-color-surface-container-low);
|
||||
}
|
||||
|
||||
.speech-capture-zone p {
|
||||
margin: 4px 0 0 0;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.m3-textarea.speech-result-textarea {
|
||||
min-height: 420px;
|
||||
height: clamp(420px, 46vh, 720px);
|
||||
resize: vertical;
|
||||
line-height: 1.55;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.speech-history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.speech-history-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--md-sys-color-outline-variant);
|
||||
border-radius: var(--md-shape-corner-small);
|
||||
}
|
||||
|
||||
.speech-history-item div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.speech-history-item strong,
|
||||
.speech-history-item span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.speech-history-item span {
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.speech-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Glassmorphic UI Cards */
|
||||
.m3-card {
|
||||
background-color: var(--md-sys-color-surface);
|
||||
|
||||
@@ -5,7 +5,8 @@ import Generator from "./components/Generator";
|
||||
import ModelManager from "./components/ModelManager";
|
||||
import Settings from "./components/Settings";
|
||||
import TextChat from "./components/TextChat";
|
||||
import { cleanupCandidates, formatBytes, getCleanupCandidates, getDiagnostics, getHardwareSpecs, getHealth, getTelemetry, getBackendOptions, getBackendStatus, listGeneratedOutputs, stopServer } from "./services/api";
|
||||
import SpeechTranscriber from "./components/SpeechTranscriber";
|
||||
import { cleanupCandidates, formatBytes, getCleanupCandidates, getDiagnostics, getHardwareSpecs, getHealth, getTelemetry, getBackendOptions, getBackendStatus, listGeneratedOutputs, listSpeechTranscriptions, stopServer } from "./services/api";
|
||||
import "./App.css";
|
||||
|
||||
function App() {
|
||||
@@ -163,6 +164,9 @@ function App() {
|
||||
const [conversations, setConversations] = useState([]);
|
||||
const [activeConversationId, setActiveConversationId] = useState(null);
|
||||
const [showHistory, setShowHistory] = useState(false); // Default hide
|
||||
const [speechTranscriptions, setSpeechTranscriptions] = useState([]);
|
||||
const [selectedSpeechTranscript, setSelectedSpeechTranscript] = useState(null);
|
||||
const [showSpeechHistory, setShowSpeechHistory] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("chat_conversations");
|
||||
@@ -243,6 +247,20 @@ function App() {
|
||||
}
|
||||
}, [activeConversationId]);
|
||||
|
||||
const refreshSpeechTranscriptions = useCallback(async () => {
|
||||
try {
|
||||
const list = await listSpeechTranscriptions();
|
||||
setSpeechTranscriptions(list);
|
||||
} catch (err) {
|
||||
console.warn("Could not load speech transcriptions:", err);
|
||||
setSpeechTranscriptions([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshSpeechTranscriptions();
|
||||
}, [refreshSpeechTranscriptions]);
|
||||
|
||||
// Load hardware specifications on mount
|
||||
useEffect(() => {
|
||||
async function loadSpecs() {
|
||||
@@ -487,8 +505,13 @@ function App() {
|
||||
showHistory={showHistory}
|
||||
setShowHistory={setShowHistory}
|
||||
onDeleteConversation={handleDeleteConversation}
|
||||
speechTranscriptions={speechTranscriptions}
|
||||
selectedSpeechTranscript={selectedSpeechTranscript}
|
||||
setSelectedSpeechTranscript={setSelectedSpeechTranscript}
|
||||
showSpeechHistory={showSpeechHistory}
|
||||
setShowSpeechHistory={setShowSpeechHistory}
|
||||
/>
|
||||
), [activeTab, specs, conversations, activeConversationId, showHistory, handleDeleteConversation]);
|
||||
), [activeTab, specs, conversations, activeConversationId, showHistory, handleDeleteConversation, speechTranscriptions, selectedSpeechTranscript, showSpeechHistory]);
|
||||
|
||||
const handleStopServer = useCallback(async () => {
|
||||
if (!serverRunning || isStoppingServer) return;
|
||||
@@ -584,6 +607,15 @@ function App() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: activeTab === "speech" ? "flex" : "none", flex: 1, flexDirection: "column", overflow: "hidden" }}>
|
||||
<SpeechTranscriber
|
||||
showAlert={showAlert}
|
||||
showConfirm={showConfirm}
|
||||
selectedTranscript={selectedSpeechTranscript}
|
||||
onTranscriptionsChanged={refreshSpeechTranscriptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: activeTab === "settings" ? "flex" : "none", flex: 1, flexDirection: "column", overflow: "hidden" }}>
|
||||
<Settings
|
||||
constraints={constraints}
|
||||
|
||||
@@ -21,7 +21,14 @@ import {
|
||||
deleteLlmModel,
|
||||
importLlmModel,
|
||||
getLlmStatus,
|
||||
searchHuggingFaceModels
|
||||
searchHuggingFaceModels,
|
||||
listSpeechModels,
|
||||
startSpeech,
|
||||
stopSpeech,
|
||||
downloadSpeechModel,
|
||||
deleteSpeechModel,
|
||||
importSpeechModel,
|
||||
getSpeechStatus
|
||||
} from "../services/api";
|
||||
|
||||
const MODEL_FILTERS = [
|
||||
@@ -220,7 +227,7 @@ function ModelManager({
|
||||
specs,
|
||||
textSettings,
|
||||
}) {
|
||||
const [activeModelType, setActiveModelType] = useState("image"); // "image" or "text"
|
||||
const [activeModelType, setActiveModelType] = useState("image"); // "image", "text", or "speech"
|
||||
const [localModels, setLocalModels] = useState([]);
|
||||
const [isLoadingModels, setIsLoadingModels] = useState(true);
|
||||
const [downloadingModelId, setDownloadingModelId] = useState(null);
|
||||
@@ -239,6 +246,8 @@ function ModelManager({
|
||||
const [backendInfo, setBackendInfo] = useState({ backendMode: "", backendBinary: "", backendDevice: "" });
|
||||
const [activeLlmModel, setActiveLlmModel] = useState(null);
|
||||
const [llmRunning, setLlmRunning] = useState(false);
|
||||
const [activeSpeechModel, setActiveSpeechModel] = useState(null);
|
||||
const [speechRunning, setSpeechRunning] = useState(false);
|
||||
const [modelSearch, setModelSearch] = useState("");
|
||||
const [selectedFilters, setSelectedFilters] = useState([]);
|
||||
const [huggingFaceModels, setHuggingFaceModels] = useState([]);
|
||||
@@ -262,9 +271,15 @@ function ModelManager({
|
||||
}
|
||||
};
|
||||
|
||||
const normalizedLocalModels = localModels.map(normalizeModel).filter((model) => model.filename);
|
||||
const displayedLocalModels = normalizedLocalModels.filter((model) => !model.isProjector);
|
||||
const allModelNames = normalizedLocalModels.map((model) => model.filename);
|
||||
const normalizedLocalModels = activeModelType === "speech"
|
||||
? localModels.filter((model) => model.filename)
|
||||
: localModels.map(normalizeModel).filter((model) => model.filename);
|
||||
const displayedLocalModels = activeModelType === "speech"
|
||||
? normalizedLocalModels.filter((model) => model.installed)
|
||||
: normalizedLocalModels.filter((model) => !model.isProjector);
|
||||
const allModelNames = activeModelType === "speech"
|
||||
? displayedLocalModels.map((model) => model.filename)
|
||||
: normalizedLocalModels.map((model) => model.filename);
|
||||
const modelNames = displayedLocalModels.map((model) => model.filename);
|
||||
const isBusy = loadingModelId !== null || isUnloading;
|
||||
|
||||
@@ -286,11 +301,22 @@ function ModelManager({
|
||||
if (appleNpuSupported) {
|
||||
visibleModelLibrary = [...visibleModelLibrary, ...COREML_MODEL_LIBRARY];
|
||||
}
|
||||
} else {
|
||||
} else if (activeModelType === "text") {
|
||||
visibleModelLibrary = [{
|
||||
group: "Recommended Text Models from Hugging Face",
|
||||
items: hasHuggingFaceResults ? displayedHuggingFaceModels : TEXT_MODEL_LIBRARY[0].items,
|
||||
}];
|
||||
} else {
|
||||
visibleModelLibrary = [{
|
||||
group: "Whisper Speech Models",
|
||||
items: normalizedLocalModels.filter((model) => model.url).map((model) => ({
|
||||
...model,
|
||||
format: "Whisper.cpp",
|
||||
approxSize: model.size,
|
||||
notes: `${model.language || "Speech"} transcription model. Stored in app/speech-models/.`,
|
||||
recommendedTiers: model.recommended ? ["Low", "Mid", "High"] : [],
|
||||
})),
|
||||
}];
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -364,9 +390,12 @@ function ModelManager({
|
||||
if (activeModelType === "image") {
|
||||
const list = await listLocalModels();
|
||||
setLocalModels(list.map(normalizeModel).filter((model) => model.filename));
|
||||
} else {
|
||||
} else if (activeModelType === "text") {
|
||||
const list = await listLlmModels();
|
||||
setLocalModels(list.map(normalizeModel).filter((model) => model.filename));
|
||||
} else {
|
||||
const list = await listSpeechModels();
|
||||
setLocalModels(list.filter((model) => model.filename));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -392,9 +421,10 @@ function ModelManager({
|
||||
let cancelled = false;
|
||||
const updateBackendInfo = async () => {
|
||||
try {
|
||||
const [sdStatus, llmStatus] = await Promise.all([
|
||||
const [sdStatus, llmStatus, speechStatus] = await Promise.all([
|
||||
getBackendStatus(),
|
||||
getLlmStatus()
|
||||
getLlmStatus(),
|
||||
getSpeechStatus()
|
||||
]);
|
||||
if (cancelled) return;
|
||||
|
||||
@@ -403,6 +433,7 @@ function ModelManager({
|
||||
backendBinary: sdStatus.settings?.backendBinary || sdStatus.loading?.backendBinary || "",
|
||||
backendDevice: sdStatus.settings?.backendDevice || sdStatus.loading?.device || "",
|
||||
llmBackendMode: llmStatus.settings?.backendMode || llmStatus.settings?.backendBinary || "",
|
||||
speechBackendMode: speechStatus.settings?.backendMode || speechStatus.backendMode || "",
|
||||
});
|
||||
|
||||
if (llmStatus.ready) {
|
||||
@@ -412,6 +443,13 @@ function ModelManager({
|
||||
setActiveLlmModel(null);
|
||||
setLlmRunning(false);
|
||||
}
|
||||
if (speechStatus.ready) {
|
||||
setActiveSpeechModel(speechStatus.settings?.model || null);
|
||||
setSpeechRunning(true);
|
||||
} else {
|
||||
setActiveSpeechModel(null);
|
||||
setSpeechRunning(false);
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
@@ -460,8 +498,10 @@ function ModelManager({
|
||||
let res;
|
||||
if (activeModelType === "image") {
|
||||
res = await downloadModel(url);
|
||||
} else {
|
||||
} else if (activeModelType === "text") {
|
||||
res = await downloadLlmModel(url, expectedFilename, companion);
|
||||
} else {
|
||||
res = await downloadSpeechModel(url, expectedFilename);
|
||||
}
|
||||
if (res && res.ok) {
|
||||
if (!companion && res.projectorUrl && res.projectorFilename) {
|
||||
@@ -523,7 +563,7 @@ function ModelManager({
|
||||
e.preventDefault();
|
||||
if (!downloadUrl.trim()) return;
|
||||
|
||||
let modelName = "model.safetensors";
|
||||
let modelName = activeModelType === "image" ? "model.safetensors" : activeModelType === "text" ? "model.gguf" : "model.bin";
|
||||
try {
|
||||
const parsed = new URL(downloadUrl.trim());
|
||||
modelName = parsed.pathname.split("/").pop() || modelName;
|
||||
@@ -539,6 +579,18 @@ function ModelManager({
|
||||
const handleLibraryDownload = async (model) => {
|
||||
if (downloadingModelId) return;
|
||||
|
||||
if (activeModelType === "speech") {
|
||||
const confirmed = await showConfirm({
|
||||
title: "Download Speech Model?",
|
||||
message: `Download "${model.name}" (${model.size || model.approxSize})?`,
|
||||
confirmLabel: "Download",
|
||||
});
|
||||
if (confirmed) {
|
||||
await downloadByUrl(model.url, model.filename);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (model.backendType === "openvino-npu") {
|
||||
const confirmed = await showConfirm({
|
||||
title: "Download OpenVINO Model?",
|
||||
@@ -639,7 +691,7 @@ function ModelManager({
|
||||
return;
|
||||
}
|
||||
await checkVramAndLoad(modelId);
|
||||
} else {
|
||||
} else if (activeModelType === "text") {
|
||||
setLoadingModelId(modelId);
|
||||
setModelLoadProgress({
|
||||
progress: 10,
|
||||
@@ -682,6 +734,35 @@ function ModelManager({
|
||||
setModelLoadProgress(null);
|
||||
setLoadingModelId(null);
|
||||
}
|
||||
} else {
|
||||
setLoadingModelId(modelId);
|
||||
setModelLoadProgress({
|
||||
progress: 30,
|
||||
phase: "Starting whisper.cpp speech runtime...",
|
||||
speed: "",
|
||||
current: 0,
|
||||
total: 0,
|
||||
model: modelId,
|
||||
backendMode: "",
|
||||
backendBinary: "",
|
||||
device: "",
|
||||
});
|
||||
try {
|
||||
await stopServer();
|
||||
await stopLlm();
|
||||
await startSpeech(modelId, { threads: textSettings?.threads || specs?.cpu_cores_physical || 4 });
|
||||
setActiveSpeechModel(modelId);
|
||||
setSpeechRunning(true);
|
||||
setActiveLlmModel(null);
|
||||
setLlmRunning(false);
|
||||
setActiveModel(null);
|
||||
setServerRunning(false);
|
||||
} catch (err) {
|
||||
showAlert({ title: "Speech Model Load Failed", message: err.message || String(err), danger: true });
|
||||
} finally {
|
||||
setModelLoadProgress(null);
|
||||
setLoadingModelId(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -872,7 +953,7 @@ function ModelManager({
|
||||
setUnloadProgress({ progress: 0, phase: "" });
|
||||
}, 500);
|
||||
}
|
||||
} else {
|
||||
} else if (activeModelType === "text") {
|
||||
setIsUnloading(true);
|
||||
setUnloadProgress({ progress: 50, phase: "Stopping llama.cpp backend process..." });
|
||||
try {
|
||||
@@ -885,6 +966,19 @@ function ModelManager({
|
||||
setIsUnloading(false);
|
||||
setUnloadProgress({ progress: 0, phase: "" });
|
||||
}
|
||||
} else {
|
||||
setIsUnloading(true);
|
||||
setUnloadProgress({ progress: 50, phase: "Stopping whisper.cpp speech runtime..." });
|
||||
try {
|
||||
await stopSpeech();
|
||||
setActiveSpeechModel(null);
|
||||
setSpeechRunning(false);
|
||||
} catch (e) {
|
||||
showAlert({ title: "Unload Failed", message: e.message || String(e), danger: true });
|
||||
} finally {
|
||||
setIsUnloading(false);
|
||||
setUnloadProgress({ progress: 0, phase: "" });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -901,11 +995,16 @@ function ModelManager({
|
||||
if (activeModel === filename) {
|
||||
await handleUnloadModel();
|
||||
}
|
||||
} else {
|
||||
} else if (activeModelType === "text") {
|
||||
await deleteLlmModel(filename);
|
||||
if (activeLlmModel === filename) {
|
||||
await handleUnloadModel();
|
||||
}
|
||||
} else {
|
||||
await deleteSpeechModel(filename);
|
||||
if (activeSpeechModel === filename) {
|
||||
await handleUnloadModel();
|
||||
}
|
||||
}
|
||||
await fetchModels();
|
||||
} catch (err) {
|
||||
@@ -937,7 +1036,7 @@ function ModelManager({
|
||||
status: progressData.status
|
||||
});
|
||||
}, controller.signal);
|
||||
} else {
|
||||
} else if (activeModelType === "text") {
|
||||
await importLlmModel(sourcePath, (progressData) => {
|
||||
setImportProgress(Math.round(progressData.progress));
|
||||
setImportInfo({
|
||||
@@ -947,6 +1046,16 @@ function ModelManager({
|
||||
status: progressData.status
|
||||
});
|
||||
}, controller.signal);
|
||||
} else {
|
||||
await importSpeechModel(sourcePath, (progressData) => {
|
||||
setImportProgress(Math.round(progressData.progress));
|
||||
setImportInfo({
|
||||
filename: progressData.filename,
|
||||
speed: progressData.speed_mb_s.toFixed(1),
|
||||
eta: Math.round(progressData.eta_secs),
|
||||
status: progressData.status
|
||||
});
|
||||
}, controller.signal);
|
||||
}
|
||||
|
||||
await fetchModels();
|
||||
@@ -996,6 +1105,13 @@ function ModelManager({
|
||||
>
|
||||
Text Models (GGUF)
|
||||
</button>
|
||||
<button
|
||||
className={`m3-btn ${activeModelType === "speech" ? "m3-btn-filled" : "m3-btn-outlined"}`}
|
||||
onClick={() => setActiveModelType("speech")}
|
||||
style={{ height: "40px", padding: "0 20px" }}
|
||||
>
|
||||
Speech Models (Whisper)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1054,6 +1170,26 @@ function ModelManager({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeModelType === "speech" && activeSpeechModel && (
|
||||
<div className="m3-card" style={{ borderLeft: "4px solid var(--md-sys-color-primary)", background: "var(--md-sys-color-primary-container)", color: "var(--md-sys-color-on-primary-container)" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<div>
|
||||
<h4 style={{ fontWeight: 700 }}>Active Speech Model: {activeSpeechModel}</h4>
|
||||
<p style={{ fontSize: "0.85rem", marginTop: "2px", opacity: 0.9 }}>
|
||||
The local whisper.cpp runtime is ready. Speech Transcriber can process WAV recordings and uploads.
|
||||
</p>
|
||||
{backendInfo.speechBackendMode && (
|
||||
<div style={{ display: "flex", gap: "8px", marginTop: "8px", flexWrap: "wrap" }}>
|
||||
<span className="status-chip" style={{ cursor: "default", background: "rgba(255,255,255,0.15)", color: "inherit", border: "1px solid rgba(255,255,255,0.2)" }}>
|
||||
<span>{backendInfo.speechBackendMode}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading Progress Bar */}
|
||||
{modelLoadProgress && (
|
||||
<div className="m3-card" style={{ borderLeft: "4px solid var(--md-sys-color-primary)", marginTop: "24px" }}>
|
||||
@@ -1105,7 +1241,7 @@ function ModelManager({
|
||||
<div className="m3-card" style={{ marginTop: "24px" }}>
|
||||
<h3 className="m3-card-title">
|
||||
<Database size={18} style={{ color: "var(--md-sys-color-primary)" }} />
|
||||
Local {activeModelType === "image" ? "Image" : "Text"} Models ({displayedLocalModels.length})
|
||||
Local {activeModelType === "image" ? "Image" : activeModelType === "text" ? "Text" : "Speech"} Models ({displayedLocalModels.length})
|
||||
</h3>
|
||||
|
||||
{isLoadingModels ? (
|
||||
@@ -1115,15 +1251,17 @@ function ModelManager({
|
||||
</div>
|
||||
) : displayedLocalModels.length === 0 ? (
|
||||
<p style={{ fontSize: "0.9rem", color: "var(--md-sys-color-outline)", textAlign: "center", padding: "16px 0" }}>
|
||||
{activeModelType === "image"
|
||||
{activeModelType === "image"
|
||||
? "No image models detected in app/models/. Download from the library below or import a file."
|
||||
: "No text models detected in app/llm-models/. Download from the library below or import a file."}
|
||||
: activeModelType === "text"
|
||||
? "No text models detected in app/llm-models/. Download from the library below or import a file."
|
||||
: "No speech models detected in app/speech-models/. Download from the library below or import a .bin file."}
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
|
||||
{displayedLocalModels.map((model) => {
|
||||
const filename = model.filename;
|
||||
const isActive = activeModelType === "image" ? activeModel === filename : activeLlmModel === filename;
|
||||
const isActive = activeModelType === "image" ? activeModel === filename : activeModelType === "text" ? activeLlmModel === filename : activeSpeechModel === filename;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1144,7 +1282,9 @@ function ModelManager({
|
||||
<span style={{ fontSize: "0.75rem", opacity: 0.8 }}>
|
||||
{activeModelType === "image"
|
||||
? (model.backendType === "openvino-npu" ? "OpenVINO NPU Model" : model.format || "Local Weights File")
|
||||
: "llama.cpp GGUF Model"
|
||||
: activeModelType === "text"
|
||||
? "llama.cpp GGUF Model"
|
||||
: `${model.language || "Whisper"} Model`
|
||||
} • {model.size || formatBytes(model.sizeBytes)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1192,7 +1332,7 @@ function ModelManager({
|
||||
<div className="workspace-title-section" style={{ marginTop: "32px", marginBottom: "16px" }}>
|
||||
<h3 className="m3-card-title">
|
||||
<Library size={20} style={{ color: "var(--md-sys-color-primary)" }} />
|
||||
{activeModelType === "text" ? "Hugging Face Model Library" : "Model Library"}
|
||||
{activeModelType === "text" ? "Hugging Face Model Library" : activeModelType === "speech" ? "Speech Model Library" : "Model Library"}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -1401,14 +1541,14 @@ function ModelManager({
|
||||
<input
|
||||
type="file"
|
||||
style={{ display: "none" }}
|
||||
accept={activeModelType === "image" ? ".safetensors,.ckpt" : ".gguf"}
|
||||
accept={activeModelType === "image" ? ".safetensors,.ckpt" : activeModelType === "text" ? ".gguf" : ".bin"}
|
||||
onChange={handleImportFile}
|
||||
disabled={importProgress !== null}
|
||||
/>
|
||||
<FolderOpen className="import-icon" />
|
||||
<span style={{ fontWeight: 600 }}>Choose weights file</span>
|
||||
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", textAlign: "center" }}>
|
||||
Select {activeModelType === "image" ? "`.safetensors` or `.ckpt` weights." : "`.gguf` weights."}
|
||||
Select {activeModelType === "image" ? "`.safetensors` or `.ckpt` weights." : activeModelType === "text" ? "`.gguf` weights." : "`whisper.cpp .bin` weights."}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -1418,7 +1558,7 @@ function ModelManager({
|
||||
Download Model from URL
|
||||
</h4>
|
||||
<p style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", marginBottom: "12px" }}>
|
||||
Download any {activeModelType === "image" ? "Safetensors" : "GGUF"} model from Hugging Face directly to your models folder.
|
||||
Download any {activeModelType === "image" ? "Safetensors" : activeModelType === "text" ? "GGUF" : "Whisper .bin"} model from Hugging Face directly to your models folder.
|
||||
</p>
|
||||
{isUrlDownloading ? (
|
||||
<div className="model-progress-section" style={{ marginTop: "0px" }}>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import React, { memo } from "react";
|
||||
import { Image, FolderDown, MessageSquare, Settings, Shield, Terminal, ChevronDown, ChevronUp, Trash2 } from "lucide-react";
|
||||
import { Image, FolderDown, MessageSquare, Mic, Settings, Shield, Terminal, ChevronDown, ChevronUp, Trash2 } from "lucide-react";
|
||||
|
||||
function formatSidebarDate(value) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
activeTab,
|
||||
@@ -10,7 +16,12 @@ function Sidebar({
|
||||
setActiveConversationId,
|
||||
showHistory,
|
||||
setShowHistory,
|
||||
onDeleteConversation
|
||||
onDeleteConversation,
|
||||
speechTranscriptions = [],
|
||||
selectedSpeechTranscript,
|
||||
setSelectedSpeechTranscript,
|
||||
showSpeechHistory,
|
||||
setShowSpeechHistory
|
||||
}) {
|
||||
return (
|
||||
<div className="sidebar">
|
||||
@@ -148,6 +159,104 @@ function Sidebar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="nav-item-wrapper" style={{ display: "flex", flexDirection: "column" }}>
|
||||
<div
|
||||
className={`nav-item ${activeTab === "speech" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("speech")}
|
||||
style={{ display: "flex", justifyContent: "space-between", alignItems: "center", width: "100%", boxSizing: "border-box" }}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||
<Mic size={20} />
|
||||
<span>Speech Transcriber</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowSpeechHistory(!showSpeechHistory);
|
||||
}}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "inherit",
|
||||
cursor: "pointer",
|
||||
padding: "4px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "50%",
|
||||
transition: "background-color 0.2s"
|
||||
}}
|
||||
className="history-toggle-arrow"
|
||||
title={showSpeechHistory ? "Hide Transcriptions" : "Show Transcriptions"}
|
||||
>
|
||||
{showSpeechHistory ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showSpeechHistory && (
|
||||
<div
|
||||
className="sidebar-history-list"
|
||||
style={{
|
||||
paddingLeft: "14px",
|
||||
marginTop: "6px",
|
||||
marginBottom: "6px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "4px",
|
||||
maxHeight: "220px",
|
||||
overflowY: "auto",
|
||||
borderLeft: "2px solid var(--border-color)"
|
||||
}}
|
||||
>
|
||||
{speechTranscriptions.length === 0 ? (
|
||||
<div style={{ padding: "8px 12px", fontSize: "0.78rem", color: "var(--md-sys-color-outline)", opacity: 0.8 }}>
|
||||
No saved transcriptions
|
||||
</div>
|
||||
) : (
|
||||
speechTranscriptions.map((item) => {
|
||||
const itemId = item.filename || item.metadata || item.textFile;
|
||||
const isActive = selectedSpeechTranscript && (selectedSpeechTranscript.filename || selectedSpeechTranscript.metadata || selectedSpeechTranscript.textFile) === itemId;
|
||||
const title = item.displayName || item.sourceFilename || item.textFile || item.filename || "Transcript";
|
||||
const date = formatSidebarDate(item.modifiedAt || item.createdAt);
|
||||
return (
|
||||
<div
|
||||
key={itemId}
|
||||
onClick={() => {
|
||||
setSelectedSpeechTranscript(item);
|
||||
setActiveTab("speech");
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "2px",
|
||||
padding: "7px 8px 7px 10px",
|
||||
borderRadius: "var(--md-shape-corner-small)",
|
||||
fontSize: "0.78rem",
|
||||
cursor: "pointer",
|
||||
background: isActive ? "var(--md-sys-color-secondary-container)" : "transparent",
|
||||
color: isActive ? "var(--md-sys-color-on-secondary-container)" : "var(--md-sys-color-on-surface-variant)",
|
||||
border: isActive ? "1px solid var(--md-sys-color-outline-variant)" : "1px solid transparent",
|
||||
transition: "background 0.2s"
|
||||
}}
|
||||
className="sidebar-history-item"
|
||||
title={title}
|
||||
>
|
||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: isActive ? 600 : 400 }}>
|
||||
{title}
|
||||
</span>
|
||||
{date && (
|
||||
<span style={{ fontSize: "0.68rem", opacity: 0.72, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{date}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`nav-item ${activeTab === "models" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("models")}
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
FileAudio,
|
||||
LoaderCircle,
|
||||
Mic,
|
||||
Play,
|
||||
Save,
|
||||
Square,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
getSpeechStatus,
|
||||
listSpeechModels,
|
||||
startSpeech,
|
||||
stopSpeech,
|
||||
transcribeSpeech,
|
||||
} from "../services/api";
|
||||
|
||||
const LANGUAGE_OPTIONS = [
|
||||
{ value: "auto", label: "Auto detect" },
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "es", label: "Spanish" },
|
||||
{ value: "fr", label: "French" },
|
||||
{ value: "de", label: "German" },
|
||||
{ value: "it", label: "Italian" },
|
||||
{ value: "pt", label: "Portuguese" },
|
||||
{ value: "hi", label: "Hindi" },
|
||||
{ value: "ja", label: "Japanese" },
|
||||
{ value: "ko", label: "Korean" },
|
||||
{ value: "zh", label: "Chinese" },
|
||||
];
|
||||
|
||||
function flattenFloat32(chunks) {
|
||||
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const merged = new Float32Array(total);
|
||||
let offset = 0;
|
||||
chunks.forEach((chunk) => {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
});
|
||||
return merged;
|
||||
}
|
||||
|
||||
function resampleLinear(input, sourceRate, targetRate) {
|
||||
if (sourceRate === targetRate) return input;
|
||||
const ratio = sourceRate / targetRate;
|
||||
const newLength = Math.max(1, Math.round(input.length / ratio));
|
||||
const output = new Float32Array(newLength);
|
||||
for (let i = 0; i < newLength; i += 1) {
|
||||
const src = i * ratio;
|
||||
const left = Math.floor(src);
|
||||
const right = Math.min(input.length - 1, left + 1);
|
||||
const weight = src - left;
|
||||
output[i] = input[left] * (1 - weight) + input[right] * weight;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function encodeWavPcm16(samples, sampleRate = 16000) {
|
||||
const bytesPerSample = 2;
|
||||
const buffer = new ArrayBuffer(44 + samples.length * bytesPerSample);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
const writeString = (offset, value) => {
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
view.setUint8(offset + i, value.charCodeAt(i));
|
||||
}
|
||||
};
|
||||
|
||||
writeString(0, "RIFF");
|
||||
view.setUint32(4, 36 + samples.length * bytesPerSample, true);
|
||||
writeString(8, "WAVE");
|
||||
writeString(12, "fmt ");
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, 1, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * bytesPerSample, true);
|
||||
view.setUint16(32, bytesPerSample, true);
|
||||
view.setUint16(34, 16, true);
|
||||
writeString(36, "data");
|
||||
view.setUint32(40, samples.length * bytesPerSample, true);
|
||||
|
||||
let offset = 44;
|
||||
for (let i = 0; i < samples.length; i += 1, offset += 2) {
|
||||
const sample = Math.max(-1, Math.min(1, samples[i]));
|
||||
view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
|
||||
}
|
||||
return new Blob([view], { type: "audio/wav" });
|
||||
}
|
||||
|
||||
function downloadBlob(filename, content, type) {
|
||||
const blob = content instanceof Blob ? content : new Blob([content], { type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value) {
|
||||
if (!value) return "";
|
||||
if (typeof value === "string") {
|
||||
const from = value.match(/from=([^;},]+)/)?.[1];
|
||||
const to = value.match(/to=([^;},]+)/)?.[1];
|
||||
return from && to ? `${from} - ${to}` : value;
|
||||
}
|
||||
if (typeof value === "object" && value.from && value.to) {
|
||||
return `${value.from} - ${value.to}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function formatTranscriptWithTimes(transcription) {
|
||||
const segments = transcription?.raw?.transcription;
|
||||
if (!Array.isArray(segments) || segments.length === 0) {
|
||||
return transcription?.text || "";
|
||||
}
|
||||
return segments
|
||||
.map((segment) => {
|
||||
const time = normalizeTimestamp(segment.timestamps);
|
||||
const text = String(segment.text || "").trim();
|
||||
return time ? `[${time}] ${text}` : text;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export default function SpeechTranscriber({ showAlert, showConfirm, selectedTranscript, onTranscriptionsChanged }) {
|
||||
const [status, setStatus] = useState({ ready: false, running: false, backendInstalled: false, settings: {} });
|
||||
const [models, setModels] = useState([]);
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [language, setLanguage] = useState("auto");
|
||||
const [threads, setThreads] = useState(Math.max(1, Math.min(8, navigator.hardwareConcurrency || 4)));
|
||||
const [audioBlob, setAudioBlob] = useState(null);
|
||||
const [audioName, setAudioName] = useState("");
|
||||
const [audioUrl, setAudioUrl] = useState("");
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isTranscribing, setIsTranscribing] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
const [resultText, setResultText] = useState("");
|
||||
const [isLoadingModel, setIsLoadingModel] = useState(false);
|
||||
const recorderRef = useRef(null);
|
||||
const abortRef = useRef(null);
|
||||
|
||||
const installedModels = useMemo(() => models.filter((model) => model.installed), [models]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const [nextStatus, nextModels] = await Promise.all([
|
||||
getSpeechStatus(),
|
||||
listSpeechModels(),
|
||||
]);
|
||||
setStatus(nextStatus);
|
||||
setModels(nextModels);
|
||||
setSelectedModel((current) => {
|
||||
const installed = nextModels.filter((model) => model.installed);
|
||||
if (current && installed.some((model) => model.filename === current || model.id === current)) return current;
|
||||
const active = nextStatus.settings?.model;
|
||||
if (active && installed.some((model) => model.filename === active)) return active;
|
||||
return installed.find((model) => model.recommended)?.filename ||
|
||||
installed[0]?.filename ||
|
||||
"";
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch((err) => showAlert?.({ title: "Speech Status Failed", message: err.message || String(err), danger: true }));
|
||||
}, [refresh, showAlert]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTranscript) return;
|
||||
setResult(selectedTranscript);
|
||||
setResultText(formatTranscriptWithTimes(selectedTranscript));
|
||||
}, [selectedTranscript]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioUrl) URL.revokeObjectURL(audioUrl);
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [audioUrl]);
|
||||
|
||||
const setCurrentAudio = useCallback((blob, name) => {
|
||||
setAudioBlob(blob);
|
||||
setAudioName(name);
|
||||
setResult(null);
|
||||
setResultText("");
|
||||
setAudioUrl((oldUrl) => {
|
||||
if (oldUrl) URL.revokeObjectURL(oldUrl);
|
||||
return URL.createObjectURL(blob);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearCurrentAudio = useCallback(() => {
|
||||
setAudioBlob(null);
|
||||
setAudioName("");
|
||||
setAudioUrl((oldUrl) => {
|
||||
if (oldUrl) URL.revokeObjectURL(oldUrl);
|
||||
return "";
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleLoadSpeechModel = async () => {
|
||||
if (!selectedModel) return;
|
||||
setIsLoadingModel(true);
|
||||
try {
|
||||
if (!status.ready || status.settings?.model !== selectedModel) {
|
||||
await startSpeech(selectedModel, { language, threads });
|
||||
}
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
showAlert?.({ title: "Speech Model Load Failed", message: err.message || String(err), danger: true });
|
||||
} finally {
|
||||
setIsLoadingModel(false);
|
||||
}
|
||||
};
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const audioContext = new AudioContext();
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
const processor = audioContext.createScriptProcessor(4096, 1, 1);
|
||||
const chunks = [];
|
||||
processor.onaudioprocess = (event) => {
|
||||
chunks.push(new Float32Array(event.inputBuffer.getChannelData(0)));
|
||||
};
|
||||
source.connect(processor);
|
||||
processor.connect(audioContext.destination);
|
||||
recorderRef.current = { stream, audioContext, source, processor, chunks, sampleRate: audioContext.sampleRate };
|
||||
setIsRecording(true);
|
||||
} catch (err) {
|
||||
showAlert?.({ title: "Microphone Failed", message: err.message || String(err), danger: true });
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = async () => {
|
||||
const recorder = recorderRef.current;
|
||||
if (!recorder) return;
|
||||
recorder.processor.disconnect();
|
||||
recorder.source.disconnect();
|
||||
recorder.stream.getTracks().forEach((track) => track.stop());
|
||||
await recorder.audioContext.close();
|
||||
recorderRef.current = null;
|
||||
setIsRecording(false);
|
||||
const merged = flattenFloat32(recorder.chunks);
|
||||
const resampled = resampleLinear(merged, recorder.sampleRate, 16000);
|
||||
const wav = encodeWavPcm16(resampled, 16000);
|
||||
setCurrentAudio(wav, `recording-${new Date().toISOString().replace(/[:.]/g, "-")}.wav`);
|
||||
};
|
||||
|
||||
const handleUploadAudio = (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
if (!file.name.toLowerCase().endsWith(".wav")) {
|
||||
showAlert?.({ title: "Unsupported Audio", message: "V1 accepts WAV files only. MP3/WebM/M4A will need the later FFmpeg path.", danger: true });
|
||||
return;
|
||||
}
|
||||
setCurrentAudio(file, file.name);
|
||||
};
|
||||
|
||||
const handleTranscribe = async () => {
|
||||
if (!audioBlob || !selectedModel) return;
|
||||
setIsTranscribing(true);
|
||||
setResult(null);
|
||||
setResultText("");
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
try {
|
||||
if (!status.ready || status.settings?.model !== selectedModel) {
|
||||
await startSpeech(selectedModel, { language, threads });
|
||||
}
|
||||
const transcription = await transcribeSpeech(audioBlob, {
|
||||
model: selectedModel,
|
||||
language,
|
||||
threads,
|
||||
filename: audioName || "audio.wav",
|
||||
signal: controller.signal,
|
||||
});
|
||||
setResult(transcription);
|
||||
setResultText(formatTranscriptWithTimes(transcription));
|
||||
await refresh();
|
||||
await onTranscriptionsChanged?.();
|
||||
} catch (err) {
|
||||
if (err.name !== "AbortError") {
|
||||
showAlert?.({ title: "Transcription Failed", message: err.message || String(err), danger: true });
|
||||
}
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setIsTranscribing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
abortRef.current?.abort();
|
||||
setIsTranscribing(false);
|
||||
};
|
||||
|
||||
const saveText = () => {
|
||||
if (!resultText.trim()) return;
|
||||
downloadBlob("transcription.txt", resultText, "text/plain;charset=utf-8");
|
||||
};
|
||||
|
||||
const saveJson = () => {
|
||||
const payload = { ...(result || {}), text: resultText };
|
||||
downloadBlob("transcription.json", JSON.stringify(payload, null, 2), "application/json;charset=utf-8");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="workspace-area speech-workspace">
|
||||
<div className="workspace-title-section">
|
||||
<h2 className="workspace-title">Speech Transcriber</h2>
|
||||
<p className="workspace-subtitle">
|
||||
Record microphone audio or upload a WAV file, then transcribe locally with whisper.cpp.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="speech-grid">
|
||||
<section className="m3-card speech-panel">
|
||||
<div className="speech-panel-header">
|
||||
<h3>Runtime</h3>
|
||||
<span className={`status-chip ${status.backendInstalled ? "" : "offline"}`}>
|
||||
{status.backendInstalled ? (status.ready ? "Ready" : "Installed") : "Backend missing"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{status.error && <div className="text-progress error">{status.error}</div>}
|
||||
|
||||
<label className="speech-label">
|
||||
Whisper model
|
||||
<select className="m3-input" value={selectedModel} onChange={(event) => setSelectedModel(event.target.value)}>
|
||||
{installedModels.length === 0 && <option value="">No downloaded speech models</option>}
|
||||
{installedModels.map((model) => (
|
||||
<option key={model.id || model.filename} value={model.filename}>
|
||||
{model.name || model.filename}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="speech-controls-row">
|
||||
<label className="speech-label">
|
||||
Language
|
||||
<select className="m3-input" value={language} onChange={(event) => setLanguage(event.target.value)}>
|
||||
{LANGUAGE_OPTIONS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="speech-label">
|
||||
Threads
|
||||
<input
|
||||
className="m3-input"
|
||||
type="number"
|
||||
min="1"
|
||||
max="32"
|
||||
value={threads}
|
||||
onChange={(event) => setThreads(Math.max(1, Math.min(32, Number(event.target.value) || 1)))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="speech-button-row">
|
||||
<button
|
||||
className="m3-btn m3-btn-filled"
|
||||
onClick={handleLoadSpeechModel}
|
||||
disabled={!selectedModel || isLoadingModel || (status.ready && status.settings?.model === selectedModel)}
|
||||
>
|
||||
{isLoadingModel ? <LoaderCircle className="progress-spinner" size={14} /> : <Play size={14} />}
|
||||
<span>{status.ready && status.settings?.model === selectedModel ? "Loaded" : isLoadingModel ? "Loading" : "Load"}</span>
|
||||
</button>
|
||||
<button className="m3-btn m3-btn-outlined" onClick={refresh}>
|
||||
<Play size={14} />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{installedModels.length === 0 && (
|
||||
<div className="text-progress">
|
||||
Download a Whisper model from Model Manager, Speech Models first.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="m3-card speech-panel">
|
||||
<div className="speech-panel-header">
|
||||
<h3>Audio</h3>
|
||||
<span className="status-chip">{audioName || "No audio selected"}</span>
|
||||
</div>
|
||||
|
||||
<div className="speech-capture-zone">
|
||||
<FileAudio size={42} />
|
||||
<div>
|
||||
<strong>{audioName || "Record or upload WAV"}</strong>
|
||||
<p>Microphone recordings are converted to 16 kHz mono PCM16 WAV before transcription.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{audioUrl && <audio controls src={audioUrl} style={{ width: "100%" }} />}
|
||||
|
||||
<div className="speech-button-row">
|
||||
{isRecording ? (
|
||||
<button className="m3-btn m3-btn-error" onClick={stopRecording}>
|
||||
<Square size={14} />
|
||||
<span>Stop Recording</span>
|
||||
</button>
|
||||
) : (
|
||||
<button className="m3-btn m3-btn-filled" onClick={startRecording} disabled={isTranscribing}>
|
||||
<Mic size={14} />
|
||||
<span>Record</span>
|
||||
</button>
|
||||
)}
|
||||
<label className="m3-btn m3-btn-outlined">
|
||||
<Upload size={14} />
|
||||
<span>Upload WAV</span>
|
||||
<input type="file" accept=".wav,audio/wav" onChange={handleUploadAudio} hidden />
|
||||
</label>
|
||||
{audioBlob && (
|
||||
<button className="m3-btn m3-btn-error" onClick={clearCurrentAudio} disabled={isTranscribing || isRecording}>
|
||||
<X size={14} />
|
||||
<span>Remove Audio</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="speech-button-row">
|
||||
{isTranscribing ? (
|
||||
<button className="m3-btn m3-btn-error" onClick={handleCancel}>
|
||||
<Square size={14} />
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="m3-btn m3-btn-filled"
|
||||
onClick={handleTranscribe}
|
||||
disabled={!audioBlob || !selectedModel || !status.backendInstalled}
|
||||
>
|
||||
<Mic size={14} />
|
||||
<span>Transcribe</span>
|
||||
</button>
|
||||
)}
|
||||
{isTranscribing && <span className="text-progress"><LoaderCircle className="progress-spinner" size={14} /> Transcribing locally...</span>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="m3-card speech-result-panel">
|
||||
<div className="speech-panel-header">
|
||||
<h3>Transcript</h3>
|
||||
<div className="speech-button-row">
|
||||
<button className="m3-btn m3-btn-outlined" onClick={saveText} disabled={!resultText.trim()}>
|
||||
<Save size={14} />
|
||||
<span>TXT</span>
|
||||
</button>
|
||||
<button className="m3-btn m3-btn-outlined" onClick={saveJson} disabled={!resultText.trim()}>
|
||||
<Save size={14} />
|
||||
<span>JSON</span>
|
||||
</button>
|
||||
{status.ready && (
|
||||
<button className="m3-btn m3-btn-error" onClick={() => stopSpeech().then(refresh)}>
|
||||
<Square size={14} />
|
||||
<span>Stop Runtime</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
className="m3-textarea speech-result-textarea"
|
||||
value={resultText}
|
||||
onChange={(event) => setResultText(event.target.value)}
|
||||
placeholder="Transcript output will appear here."
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -570,6 +570,89 @@ export async function deleteLlmModel(filename) {
|
||||
return await readJsonResponse(res, "The local server returned an invalid text model delete response.");
|
||||
}
|
||||
|
||||
export async function getSpeechStatus() {
|
||||
try {
|
||||
const res = await fetch("/api/speech/status");
|
||||
return await readJsonResponse(res, "The local server returned invalid speech backend status.");
|
||||
} catch (err) {
|
||||
return { ready: false, running: false, backendInstalled: false, error: err.message, settings: {} };
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSpeechModels() {
|
||||
const res = await fetch("/api/speech/models");
|
||||
const data = await readJsonResponse(res, "The local server returned invalid speech model data.");
|
||||
return data.models || [];
|
||||
}
|
||||
|
||||
export async function startSpeech(model, options = {}) {
|
||||
const res = await fetch("/api/speech/start", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
language: options.language || "auto",
|
||||
threads: options.threads,
|
||||
}),
|
||||
});
|
||||
return await readJsonResponse(res, "The local server returned an invalid speech start response.");
|
||||
}
|
||||
|
||||
export async function stopSpeech() {
|
||||
const res = await fetch("/api/speech/stop", { method: "POST" });
|
||||
return await readJsonResponse(res, "The local server returned an invalid speech stop response.");
|
||||
}
|
||||
|
||||
export async function transcribeSpeech(fileOrBlob, options = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (options.model) params.set("model", options.model);
|
||||
if (options.language) params.set("language", options.language);
|
||||
if (options.filename) params.set("filename", options.filename);
|
||||
if (options.threads) params.set("threads", String(options.threads));
|
||||
|
||||
const res = await fetch(`/api/speech/transcribe?${params.toString()}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "audio/wav" },
|
||||
body: fileOrBlob,
|
||||
signal: options.signal,
|
||||
});
|
||||
const data = await readJsonResponse(res, "The local server returned an invalid transcription response.");
|
||||
return data.transcription;
|
||||
}
|
||||
|
||||
export async function downloadSpeechModel(modelIdOrUrl, filename = null) {
|
||||
const isUrl = /^https?:\/\//i.test(String(modelIdOrUrl || ""));
|
||||
const res = await fetch("/api/speech/download-model", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: isUrl ? "" : modelIdOrUrl,
|
||||
url: isUrl ? modelIdOrUrl : "",
|
||||
filename,
|
||||
}),
|
||||
});
|
||||
return await readJsonResponse(res, "The local server returned an invalid speech download response.");
|
||||
}
|
||||
|
||||
export async function importSpeechModel(file, onProgress, signal) {
|
||||
return await uploadModelFileToEndpoint(file, "/api/speech/import-model", onProgress, signal);
|
||||
}
|
||||
|
||||
export async function deleteSpeechModel(filename) {
|
||||
const res = await fetch("/api/speech/delete-model", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename }),
|
||||
});
|
||||
return await readJsonResponse(res, "The local server returned an invalid speech model delete response.");
|
||||
}
|
||||
|
||||
export async function listSpeechTranscriptions() {
|
||||
const res = await fetch("/api/speech/transcriptions");
|
||||
const data = await readJsonResponse(res, "The local server returned invalid transcription history.");
|
||||
return data.transcriptions || [];
|
||||
}
|
||||
|
||||
export async function getLlmRecommendations(useCase = "chat", limit = 10) {
|
||||
try {
|
||||
const res = await fetch(`/api/llm/recommend?useCase=${encodeURIComponent(useCase)}&limit=${limit}`);
|
||||
|
||||
@@ -133,12 +133,16 @@ LLM_ROCM_PATH="$APP_DIR/llm-backend/linux/rocm/llama-server"
|
||||
LLM_SYCL_PATH="$APP_DIR/llm-backend/linux/sycl/llama-server"
|
||||
LLM_VULKAN_PATH="$APP_DIR/llm-backend/linux/vulkan/llama-server"
|
||||
LLM_CPU_PATH="$APP_DIR/llm-backend/linux/cpu/llama-server"
|
||||
SPEECH_BACKEND_PATH="$APP_DIR/speech-backend/linux/whisper-cli"
|
||||
if [[ ! -x "$CPU_BACKEND_PATH" || ! -x "$CPU_SERVER_PATH" ]] && [[ ! -x "$BACKEND_PATH" || ! -x "$VULKAN_SERVER_PATH" ]]; then
|
||||
SETUP_REASON="Linux backend binaries are missing or not executable."
|
||||
fi
|
||||
if [[ ! -x "$LLM_CUDA_PATH" && ! -x "$LLM_ROCM_PATH" && ! -x "$LLM_SYCL_PATH" && ! -x "$LLM_VULKAN_PATH" && ! -x "$LLM_CPU_PATH" ]]; then
|
||||
SETUP_REASON="Linux llama.cpp text backend is missing or not executable."
|
||||
fi
|
||||
if [[ ! -x "$SPEECH_BACKEND_PATH" ]]; then
|
||||
SETUP_REASON="Linux whisper.cpp speech backend is missing or not executable."
|
||||
fi
|
||||
|
||||
if [[ -n "$SETUP_REASON" ]]; then
|
||||
echo ""
|
||||
@@ -216,6 +220,7 @@ 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 ""
|
||||
echo " Press Ctrl+C in this window to stop all services."
|
||||
echo " ============================================================"
|
||||
|
||||
@@ -188,6 +188,7 @@ 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: Available when a whisper.cpp binary is present"
|
||||
echo ""
|
||||
echo " Press Ctrl+C in this window to stop all services."
|
||||
echo " ============================================================"
|
||||
|
||||
+20
-1
@@ -32,6 +32,13 @@ if (Test-Path $llmBackendDir) {
|
||||
Remove-Item $llmBackendDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Delete whisper.cpp backend
|
||||
$speechBackendDir = Join-Path $appDir "speech-backend"
|
||||
if (Test-Path $speechBackendDir) {
|
||||
Write-Host " >> Removing whisper.cpp speech backend binaries..." -ForegroundColor Cyan
|
||||
Remove-Item $speechBackendDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Delete dist
|
||||
$distDir = Join-Path $appDir "dist"
|
||||
if (Test-Path $distDir) {
|
||||
@@ -51,6 +58,18 @@ if (Test-Path $llmModelsDir) {
|
||||
Write-Host " >> Preserving text models in app/llm-models." -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
# Preserve speech models
|
||||
$speechModelsDir = Join-Path $appDir "speech-models"
|
||||
if (Test-Path $speechModelsDir) {
|
||||
Write-Host " >> Preserving speech models in app/speech-models." -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
# Preserve transcriptions
|
||||
$transcriptionsDir = Join-Path $appDir "transcriptions"
|
||||
if (Test-Path $transcriptionsDir) {
|
||||
Write-Host " >> Preserving speech transcripts in app/transcriptions." -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
# Preserve OpenVINO models
|
||||
$openVinoModelsDir = Join-Path $appDir "openvino-models"
|
||||
if (Test-Path $openVinoModelsDir) {
|
||||
@@ -82,7 +101,7 @@ if (Test-Path $lockFile) {
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " ============================================================" -ForegroundColor Green
|
||||
Write-Host " Reset complete. Image models, text models, OpenVINO models, and generated outputs were preserved." -ForegroundColor Green
|
||||
Write-Host " Reset complete. Image models, text models, speech models, transcripts, OpenVINO models, and generated outputs were preserved." -ForegroundColor Green
|
||||
Write-Host " ============================================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Read-Host " Press Enter to close..."
|
||||
+17
-1
@@ -34,6 +34,12 @@ if [[ -d "$APP_DIR/llm-backend" ]]; then
|
||||
rm -rf "$APP_DIR/llm-backend"
|
||||
fi
|
||||
|
||||
# Delete whisper.cpp backend
|
||||
if [[ -d "$APP_DIR/speech-backend" ]]; then
|
||||
echo " >> Removing whisper.cpp speech backend binaries..."
|
||||
rm -rf "$APP_DIR/speech-backend"
|
||||
fi
|
||||
|
||||
# Delete dist
|
||||
if [[ -d "$APP_DIR/dist" ]]; then
|
||||
echo " >> Removing dist/ build folder..."
|
||||
@@ -50,6 +56,16 @@ if [[ -d "$APP_DIR/llm-models" ]]; then
|
||||
echo " >> Preserving text models in app/llm-models."
|
||||
fi
|
||||
|
||||
# Preserve speech models
|
||||
if [[ -d "$APP_DIR/speech-models" ]]; then
|
||||
echo " >> Preserving speech models in app/speech-models."
|
||||
fi
|
||||
|
||||
# Preserve transcriptions
|
||||
if [[ -d "$APP_DIR/transcriptions" ]]; then
|
||||
echo " >> Preserving speech transcripts in app/transcriptions."
|
||||
fi
|
||||
|
||||
# Preserve OpenVINO models
|
||||
if [[ -d "$APP_DIR/openvino-models" ]]; then
|
||||
echo " >> Preserving OpenVINO models in app/openvino-models."
|
||||
@@ -76,7 +92,7 @@ fi
|
||||
|
||||
echo ""
|
||||
echo " ============================================================"
|
||||
echo " Reset complete. Image models, text models, OpenVINO models, and generated outputs were preserved."
|
||||
echo " Reset complete. Image models, text models, speech models, transcripts, OpenVINO models, and generated outputs were preserved."
|
||||
echo " ============================================================"
|
||||
echo ""
|
||||
read -rp " Press Enter to close..."
|
||||
+465
-10
@@ -30,8 +30,10 @@ function readPort(value, fallback) {
|
||||
const PORT_FRONTEND = readPort(process.env.PORT || process.env.FRONTEND_PORT, 1420);
|
||||
const PREFERRED_BACKEND_PORT = readPort(process.env.BACKEND_PORT || process.env.SD_BACKEND_PORT, 8080);
|
||||
const PREFERRED_LLM_PORT = readPort(process.env.LLM_PORT, 10086);
|
||||
const PREFERRED_SPEECH_PORT = readPort(process.env.SPEECH_PORT, 10088);
|
||||
let PORT_BACKEND = PREFERRED_BACKEND_PORT;
|
||||
let PORT_LLM = PREFERRED_LLM_PORT;
|
||||
let PORT_SPEECH = PREFERRED_SPEECH_PORT;
|
||||
const MAX_JSON_BODY_BYTES = 64 * 1024 * 1024;
|
||||
const SERVER_BUILD = "text-image-v1";
|
||||
const ROOT = path.join(__dirname, "..");
|
||||
@@ -105,6 +107,34 @@ const LLM_BENCHMARK_PATH = path.join(LLM_CONFIG_DIR, "llm-benchmarks.json");
|
||||
if (!fs.existsSync(LLM_CONFIG_DIR)) {
|
||||
fs.mkdirSync(LLM_CONFIG_DIR, { recursive: true });
|
||||
}
|
||||
const SPEECH_MODELS = path.join(ROOT, "app", "speech-models");
|
||||
if (!fs.existsSync(SPEECH_MODELS)) {
|
||||
fs.mkdirSync(SPEECH_MODELS, { recursive: true });
|
||||
}
|
||||
const TRANSCRIPTIONS = path.join(ROOT, "app", "transcriptions");
|
||||
if (!fs.existsSync(TRANSCRIPTIONS)) {
|
||||
fs.mkdirSync(TRANSCRIPTIONS, { recursive: true });
|
||||
}
|
||||
const SPEECH_BACKEND_PATHS = {
|
||||
winCli: path.join(ROOT, "app", "speech-backend", "win", "whisper-cli.exe"),
|
||||
winServer: path.join(ROOT, "app", "speech-backend", "win", "whisper-server.exe"),
|
||||
linuxCli: path.join(ROOT, "app", "speech-backend", "linux", "whisper-cli"),
|
||||
linuxServer: path.join(ROOT, "app", "speech-backend", "linux", "whisper-server"),
|
||||
macCli: path.join(ROOT, "app", "speech-backend", "mac", "whisper-cli"),
|
||||
macServer: path.join(ROOT, "app", "speech-backend", "mac", "whisper-server"),
|
||||
};
|
||||
const SPEECH_MODEL_CATALOG = [
|
||||
{ id: "tiny.en", name: "Whisper Tiny English", filename: "ggml-tiny.en.bin", size: "75 MB", language: "English", recommended: false },
|
||||
{ id: "tiny.en-q5_1", name: "Whisper Tiny English Q5", filename: "ggml-tiny.en-q5_1.bin", size: "31 MB", language: "English", recommended: false },
|
||||
{ id: "base.en", name: "Whisper Base English", filename: "ggml-base.en.bin", size: "142 MB", language: "English", recommended: true },
|
||||
{ id: "base.en-q5_1", name: "Whisper Base English Q5", filename: "ggml-base.en-q5_1.bin", size: "57 MB", language: "English", recommended: true },
|
||||
{ id: "small.en", name: "Whisper Small English", filename: "ggml-small.en.bin", size: "466 MB", language: "English", recommended: false },
|
||||
{ id: "small.en-q5_1", name: "Whisper Small English Q5", filename: "ggml-small.en-q5_1.bin", size: "181 MB", language: "English", recommended: false },
|
||||
{ id: "tiny", name: "Whisper Tiny Multilingual", filename: "ggml-tiny.bin", size: "75 MB", language: "Multilingual", recommended: false },
|
||||
{ id: "base", name: "Whisper Base Multilingual", filename: "ggml-base.bin", size: "142 MB", language: "Multilingual", recommended: false },
|
||||
{ id: "base-q5_1", name: "Whisper Base Multilingual Q5", filename: "ggml-base-q5_1.bin", size: "57 MB", language: "Multilingual", recommended: false },
|
||||
{ id: "small", name: "Whisper Small Multilingual", filename: "ggml-small.bin", size: "466 MB", language: "Multilingual", recommended: false },
|
||||
];
|
||||
const OPENVINO_MODELS = path.join(ROOT, "app", "openvino-models");
|
||||
if (!fs.existsSync(OPENVINO_MODELS)) {
|
||||
fs.mkdirSync(OPENVINO_MODELS, { recursive: true });
|
||||
@@ -142,6 +172,23 @@ let llmProcSeq = 0;
|
||||
let llmReady = false;
|
||||
let llmError = null;
|
||||
let llmOperationQueue = Promise.resolve();
|
||||
let speechReady = false;
|
||||
let speechError = null;
|
||||
let speechOperationQueue = Promise.resolve();
|
||||
let speechSettings = {
|
||||
model: null,
|
||||
language: "auto",
|
||||
threads: Math.max(1, Math.min(8, os.cpus().length || 4)),
|
||||
backendBinary: "",
|
||||
backendMode: "",
|
||||
};
|
||||
let speechTranscriptionState = {
|
||||
active: false,
|
||||
phase: "",
|
||||
progress: 0,
|
||||
model: "",
|
||||
filename: "",
|
||||
};
|
||||
let llmSettings = {
|
||||
model: null,
|
||||
threads: Math.max(1, Math.min(16, os.cpus().length || 4)),
|
||||
@@ -1396,6 +1443,21 @@ async function findAvailableLlmPort() {
|
||||
throw new Error(`No free LLM port found. Tried ${PREFERRED_LLM_PORT} and 28121-28160.`);
|
||||
}
|
||||
|
||||
async function findAvailableSpeechPort() {
|
||||
const preferred = await checkPort(PREFERRED_SPEECH_PORT);
|
||||
if (preferred.available) return PREFERRED_SPEECH_PORT;
|
||||
|
||||
for (let port = 28161; port <= 28190; port += 1) {
|
||||
const candidate = await checkPort(port);
|
||||
if (candidate.available) {
|
||||
console.log(` [speech] Preferred port ${PREFERRED_SPEECH_PORT} is busy; using ${port} instead.`);
|
||||
return port;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`No free speech port found. Tried ${PREFERRED_SPEECH_PORT} and 28161-28190.`);
|
||||
}
|
||||
|
||||
function getLlmBackend() {
|
||||
return getLlmBackendCandidates()[0] || null;
|
||||
}
|
||||
@@ -1537,6 +1599,283 @@ function getLlmModels() {
|
||||
}
|
||||
}
|
||||
|
||||
function findExistingFile(candidates) {
|
||||
return candidates.find((candidate) => candidate && fs.existsSync(candidate)) || "";
|
||||
}
|
||||
|
||||
function getSpeechBackend() {
|
||||
if (osPlatform === "win32") {
|
||||
return {
|
||||
cli: findExistingFile([SPEECH_BACKEND_PATHS.winCli, path.join(ROOT, "app", "speech-backend", "win", "main.exe")]),
|
||||
server: findExistingFile([SPEECH_BACKEND_PATHS.winServer, path.join(ROOT, "app", "speech-backend", "win", "server.exe")]),
|
||||
mode: "whisper.cpp CPU",
|
||||
};
|
||||
}
|
||||
if (osPlatform === "darwin") {
|
||||
return {
|
||||
cli: findExistingFile([SPEECH_BACKEND_PATHS.macCli, path.join(ROOT, "app", "speech-backend", "mac", "main")]),
|
||||
server: findExistingFile([SPEECH_BACKEND_PATHS.macServer, path.join(ROOT, "app", "speech-backend", "mac", "server")]),
|
||||
mode: "whisper.cpp Metal/CPU",
|
||||
};
|
||||
}
|
||||
return {
|
||||
cli: findExistingFile([SPEECH_BACKEND_PATHS.linuxCli, path.join(ROOT, "app", "speech-backend", "linux", "main")]),
|
||||
server: findExistingFile([SPEECH_BACKEND_PATHS.linuxServer, path.join(ROOT, "app", "speech-backend", "linux", "server")]),
|
||||
mode: "whisper.cpp CPU",
|
||||
};
|
||||
}
|
||||
|
||||
function getSpeechModels() {
|
||||
const catalog = SPEECH_MODEL_CATALOG.map((model) => {
|
||||
const modelPath = path.join(SPEECH_MODELS, model.filename);
|
||||
const installed = fs.existsSync(modelPath);
|
||||
return {
|
||||
...model,
|
||||
installed,
|
||||
sizeBytes: installed ? getPathSize(modelPath) : 0,
|
||||
localSize: installed ? formatBytes(getPathSize(modelPath)) : "",
|
||||
url: `https://huggingface.co/ggerganov/whisper.cpp/resolve/main/${model.filename}`,
|
||||
};
|
||||
});
|
||||
|
||||
const known = new Set(catalog.map((model) => model.filename.toLowerCase()));
|
||||
let custom = [];
|
||||
try {
|
||||
custom = fs.readdirSync(SPEECH_MODELS)
|
||||
.filter((filename) => filename.toLowerCase().endsWith(".bin") && !known.has(filename.toLowerCase()))
|
||||
.map((filename) => {
|
||||
const sizeBytes = getPathSize(path.join(SPEECH_MODELS, filename));
|
||||
return {
|
||||
id: filename,
|
||||
name: filename,
|
||||
filename,
|
||||
size: formatBytes(sizeBytes),
|
||||
localSize: formatBytes(sizeBytes),
|
||||
sizeBytes,
|
||||
language: "Custom",
|
||||
installed: true,
|
||||
recommended: false,
|
||||
custom: true,
|
||||
};
|
||||
});
|
||||
} catch (_) {}
|
||||
return [...catalog, ...custom];
|
||||
}
|
||||
|
||||
function resolveSpeechModel(value) {
|
||||
const raw = String(value || "").trim();
|
||||
const model = getSpeechModels().find((item) => item.id === raw || item.filename === raw) ||
|
||||
getSpeechModels().find((item) => item.installed);
|
||||
if (!model) throw new Error("Download or import a Whisper model first.");
|
||||
const modelPath = path.join(SPEECH_MODELS, path.basename(model.filename));
|
||||
if (!pathInside(modelPath, SPEECH_MODELS) || !fs.existsSync(modelPath)) {
|
||||
throw new Error(`Speech model is not installed: ${model.filename}`);
|
||||
}
|
||||
return { ...model, path: modelPath };
|
||||
}
|
||||
|
||||
function runExclusiveSpeechOperation(operation) {
|
||||
const run = speechOperationQueue.catch(() => {}).then(operation);
|
||||
speechOperationQueue = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
|
||||
async function startSpeech(settings = {}) {
|
||||
const backend = getSpeechBackend();
|
||||
if (!backend.cli) {
|
||||
throw new Error("whisper.cpp is not installed. Run the platform setup script to install the speech backend.");
|
||||
}
|
||||
const model = resolveSpeechModel(settings.model);
|
||||
await killBackend();
|
||||
await killOpenVinoWorker();
|
||||
await killLlm();
|
||||
PORT_SPEECH = await findAvailableSpeechPort();
|
||||
speechError = null;
|
||||
speechReady = true;
|
||||
speechSettings = {
|
||||
...speechSettings,
|
||||
model: model.filename,
|
||||
language: settings.language || speechSettings.language || "auto",
|
||||
threads: Math.max(1, Math.min(32, Number(settings.threads) || speechSettings.threads || 4)),
|
||||
backendBinary: path.basename(backend.cli),
|
||||
backendMode: backend.mode,
|
||||
};
|
||||
}
|
||||
|
||||
async function stopSpeech() {
|
||||
speechReady = false;
|
||||
speechError = null;
|
||||
speechTranscriptionState = { active: false, phase: "", progress: 0, model: "", filename: "" };
|
||||
}
|
||||
|
||||
function readBinaryBody(req, limitBytes = 250 * 1024 * 1024) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
let bytes = 0;
|
||||
req.on("data", (chunk) => {
|
||||
bytes += chunk.length;
|
||||
if (bytes > limitBytes) {
|
||||
reject(new Error(`Audio file is too large. Limit is ${Math.round(limitBytes / (1024 * 1024))} MB.`));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function isWaveBuffer(buffer) {
|
||||
return buffer?.length > 44 &&
|
||||
buffer.toString("ascii", 0, 4) === "RIFF" &&
|
||||
buffer.toString("ascii", 8, 12) === "WAVE";
|
||||
}
|
||||
|
||||
function saveTranscript(result) {
|
||||
const createdAt = new Date().toISOString();
|
||||
const stamp = createdAt.replace(/[:.]/g, "-");
|
||||
const base = `transcript-${stamp}-${safeOutputName(result.sourceFilename || "audio")}`;
|
||||
const textFilename = `${base}.txt`;
|
||||
const jsonFilename = `${base}.json`;
|
||||
fs.writeFileSync(path.join(TRANSCRIPTIONS, textFilename), result.text || "", "utf8");
|
||||
const metadata = {
|
||||
...result,
|
||||
createdAt,
|
||||
textFile: textFilename,
|
||||
metadata: jsonFilename,
|
||||
};
|
||||
fs.writeFileSync(path.join(TRANSCRIPTIONS, jsonFilename), JSON.stringify(metadata, null, 2), "utf8");
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function listTranscriptions() {
|
||||
try {
|
||||
return fs.readdirSync(TRANSCRIPTIONS)
|
||||
.filter((file) => file.toLowerCase().endsWith(".json"))
|
||||
.map((file) => {
|
||||
const filePath = path.join(TRANSCRIPTIONS, file);
|
||||
try {
|
||||
const metadata = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
const stat = fs.statSync(filePath);
|
||||
return {
|
||||
...metadata,
|
||||
filename: file,
|
||||
displayName: metadata.sourceFilename || metadata.textFile || file,
|
||||
sizeBytes: stat.size,
|
||||
size: formatBytes(stat.size),
|
||||
modifiedAt: stat.mtime.toISOString(),
|
||||
createdAt: metadata.createdAt || stat.mtime.toISOString(),
|
||||
url: `/transcriptions/${encodeURIComponent(file)}`,
|
||||
textUrl: metadata.textFile ? `/transcriptions/${encodeURIComponent(metadata.textFile)}` : "",
|
||||
};
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || "")));
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function transcribeWavBuffer(buffer, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!isWaveBuffer(buffer)) {
|
||||
reject(new Error("Speech transcription currently accepts WAV audio only."));
|
||||
return;
|
||||
}
|
||||
const backend = getSpeechBackend();
|
||||
if (!backend.cli) {
|
||||
reject(new Error("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.wav") || "recording.wav";
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const tempBase = path.join(TRANSCRIPTIONS, `.speech-${stamp}-${sourceFilename.replace(/\.[^.]+$/, "")}`);
|
||||
const wavPath = `${tempBase}.wav`;
|
||||
const outBase = `${tempBase}-out`;
|
||||
fs.writeFileSync(wavPath, buffer);
|
||||
|
||||
speechTranscriptionState = {
|
||||
active: true,
|
||||
phase: "Transcribing audio...",
|
||||
progress: -1,
|
||||
model: model.filename,
|
||||
filename: sourceFilename,
|
||||
};
|
||||
|
||||
const args = [
|
||||
"-m", model.path,
|
||||
"-f", wavPath,
|
||||
"-otxt",
|
||||
"-oj",
|
||||
"-of", outBase,
|
||||
"-t", String(Math.max(1, Math.min(32, Number(options.threads) || speechSettings.threads || 4))),
|
||||
];
|
||||
if (language && language !== "auto") {
|
||||
args.push("-l", language);
|
||||
} else {
|
||||
args.push("-l", "auto");
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
console.log(" [speech] Starting:", backend.cli, args.join(" "));
|
||||
const proc = spawn(backend.cli, args, {
|
||||
stdio: "pipe",
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
...(osPlatform === "linux" ? { LD_LIBRARY_PATH: path.dirname(backend.cli) + (process.env.LD_LIBRARY_PATH ? `:${process.env.LD_LIBRARY_PATH}` : "") } : {}),
|
||||
...(osPlatform === "darwin" ? { DYLD_LIBRARY_PATH: path.dirname(backend.cli) + (process.env.DYLD_LIBRARY_PATH ? `:${process.env.DYLD_LIBRARY_PATH}` : "") } : {}),
|
||||
},
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
proc.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
process.stdout.write(" [speech] " + data.toString());
|
||||
});
|
||||
proc.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
process.stderr.write(" [speech-err] " + data.toString());
|
||||
});
|
||||
proc.on("exit", (code) => {
|
||||
try { fs.unlinkSync(wavPath); } catch (_) {}
|
||||
speechTranscriptionState = { active: false, phase: code === 0 ? "Complete" : "Failed", progress: code === 0 ? 100 : 0, model: model.filename, filename: sourceFilename };
|
||||
if (code !== 0) {
|
||||
const message = (stderr || stdout || `whisper.cpp exited with code ${code}`).trim().slice(-1200);
|
||||
speechError = message;
|
||||
reject(new Error(message));
|
||||
return;
|
||||
}
|
||||
const txtPath = `${outBase}.txt`;
|
||||
const jsonPath = `${outBase}.json`;
|
||||
const text = fs.existsSync(txtPath) ? fs.readFileSync(txtPath, "utf8").trim() : stdout.trim();
|
||||
let raw = null;
|
||||
try {
|
||||
raw = fs.existsSync(jsonPath) ? JSON.parse(fs.readFileSync(jsonPath, "utf8")) : null;
|
||||
} catch (_) {}
|
||||
try { fs.unlinkSync(txtPath); } catch (_) {}
|
||||
try { fs.unlinkSync(jsonPath); } catch (_) {}
|
||||
const saved = saveTranscript({
|
||||
text,
|
||||
model: model.filename,
|
||||
modelName: model.name,
|
||||
language,
|
||||
sourceFilename,
|
||||
durationMs: Date.now() - startedAt,
|
||||
raw,
|
||||
});
|
||||
speechError = null;
|
||||
resolve(saved);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function pingLlmReady(expectedModel = "") {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.get(`http://127.0.0.1:${PORT_LLM}/v1/models`, (res) => {
|
||||
@@ -3730,7 +4069,7 @@ function cancelModelDownload() {
|
||||
try { fs.unlinkSync(activeDownload.tempPath || activeDownload.destPath); } catch (_) {}
|
||||
}
|
||||
} else if (filename) {
|
||||
const targetDir = downloadState.kind === "text" ? LLM_MODELS : MODELS;
|
||||
const targetDir = downloadState.kind === "text" ? LLM_MODELS : downloadState.kind === "speech" ? SPEECH_MODELS : MODELS;
|
||||
try { fs.unlinkSync(`${path.join(targetDir, filename)}.part`); } catch (_) {}
|
||||
}
|
||||
activeDownload = null;
|
||||
@@ -3754,7 +4093,7 @@ const MIME = {
|
||||
".html": "text/html", ".js": "application/javascript",
|
||||
".css": "text/css", ".png": "image/png",
|
||||
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon", ".json": "application/json",
|
||||
".ico": "image/x-icon", ".json": "application/json", ".wav": "audio/wav", ".txt": "text/plain",
|
||||
".woff2":"font/woff2", ".woff": "font/woff", ".ttf": "font/ttf",
|
||||
};
|
||||
|
||||
@@ -4047,12 +4386,17 @@ function deleteGeneratedOutputs(outputs = []) {
|
||||
return deleted;
|
||||
}
|
||||
|
||||
function streamModelUpload(req, filename, targetDir = MODELS, textOnly = false) {
|
||||
function streamModelUpload(req, filename, targetDir = MODELS, mode = "image") {
|
||||
return new Promise((resolve, reject) => {
|
||||
const safeFilename = path.basename(filename || "");
|
||||
const lowerName = safeFilename.toLowerCase();
|
||||
if (!safeFilename || (textOnly ? !lowerName.endsWith(".gguf") : !isModelFile(lowerName))) {
|
||||
reject(new Error(textOnly ? "Filename must end with .gguf" : "Filename must end with .gguf, .safetensors, or .ckpt"));
|
||||
const valid = mode === "text"
|
||||
? lowerName.endsWith(".gguf")
|
||||
: mode === "speech"
|
||||
? lowerName.endsWith(".bin")
|
||||
: isModelFile(lowerName);
|
||||
if (!safeFilename || !valid) {
|
||||
reject(new Error(mode === "text" ? "Filename must end with .gguf" : mode === "speech" ? "Filename must end with .bin" : "Filename must end with .gguf, .safetensors, or .ckpt"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4090,9 +4434,9 @@ function streamModelUpload(req, filename, targetDir = MODELS, textOnly = false)
|
||||
finished = true;
|
||||
fs.renameSync(tempPath, destPath);
|
||||
console.log(` [api] Imported model file: ${safeFilename}`);
|
||||
if (textOnly) {
|
||||
if (mode === "text" || mode === "speech") {
|
||||
const stats = fs.statSync(destPath);
|
||||
resolve({ filename: safeFilename, name: safeFilename, sizeBytes: stats.size, size: formatBytes(stats.size), format: "GGUF" });
|
||||
resolve({ filename: safeFilename, name: safeFilename, sizeBytes: stats.size, size: formatBytes(stats.size), format: mode === "speech" ? "Whisper GGML" : "GGUF" });
|
||||
} else {
|
||||
resolve(getModelInfo(safeFilename));
|
||||
}
|
||||
@@ -4139,6 +4483,13 @@ const server = http.createServer(async (req, res) => {
|
||||
settings: llmSettings,
|
||||
port: PORT_LLM,
|
||||
},
|
||||
speech: {
|
||||
ready: speechReady,
|
||||
error: speechError,
|
||||
settings: speechSettings,
|
||||
backend: getSpeechBackend(),
|
||||
transcription: speechTranscriptionState,
|
||||
},
|
||||
hardware: getHardwareSpecs(),
|
||||
telemetry: getTelemetry(),
|
||||
});
|
||||
@@ -4278,6 +4629,109 @@ const server = http.createServer(async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (req.url === "/api/speech/status" && req.method === "GET") {
|
||||
const backend = getSpeechBackend();
|
||||
return json(res, 200, {
|
||||
ok: true,
|
||||
ready: speechReady,
|
||||
running: speechReady,
|
||||
port: PORT_SPEECH,
|
||||
preferredPort: PREFERRED_SPEECH_PORT,
|
||||
backendInstalled: Boolean(backend.cli),
|
||||
backendPath: backend.cli || "",
|
||||
serverPath: backend.server || "",
|
||||
backendMode: backend.mode,
|
||||
error: speechError,
|
||||
settings: speechSettings,
|
||||
transcription: speechTranscriptionState,
|
||||
});
|
||||
}
|
||||
|
||||
if (req.url === "/api/speech/models" && req.method === "GET") {
|
||||
return json(res, 200, { ok: true, models: getSpeechModels() });
|
||||
}
|
||||
|
||||
if (req.url === "/api/speech/start" && req.method === "POST") {
|
||||
const body = await readJsonBody(req, res);
|
||||
if (!body) return;
|
||||
try {
|
||||
await runExclusiveSpeechOperation(() => startSpeech(body));
|
||||
return json(res, 200, { ok: true, ready: speechReady, settings: speechSettings, port: PORT_SPEECH });
|
||||
} catch (err) {
|
||||
speechError = err.message || String(err);
|
||||
return json(res, 500, { ok: false, error: speechError });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.url === "/api/speech/stop" && req.method === "POST") {
|
||||
await runExclusiveSpeechOperation(() => stopSpeech());
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
if (req.url.startsWith("/api/speech/transcribe") && req.method === "POST") {
|
||||
const parsed = new URL(req.url, `http://${req.headers.host || "localhost"}`);
|
||||
try {
|
||||
const audio = await readBinaryBody(req);
|
||||
const result = await runExclusiveSpeechOperation(() => transcribeWavBuffer(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",
|
||||
threads: parsed.searchParams.get("threads") || speechSettings.threads,
|
||||
}));
|
||||
return json(res, 200, { ok: true, transcription: result });
|
||||
} 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;
|
||||
if (downloadState.active) return json(res, 409, { ok: false, error: "Another model download is already active." });
|
||||
const modelId = String(body.modelId || body.model_id || body.model || "");
|
||||
const catalogModel = SPEECH_MODEL_CATALOG.find((model) => model.id === modelId || model.filename === modelId);
|
||||
const url = body.url ? String(body.url) : (catalogModel ? `https://huggingface.co/ggerganov/whisper.cpp/resolve/main/${catalogModel.filename}` : "");
|
||||
const filename = path.basename(String(body.filename || catalogModel?.filename || ""));
|
||||
if (!url || !filename || !filename.toLowerCase().endsWith(".bin")) {
|
||||
return json(res, 400, { ok: false, error: "A speech model .bin URL or catalog model id is required." });
|
||||
}
|
||||
startModelDownload(url, filename, SPEECH_MODELS, "speech");
|
||||
return json(res, 200, { ok: true, message: "Speech model download started", filename });
|
||||
}
|
||||
|
||||
if (req.url.startsWith("/api/speech/import-model") && req.method === "POST") {
|
||||
try {
|
||||
const parsed = new URL(req.url, `http://${req.headers.host || "localhost"}`);
|
||||
const result = await streamModelUpload(req, parsed.searchParams.get("filename"), SPEECH_MODELS, "speech");
|
||||
return json(res, 200, { ok: true, message: `Imported ${result.filename}`, model: result });
|
||||
} catch (err) {
|
||||
console.error(" [api] Failed to import speech model:", err);
|
||||
return json(res, 500, { ok: false, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.url === "/api/speech/delete-model" && req.method === "POST") {
|
||||
const body = await readJsonBody(req, res);
|
||||
if (!body) return;
|
||||
const filename = path.basename(String(body.filename || ""));
|
||||
const modelPath = path.join(SPEECH_MODELS, filename);
|
||||
if (!filename || !pathInside(modelPath, SPEECH_MODELS)) {
|
||||
return json(res, 400, { ok: false, error: "Invalid filename" });
|
||||
}
|
||||
if (speechSettings.model === filename) await stopSpeech();
|
||||
try {
|
||||
fs.unlinkSync(modelPath);
|
||||
return json(res, 200, { ok: true });
|
||||
} catch (err) {
|
||||
return json(res, err.code === "ENOENT" ? 404 : 500, { ok: false, error: err.code === "ENOENT" ? "Speech model not found" : err.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.url === "/api/speech/transcriptions" && req.method === "GET") {
|
||||
return json(res, 200, { ok: true, transcriptions: listTranscriptions() });
|
||||
}
|
||||
|
||||
function isOomError(msg) {
|
||||
if (!msg) return false;
|
||||
const lower = String(msg).toLowerCase();
|
||||
@@ -4855,7 +5309,7 @@ async function getLlmfitRecommendations(useCase = "chat", limit = 10) {
|
||||
if (req.url.startsWith("/api/llm/import-model") && req.method === "POST") {
|
||||
try {
|
||||
const parsed = new URL(req.url, `http://${req.headers.host || "localhost"}`);
|
||||
const result = await streamModelUpload(req, parsed.searchParams.get("filename"), LLM_MODELS, true);
|
||||
const result = await streamModelUpload(req, parsed.searchParams.get("filename"), LLM_MODELS, "text");
|
||||
return json(res, 200, { ok: true, message: `Imported ${result.filename}`, model: result });
|
||||
} catch (err) {
|
||||
console.error(" [api] Failed to import text model:", err);
|
||||
@@ -4942,6 +5396,7 @@ server.listen(PORT_FRONTEND, "0.0.0.0", () => {
|
||||
console.log(" Frontend : http://localhost:" + PORT_FRONTEND);
|
||||
console.log(" Image API: http://127.0.0.1:" + PORT_BACKEND);
|
||||
console.log(" Text API : starts on http://127.0.0.1:" + PREFERRED_LLM_PORT);
|
||||
console.log(" Speech : managed locally, API on frontend port");
|
||||
console.log(" ============================================================");
|
||||
console.log("");
|
||||
|
||||
@@ -4950,5 +5405,5 @@ server.listen(PORT_FRONTEND, "0.0.0.0", () => {
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on("SIGINT", async () => { await killBackend(); await killOpenVinoWorker(); await killLlm(); process.exit(0); });
|
||||
process.on("SIGTERM", async () => { await killBackend(); await killOpenVinoWorker(); await killLlm(); process.exit(0); });
|
||||
process.on("SIGINT", async () => { await killBackend(); await killOpenVinoWorker(); await killLlm(); await stopSpeech(); process.exit(0); });
|
||||
process.on("SIGTERM", async () => { await killBackend(); await killOpenVinoWorker(); await killLlm(); await stopSpeech(); process.exit(0); });
|
||||
@@ -0,0 +1,83 @@
|
||||
param([string]$Release = "v1.9.1")
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$rootDir = Split-Path -Parent $scriptDir
|
||||
$appDir = Join-Path $rootDir "app"
|
||||
$toolsDir = Join-Path $appDir "tools"
|
||||
$speechRoot = Join-Path $appDir "speech-backend\win"
|
||||
|
||||
function Enable-Tls12 {
|
||||
try {
|
||||
$tls12 = [Enum]::ToObject([Net.SecurityProtocolType], 3072)
|
||||
[Net.ServicePointManager]::SecurityProtocol =
|
||||
[Net.ServicePointManager]::SecurityProtocol -bor $tls12
|
||||
} catch {
|
||||
throw "TLS 1.2 could not be enabled: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
function Download-File {
|
||||
param([string]$Url, [string]$DestPath, [string]$Label)
|
||||
Write-Host " >> Downloading $Label..."
|
||||
Enable-Tls12
|
||||
$client = New-Object System.Net.WebClient
|
||||
$client.Headers.Add("User-Agent", "Local-AI-Image-Generator")
|
||||
try {
|
||||
$client.DownloadFile($Url, $DestPath)
|
||||
} finally {
|
||||
$client.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Find-FirstFile {
|
||||
param([string]$Root, [string[]]$Names)
|
||||
foreach ($name in $Names) {
|
||||
$found = Get-ChildItem $Root -Recurse -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -ieq $name } |
|
||||
Select-Object -First 1
|
||||
if ($found) { return $found.FullName }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $toolsDir, $speechRoot | Out-Null
|
||||
|
||||
$cliPath = Join-Path $speechRoot "whisper-cli.exe"
|
||||
if (Test-Path $cliPath) {
|
||||
Write-Host " OK whisper.cpp speech backend already ready."
|
||||
exit 0
|
||||
}
|
||||
|
||||
$asset = "whisper-bin-x64.zip"
|
||||
$archive = Join-Path $toolsDir $asset
|
||||
$extract = Join-Path $toolsDir "whisper-extract"
|
||||
$url = "https://github.com/ggml-org/whisper.cpp/releases/download/$Release/$asset"
|
||||
|
||||
Remove-Item $archive, $extract -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Download-File -Url $url -DestPath $archive -Label "whisper.cpp speech backend ($Release)"
|
||||
|
||||
Write-Host " >> Extracting whisper.cpp speech backend..."
|
||||
Expand-Archive -Path $archive -DestinationPath $extract -Force
|
||||
|
||||
$cli = Find-FirstFile -Root $extract -Names @("whisper-cli.exe", "main.exe")
|
||||
$server = Find-FirstFile -Root $extract -Names @("whisper-server.exe", "server.exe")
|
||||
if (-not $cli) {
|
||||
throw "whisper-cli.exe was not found after extracting $asset"
|
||||
}
|
||||
|
||||
Get-ChildItem $extract -Recurse -File | ForEach-Object {
|
||||
Copy-Item $_.FullName (Join-Path $speechRoot $_.Name) -Force
|
||||
}
|
||||
if ((Split-Path -Leaf $cli) -ine "whisper-cli.exe") {
|
||||
Copy-Item $cli $cliPath -Force
|
||||
}
|
||||
if ($server -and (Split-Path -Leaf $server) -ine "whisper-server.exe") {
|
||||
Copy-Item $server (Join-Path $speechRoot "whisper-server.exe") -Force
|
||||
}
|
||||
|
||||
Remove-Item $archive, $extract -Recurse -Force -ErrorAction SilentlyContinue
|
||||
if (-not (Test-Path $cliPath)) {
|
||||
throw "whisper-cli.exe was not installed."
|
||||
}
|
||||
Write-Host " OK whisper.cpp speech backend installed."
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
APP_DIR="$ROOT_DIR/app"
|
||||
TOOLS_DIR="$APP_DIR/tools"
|
||||
RELEASE="${WHISPER_RELEASE:-v1.9.1}"
|
||||
PLATFORM="$(uname -s)"
|
||||
ARCH="$(uname -m)"
|
||||
|
||||
download_and_extract() {
|
||||
local asset="$1"
|
||||
local dest="$2"
|
||||
local archive="$TOOLS_DIR/$asset"
|
||||
local url="https://github.com/ggml-org/whisper.cpp/releases/download/$RELEASE/$asset"
|
||||
|
||||
if [[ -x "$dest/whisper-cli" ]]; then
|
||||
echo " OK whisper.cpp speech backend already ready: $dest"
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p "$TOOLS_DIR" "$dest"
|
||||
rm -f "$archive" "$archive.part"
|
||||
echo " >> Downloading $asset"
|
||||
curl -fSL --progress-bar "$url" -o "$archive.part"
|
||||
mv "$archive.part" "$archive"
|
||||
tar -xzf "$archive" -C "$dest" --strip-components=1
|
||||
rm -f "$archive"
|
||||
chmod +x "$dest"/whisper-* "$dest"/main "$dest"/server 2>/dev/null || true
|
||||
|
||||
if [[ ! -x "$dest/whisper-cli" && -x "$dest/main" ]]; then
|
||||
cp "$dest/main" "$dest/whisper-cli"
|
||||
chmod +x "$dest/whisper-cli"
|
||||
fi
|
||||
if [[ ! -x "$dest/whisper-server" && -x "$dest/server" ]]; then
|
||||
cp "$dest/server" "$dest/whisper-server"
|
||||
chmod +x "$dest/whisper-server"
|
||||
fi
|
||||
|
||||
if [[ ! -x "$dest/whisper-cli" ]]; then
|
||||
echo " XX whisper-cli was not found after extracting $asset" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "$PLATFORM" == "Linux" ]]; then
|
||||
if [[ "$ARCH" == "aarch64" || "$ARCH" == "arm64" ]]; then
|
||||
download_and_extract "whisper-bin-ubuntu-arm64.tar.gz" "$APP_DIR/speech-backend/linux"
|
||||
else
|
||||
download_and_extract "whisper-bin-ubuntu-x64.tar.gz" "$APP_DIR/speech-backend/linux"
|
||||
fi
|
||||
elif [[ "$PLATFORM" == "Darwin" ]]; then
|
||||
mkdir -p "$APP_DIR/speech-backend/mac"
|
||||
if [[ -x "$APP_DIR/speech-backend/mac/whisper-cli" ]]; then
|
||||
echo " OK whisper.cpp macOS speech backend already ready."
|
||||
else
|
||||
echo " !! No official portable macOS whisper.cpp CLI archive is available for this setup script yet."
|
||||
echo " Build whisper.cpp manually and copy whisper-cli to app/speech-backend/mac/whisper-cli."
|
||||
fi
|
||||
else
|
||||
echo "Unsupported platform: $PLATFORM" >&2
|
||||
exit 1
|
||||
fi
|
||||
+10
-3
@@ -158,7 +158,7 @@ function Expand-WithProgress {
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
Print-Header
|
||||
|
||||
$steps = 5
|
||||
$steps = 6
|
||||
|
||||
# ── Step 1: Portable Node.js ──────────────────────────────────────────────────
|
||||
Print-Step 1 $steps "Setting up portable Node.js (app/tools/node-win/)"
|
||||
@@ -427,7 +427,14 @@ if (-not $?) {
|
||||
Read-Host; exit 1
|
||||
}
|
||||
|
||||
Print-Step 4 $steps "Installing frontend dependencies (app/frontend/)"
|
||||
Print-Step 4 $steps "Setting up whisper.cpp speech backend"
|
||||
& (Join-Path $scriptDir "setup-whisper.ps1")
|
||||
if (-not $?) {
|
||||
Print-Fail "whisper.cpp setup failed."
|
||||
Read-Host; exit 1
|
||||
}
|
||||
|
||||
Print-Step 5 $steps "Installing frontend dependencies (app/frontend/)"
|
||||
Write-Host ""
|
||||
|
||||
if (-not (Test-Path $npmCmd)) {
|
||||
@@ -481,7 +488,7 @@ try {
|
||||
Print-OK "Dependencies installed!"
|
||||
|
||||
# ── Step 4: Build frontend ────────────────────────────────────────────────
|
||||
Print-Step 5 $steps "Building frontend -> app/dist/"
|
||||
Print-Step 6 $steps "Building frontend -> app/dist/"
|
||||
Write-Host ""
|
||||
|
||||
& $npmCmd run build 2>&1
|
||||
|
||||
+6
-3
@@ -258,7 +258,7 @@ if [[ "$PLATFORM" == "Linux" ]]; then
|
||||
check_glibc
|
||||
fi
|
||||
|
||||
TOTAL_STEPS=5
|
||||
TOTAL_STEPS=6
|
||||
|
||||
# ── Step 1: Portable Node.js ────────────────────────────────────────────────
|
||||
print_step 1 $TOTAL_STEPS "Setting up portable Node.js ($NODE_DIR/)"
|
||||
@@ -544,7 +544,10 @@ fi
|
||||
print_step 3 $TOTAL_STEPS "Setting up llama.cpp text backend"
|
||||
bash "$SCRIPT_DIR/setup-llama.sh"
|
||||
|
||||
print_step 4 $TOTAL_STEPS "Installing frontend dependencies (app/frontend/)"
|
||||
print_step 4 $TOTAL_STEPS "Setting up whisper.cpp speech backend"
|
||||
bash "$SCRIPT_DIR/setup-whisper.sh"
|
||||
|
||||
print_step 5 $TOTAL_STEPS "Installing frontend dependencies (app/frontend/)"
|
||||
|
||||
if [[ ! -x "$NPM_BIN" ]]; then
|
||||
print_fail "Portable npm was not found at $NPM_BIN"
|
||||
@@ -642,7 +645,7 @@ else
|
||||
fi
|
||||
|
||||
# ── Step 4: Build frontend ──────────────────────────────────────────────────
|
||||
print_step 5 $TOTAL_STEPS "Building frontend -> app/dist/"
|
||||
print_step 6 $TOTAL_STEPS "Building frontend -> app/dist/"
|
||||
|
||||
if [ "$USE_SYMLINKS" = false ]; then
|
||||
# If symlinks are disabled, run vite directly using the local node executable.
|
||||
|
||||
@@ -14,6 +14,7 @@ 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\whisper-cli.exe
|
||||
set SERVE=%~dp0scripts\serve.cjs
|
||||
if "%FRONTEND_PORT%"=="" set FRONTEND_PORT=1420
|
||||
if "%LLM_PORT%"=="" set LLM_PORT=10086
|
||||
@@ -38,6 +39,10 @@ if not exist "%LLM_CUDA_BACKEND%" if not exist "%LLM_HIP_BACKEND%" if not exist
|
||||
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 exist "%CUDA_BACKEND%" goto :launch
|
||||
if exist "%VULKAN_BACKEND%" goto :launch
|
||||
set SETUP_REASON=No backend binary is installed.
|
||||
@@ -101,6 +106,7 @@ 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.
|
||||
echo Press Ctrl+C in this window to stop all services.
|
||||
echo ============================================================
|
||||
|
||||
Reference in new issue
Block a user