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>
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user