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
+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.
|
||||
|
||||
Reference in new issue
Block a user