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>
69 lines
2.7 KiB
TypeScript
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);
|
|
}
|
|
}
|