Files
transcribe/sidecar/worker.ts
T
pastilhasandClaude Fable 5 dd3dbb611b conversations come back as conversations
A probe on the torch stage worker (~/dockers/torch-worker) runs beside every
transcription and decides what the recording is: solo dictation returns as
paragraphs exactly as before, several voices return speaker-labeled with
conversation: true. Nobody declares which — the caller cannot know.

Transcription and the worker each get a serial FIFO queue on their own
resource, so the probe costs a solo memo nothing and multi-user traffic
stays fair. The worker is optional and best-effort throughout: unreachable
or failing, everything still transcribes, just never labeled.

Speaker attribution is segment-granularity by maximal turn overlap —
whisper.cpp's own timestamps, no alignment stage. If boundaries prove
sloppy, wav2vec2 alignment returns as a worker endpoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 04:27:14 +00:00

69 lines
2.7 KiB
TypeScript

// Client for the torch stage worker (~/dockers/torch-worker) — speaker diarization and the
// "is this a conversation?" probe. The worker is deliberately dumb: it answers acoustic
// questions about audio it is handed and holds no policy. Deciding what to do with a verdict
// happens in index.ts.
//
// Every call passes `timeout: false`: a full diarization runs at roughly half realtime on CPU,
// so an hour-long recording legitimately holds this connection for tens of minutes. The 255s
// lessons of 2026-08-24 are not getting relearned here.
type BunRequestInit = RequestInit & { timeout?: boolean | number };
export type SpeakerTurn = { start: number; end: number; speaker: string };
export type ProbeVerdict = 'solo' | 'conversation' | 'unsure';
export type ProbeResult = {
duration: number;
analyzed: { start: number; end: number; whole_file: boolean };
speakers_found: number;
overlap_ratio: number;
verdict: ProbeVerdict;
/** Complete diarization when the probe covered the whole file — reuse it, skip /diarize. */
turns: SpeakerTurn[] | null;
};
export type DiarizeResult = {
duration: number;
speakers: string[];
turns: SpeakerTurn[];
};
async function post<T>(url: string, form: FormData): Promise<T> {
const res = await fetch(url, { method: 'POST', body: form, timeout: false } as BunRequestInit);
if (!res.ok) {
const detail = await res.text().catch(() => '');
throw new Error(`worker ${res.status}: ${detail.slice(0, 300)}`);
}
return (await res.json()) as T;
}
export async function probeAudio(file: File | Blob, workerUrl: string): Promise<ProbeResult> {
const form = new FormData();
form.append('file', file);
return post<ProbeResult>(`${workerUrl.replace(/\/+$/, '')}/probe`, form);
}
export async function diarizeAudio(file: File | Blob, workerUrl: string): Promise<DiarizeResult> {
const form = new FormData();
form.append('file', file);
return post<DiarizeResult>(`${workerUrl.replace(/\/+$/, '')}/diarize`, form);
}
export type WorkerProbe = { ok: true } | { ok: false; error: string };
/** Reachability check for PUT /config validation and /_health, mirroring probeWhisper. */
export async function probeWorker(workerUrl: string): Promise<WorkerProbe> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(`${workerUrl.replace(/\/+$/, '')}/health`, { signal: controller.signal });
if (!res.ok) return { ok: false, error: `worker answered ${res.status}` };
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : 'could not reach the worker' };
} finally {
clearTimeout(timer);
}
}