Files
Coder 287fe2b820
All checks were successful
build-and-publish / build-test (push) Successful in 27s
build-and-publish / publish (push) Has been skipped
fix(telegram-remote): poll Whishper through status 1 (processing)
Whishper job lifecycle: 0=created, 1=processing (tens of seconds),
2/3=finished. The poll loop treated any status != -1 as terminal and
threw 'job finished without text (status 1)' mid-transcription. Keep
polling on -1/0/1; only finished statuses with empty text are errors.
2026-08-27 22:34:28 +07:00

90 lines
3.6 KiB
JavaScript

/**
* Zero-dependency client for the home-lab Whishper speech-to-text server
* (g-daco/Whishper). Multipart upload + job polling over global fetch/
* FormData/Blob (Node >= 22). Server contract (verified against
* http://192.168.31.159:8082):
* POST {base}/api/transcriptions multipart fields: file, language,
* modelSize, device (cuda|cpu), sourceUrl
* GET {base}/api/transcriptions/{id}
* Job status 0 = created/queued, 1 = processing, 2/3 = finished; a finished
* job has a non-empty result.text on success.
*/
const POLL_INTERVAL_MS = 1500;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Transcribe an audio buffer via the Whishper server.
* @param {object} opts - { baseUrl, model, device, language, timeoutMs, maxBytes }
* @param {Buffer} audioBuffer - audio bytes to transcribe
* @param {string} filename - upload filename (e.g. "voice.ogg")
* @param {string} [mimeType] - content type for the upload blob
* @returns {Promise<string>} the transcribed text
* @throws {Error} clear message on empty baseUrl, non-Buffer input, size
* overrun, upload HTTP rejection, terminal-without-text, or timeout
*/
export async function transcribeAudio({ baseUrl, model, device, language, timeoutMs, maxBytes }, audioBuffer, filename, mimeType) {
const base = String(baseUrl ?? "").replace(/\/+$/, "");
if (!base) throw new Error("whisper: baseUrl is empty");
if (!Buffer.isBuffer(audioBuffer)) throw new Error("whisper: audioBuffer must be a Buffer");
const max = Number(maxBytes) || 0;
if (max > 0 && audioBuffer.length > max) {
throw new Error("whisper: audio is " + audioBuffer.length + " bytes, limit is " + max);
}
const name = String(filename ?? "audio.bin");
const form = new FormData();
form.append("file", new Blob([audioBuffer], mimeType ? { type: mimeType } : {}), name);
form.append("language", String(language ?? "").trim() || "auto");
form.append("modelSize", String(model ?? "large-v2"));
form.append("device", String(device ?? "cuda"));
form.append("sourceUrl", "");
let res;
try {
res = await fetch(base + "/api/transcriptions", { method: "POST", body: form });
} catch (error) {
throw new Error("whisper: upload failed: " + error.message);
}
if (!res.ok) {
const body = (await res.text()).slice(0, 300);
throw new Error("whisper: upload rejected (HTTP " + res.status + "): " + body);
}
let created;
try {
created = await res.json();
} catch {
throw new Error("whisper: upload response was not JSON");
}
const id = created?.id;
if (!id) throw new Error("whisper: no job id in upload response");
const timeout = Math.max(Number(timeoutMs) || 120000, 1000);
const deadline = Date.now() + timeout;
for (;;) {
if (Date.now() >= deadline) {
throw new Error("whisper: transcription timed out after " + timeout + "ms");
}
await sleep(POLL_INTERVAL_MS);
let job;
try {
const pollRes = await fetch(base + "/api/transcriptions/" + id);
if (!pollRes.ok) throw new Error("HTTP " + pollRes.status);
job = await pollRes.json();
} catch (error) {
throw new Error("whisper: poll failed: " + error.message);
}
const text = job?.result?.text;
if (typeof text === "string" && text.trim().length > 0) return text;
// Whishper lifecycle (verified on this server): 0 = created/queued,
// 1 = processing (can last tens of seconds), 2/3 = finished.
const status = job?.status;
const running = status === -1 || status === 0 || status === 1;
if (!running) {
throw new Error("whisper: job finished without text (status " + String(status) + ")");
}
}
}