Refactor app to speech-only transcription tool

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

No files matched your search

+134 -3
View File
@@ -2425,7 +2425,7 @@ async function stopSpeech() {
speechTranscriptionState = { active: false, phase: "", progress: 0, model: "", filename: "" };
}
function readBinaryBody(req, limitBytes = 250 * 1024 * 1024) {
function readBinaryBody(req, limitBytes = 1024 * 1024 * 1024) {
return new Promise((resolve, reject) => {
const chunks = [];
let bytes = 0;
@@ -2449,6 +2449,24 @@ function isWaveBuffer(buffer) {
buffer.toString("ascii", 8, 12) === "WAVE";
}
function isAudioFile(buffer) {
try {
if (!buffer || buffer.length < 12) return false;
if (isWaveBuffer(buffer)) return true;
const magic = buffer.toString("ascii", 0, 3);
if (magic === "ID3") return true; // MP3 with ID3 tag
const ftyp = buffer.toString("ascii", 4, 8);
if (ftyp === "ftyp") return true; // MP4/M4A container
const ogg = buffer.toString("ascii", 0, 4);
if (ogg === "OggS") return true; // OGG/OPUS
const flac = buffer.toString("ascii", 0, 4);
if (flac === "fLaC") return true; // FLAC
return false;
} catch (_) {
return false;
}
}
function saveTranscript(result) {
const createdAt = new Date().toISOString();
const stamp = createdAt.replace(/[:.]/g, "-");
@@ -2600,6 +2618,62 @@ function transcribeWavBuffer(buffer, options = {}) {
});
}
function transcribeAudioBuffer(buffer, options = {}) {
if (isWaveBuffer(buffer)) {
return transcribeWavBuffer(buffer, options);
}
return new Promise((resolve, reject) => {
const backendPreference = normalizeSpeechBackendPreference(options.backendPreference || speechSettings.backendPreference);
const backend = getSpeechBackend(backendPreference);
if (!backend.cli) {
reject(new Error(`${backend.label || "Selected"} whisper.cpp CLI backend is not installed.`));
return;
}
const model = resolveSpeechModel(options.model || speechSettings.model);
const language = String(options.language || speechSettings.language || "auto");
const sourceFilename = safeOutputName(options.filename || "recording") || "recording";
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const tempBase = path.join(TRANSCRIPTIONS, `.speech-${stamp}-${sourceFilename.replace(/\.[^.]+$/, "")}`);
const inputPath = `${tempBase}-input`;
const wavPath = `${tempBase}.wav`;
const outBase = `${tempBase}-out`;
fs.writeFileSync(inputPath, buffer);
speechTranscriptionState = {
active: true,
phase: "Converting audio...",
progress: -1,
model: model.filename,
filename: sourceFilename,
};
const ffmpegArgs = ["-y", "-i", inputPath, "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", wavPath];
console.log(" [speech] Converting with ffmpeg:", ffmpegArgs.join(" "));
const ffmpegProc = spawn("ffmpeg", ffmpegArgs, {
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
let ffmpegStderr = "";
ffmpegProc.stderr.on("data", (data) => { ffmpegStderr += data.toString(); });
ffmpegProc.on("exit", (code) => {
try { fs.unlinkSync(inputPath); } catch (_) {}
if (code !== 0) {
const message = (ffmpegStderr || `ffmpeg exited with code ${code}`).trim().slice(-1200);
speechError = message;
try { fs.unlinkSync(wavPath); } catch (_) {}
reject(new Error("Audio conversion failed: " + message));
return;
}
const wavBuffer = fs.readFileSync(wavPath);
try { fs.unlinkSync(wavPath); } catch (_) {}
transcribeWavBuffer(wavBuffer, options).then(resolve).catch((err) => {
try { fs.unlinkSync(wavPath); } catch (_) {}
reject(err);
});
});
});
}
function pingLlmReady(expectedModel = "") {
return new Promise((resolve) => {
const req = http.get(`http://127.0.0.1:${PORT_LLM}/v1/models`, (res) => {
@@ -5956,10 +6030,10 @@ const server = http.createServer(async (req, res) => {
const parsed = new URL(req.url, `http://${req.headers.host || "localhost"}`);
try {
const audio = await readBinaryBody(req);
const result = await runExclusiveSpeechOperation(() => transcribeWavBuffer(audio, {
const result = await runExclusiveSpeechOperation(() => transcribeAudioBuffer(audio, {
model: parsed.searchParams.get("model") || speechSettings.model,
language: parsed.searchParams.get("language") || "auto",
filename: parsed.searchParams.get("filename") || req.headers["x-filename"] || "recording.wav",
filename: parsed.searchParams.get("filename") || req.headers["x-filename"] || "recording",
threads: parsed.searchParams.get("threads") || speechSettings.threads,
backendPreference: parsed.searchParams.get("backendPreference") || speechSettings.backendPreference,
translate: parsed.searchParams.get("translate") === "true",
@@ -5971,6 +6045,63 @@ const server = http.createServer(async (req, res) => {
}
}
// API endpoint that returns transcription as downloadable file (txt, json, md)
if (req.url.startsWith("/api/speech/transcribe-file") && req.method === "POST") {
const parsed = new URL(req.url, `http://${req.headers.host || "localhost"}`);
try {
const audio = await readBinaryBody(req);
const outputFormat = parsed.searchParams.get("format") || "txt";
const result = await runExclusiveSpeechOperation(() => transcribeAudioBuffer(audio, {
model: parsed.searchParams.get("model") || speechSettings.model,
language: parsed.searchParams.get("language") || "auto",
filename: parsed.searchParams.get("filename") || req.headers["x-filename"] || "recording",
threads: parsed.searchParams.get("threads") || speechSettings.threads,
backendPreference: parsed.searchParams.get("backendPreference") || speechSettings.backendPreference,
translate: parsed.searchParams.get("translate") === "true",
}));
const filename = parsed.searchParams.get("output_filename") || `transcription-${Date.now()}`;
let fileContent = "";
let contentType = "text/plain;charset=utf-8";
let extension = "txt";
if (outputFormat === "json") {
fileContent = JSON.stringify({
text: result.text,
model: result.model,
modelName: result.modelName,
language: result.language,
backend: result.backend,
backendMode: result.backendMode,
sourceFilename: result.sourceFilename,
durationMs: result.durationMs,
raw: result.raw,
createdAt: result.createdAt
}, null, 2);
contentType = "application/json;charset=utf-8";
extension = "json";
} else if (outputFormat === "md") {
fileContent = `# Trascrizione\n\n${result.text}\n\n---\n*Modello: ${result.modelName || result.model || "unknown"}\n*Lingua: ${result.language || "auto"}\n*Backend: ${result.backend || "unknown"} (${result.backendMode || ""})\n*File sorgente: ${result.sourceFilename || "unknown"}\n*Durata elaborazione: ${result.durationMs ? (result.durationMs / 1000).toFixed(1) : "?"}s\n*Generato il: ${new Date().toISOString()}`;
contentType = "text/markdown;charset=utf-8";
extension = "md";
} else {
fileContent = result.text;
contentType = "text/plain;charset=utf-8";
extension = "txt";
}
res.writeHead(200, {
"Content-Type": contentType,
"Content-Disposition": `attachment; filename="${filename}.${extension}"`,
"Content-Length": Buffer.byteLength(fileContent, "utf8")
});
res.end(fileContent);
} catch (err) {
speechError = err.message || String(err);
return json(res, err.message?.includes("too large") ? 413 : 500, { ok: false, error: speechError });
}
}
if (req.url === "/api/speech/download-model" && req.method === "POST") {
const body = await readJsonBody(req, res);
if (!body) return;