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:
2026-08-25 04:27:14 +00:00
co-authored by Claude Fable 5
parent bd08f9a5fe
commit dd3dbb611b
10 changed files with 416 additions and 20 deletions
+90 -11
View File
@@ -3,6 +3,9 @@ import { createSidecarConnector } from '@@/sidecar/connect';
import { API_URL } from '@@/officer-url.mjs';
import { getConfig, setConfig, redactUrl, probeWhisper } from './config';
import { transcribeAudio } from './transcribe';
import { toConversation } from './conversation';
import { whisperQueue, workerQueue } from './queue';
import { diarizeAudio, probeAudio, probeWorker, type SpeakerTurn } from './worker';
// The officer-transcribe sidecar. Owns the whole whisper.cpp contract for Officer: the server URL and
// the request tuning that makes it usable for a person talking into a phone (see transcribe.ts — moved
@@ -13,10 +16,15 @@ import { transcribeAudio } from './transcribe';
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// HTTP CONTRACT — the platform strips its /api/transcribe mount prefix before forwarding.
//
// GET /_health { ok, configured, url? }
// GET /config { configured, url } — url has its userinfo stripped, never a secret
// PUT /config { url } — validated against the server before storing
// POST /transcribe multipart: file, spokenLanguages[] → { text, detectedLanguage }
// GET /_health { ok, configured, url?, worker, queues }
// GET /config { configured, url, workerUrl } — urls have userinfo stripped, never a secret
// PUT /config { url, workerUrl? } — both validated against their servers first
// POST /transcribe multipart: file, spokenLanguages[]
// → { text, detectedLanguage, conversation: false }
// → { text: "Speaker 1: …", detectedLanguage, conversation: true, speakers }
// The conversation probe and diarization run on the torch worker (workerUrl,
// ~/dockers/torch-worker) behind their own queue; any worker failure degrades
// to the plain transcript rather than failing the request.
//
// anything else 404
//
@@ -38,7 +46,11 @@ const port = getFreePort();
async function handleConfigGet(): Promise<Response> {
const cfg = await getConfig();
return Response.json({ configured: !!cfg, url: cfg ? redactUrl(cfg.url) : null });
return Response.json({
configured: !!cfg,
url: cfg ? redactUrl(cfg.url) : null,
workerUrl: cfg?.workerUrl ? redactUrl(cfg.workerUrl) : null,
});
}
function isHttpUrl(url: string): boolean {
@@ -46,7 +58,7 @@ function isHttpUrl(url: string): boolean {
}
async function handleConfigPut(req: Request): Promise<Response> {
const body = ((await req.json().catch(() => null)) as { url?: unknown } | null) ?? {};
const body = ((await req.json().catch(() => null)) as { url?: unknown; workerUrl?: unknown } | null) ?? {};
const url = typeof body.url === 'string' ? body.url.trim() : '';
if (!url) return Response.json({ error: 'url is required' }, { status: 400 });
if (!isHttpUrl(url)) return Response.json({ error: 'url must start with http:// or https://' }, { status: 400 });
@@ -54,10 +66,31 @@ async function handleConfigPut(req: Request): Promise<Response> {
const probe = await probeWhisper(url);
if (!probe.ok) return Response.json({ error: probe.error }, { status: 400 });
await setConfig({ url });
return Response.json({ configured: true, url: redactUrl(url) });
// The worker is optional, but a URL that is GIVEN must answer — storing a dead URL silently
// would turn every future conversation back into an unlabeled wall of text with no error.
const workerUrl = typeof body.workerUrl === 'string' ? body.workerUrl.trim() : '';
if (workerUrl) {
if (!isHttpUrl(workerUrl)) {
return Response.json({ error: 'workerUrl must start with http:// or https://' }, { status: 400 });
}
const workerProbe = await probeWorker(workerUrl);
if (!workerProbe.ok) return Response.json({ error: `diarization worker: ${workerProbe.error}` }, { status: 400 });
}
await setConfig({ url, ...(workerUrl ? { workerUrl } : {}) });
return Response.json({
configured: true,
url: redactUrl(url),
workerUrl: workerUrl ? redactUrl(workerUrl) : null,
});
}
// The pipeline. Transcription and the conversation probe run CONCURRENTLY on their own
// resources (whisper.cpp on its GPU, the torch worker on CPU cores), each behind its own serial
// queue, so the probe costs a solo memo nothing — it finishes long before the transcript does.
//
// The probe and diarization are strictly best-effort: any worker failure logs and degrades to
// the plain transcript. A memo must never be lost to a speaker-labeling problem.
async function handleTranscribe(req: Request): Promise<Response> {
const cfg = await getConfig();
if (!cfg) {
@@ -69,10 +102,47 @@ async function handleTranscribe(req: Request): Promise<Response> {
if (!file || !(file instanceof File)) return Response.json({ error: 'file is required' }, { status: 400 });
const spokenLanguages = (body?.getAll('spokenLanguages') ?? []).map(String);
const workerUrl = cfg.workerUrl;
const probePromise = workerUrl
? workerQueue.run(() => probeAudio(file, workerUrl)).catch((err) => {
console.error('[transcribe] conversation probe failed — returning plain transcript', err);
return null;
})
: Promise.resolve(null);
try {
const result = await transcribeAudio({ file, whisperUrl: cfg.url, spokenLanguages });
return Response.json(result);
const result = await whisperQueue.run(() => transcribeAudio({ file, whisperUrl: cfg.url, spokenLanguages }));
const probe = await probePromise;
if (probe && probe.verdict !== 'solo' && workerUrl) {
// "unsure" diarizes too: a mislabeled monologue reads fine, a missed conversation is
// information lost for good. Reuse the probe's turns when it already covered the file.
const turns: SpeakerTurn[] | null = probe.turns
?? (await workerQueue.run(() => diarizeAudio(file, workerUrl)).then(
(d) => d.turns,
(err) => {
console.error('[transcribe] diarization failed — returning plain transcript', err);
return null;
},
));
if (turns && turns.length > 0) {
const conversation = toConversation(result.segments, turns);
// A "conversation" that clusters into one voice end-to-end is a monologue after all —
// the paragraph rendering reads better than a wall of "Speaker 1:" prefixes.
if (conversation.speakerCount > 1) {
return Response.json({
text: conversation.text,
detectedLanguage: result.detectedLanguage,
conversation: true,
speakers: conversation.speakerCount,
});
}
}
}
return Response.json({ text: result.text, detectedLanguage: result.detectedLanguage, conversation: false });
} catch (err) {
console.error('[transcribe] transcription failed', err);
return Response.json(
@@ -115,7 +185,16 @@ const server = Bun.serve({
if (!cfg) return Response.json({ ok: false, configured: false }, { status: 503 });
const probe = await probeWhisper(cfg.url);
if (!probe.ok) return Response.json({ ok: false, configured: true, error: probe.error }, { status: 502 });
return Response.json({ ok: true, configured: true, url: redactUrl(cfg.url) });
// The worker degrades rather than fails: an unreachable worker is reported but does not
// cost the health check — transcription still works without it.
const worker = cfg.workerUrl ? await probeWorker(cfg.workerUrl) : null;
return Response.json({
ok: true,
configured: true,
url: redactUrl(cfg.url),
worker: worker === null ? 'not configured' : worker.ok ? 'ok' : `unreachable: ${worker.error}`,
queues: { whisper: whisperQueue.pending, worker: workerQueue.pending },
});
}
if (url.pathname === '/config') {