/** * 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} 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) + ")"); } } }