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:
1 parent
313b994c9b
commit
76946f8594
14 files changed
+671
-10753
No files matched your search
+134
-3
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user