this holds a connection open while whisper works, and nothing flows on it meanwhile — exactly what an idle timeout kills. unset, Bun drops the platform's connection at around 255s. it was the last of three hops still doing it. the proxy's outbound fetch and the platform's inbound timeout were both fixed earlier today and a 42-minute recording still died at 4m16s. whisperx went on and finished it; the result was discarded. with diarization on, align runs at roughly 1.5x the audio duration, so an hour-long episode needs ninety minutes. no ceiling is both safe and high enough, and 255 is Bun's maximum anyway — "raise it" was never available. 0 disables it.
185 lines
7.7 KiB
TypeScript
185 lines
7.7 KiB
TypeScript
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
|
|
import { createSidecarConnector } from '@@/sidecar/connect';
|
|
import { API_URL } from '@@/officer-url.mjs';
|
|
import { getConfig, setConfig, redactUrl, probeWhisper } from './config';
|
|
import { transcribeAudio } from './transcribe';
|
|
|
|
// 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
|
|
// here unchanged from the platform, and every constant in it has a reason attached in its own comments).
|
|
//
|
|
// The platform API is a thin auth-gated forwarder (../api/router.ts) holding no Whisper knowledge at all.
|
|
//
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
// 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 }
|
|
//
|
|
// anything else 404
|
|
//
|
|
// Every route needs `X-Officer-User`, which the platform proxy sets after authenticating the caller. We
|
|
// bind loopback only, so its presence is the trust signal — a request without it did not come through
|
|
// the platform.
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
|
function getFreePort(): number {
|
|
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
|
const p = probeServer.port;
|
|
probeServer.stop(true);
|
|
if (p == null) throw new Error('failed to acquire a free port');
|
|
return p;
|
|
}
|
|
|
|
const port = getFreePort();
|
|
|
|
async function handleConfigGet(): Promise<Response> {
|
|
const cfg = await getConfig();
|
|
return Response.json({ configured: !!cfg, url: cfg ? redactUrl(cfg.url) : null });
|
|
}
|
|
|
|
function isHttpUrl(url: string): boolean {
|
|
return /^https?:\/\//i.test(url);
|
|
}
|
|
|
|
async function handleConfigPut(req: Request): Promise<Response> {
|
|
const body = ((await req.json().catch(() => null)) as { url?: 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 });
|
|
|
|
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) });
|
|
}
|
|
|
|
async function handleTranscribe(req: Request): Promise<Response> {
|
|
const cfg = await getConfig();
|
|
if (!cfg) {
|
|
return Response.json({ error: 'Whisper not configured — PUT /api/transcribe/config first' }, { status: 400 });
|
|
}
|
|
|
|
const body = await req.formData().catch(() => null);
|
|
const file = body?.get('file');
|
|
if (!file || !(file instanceof File)) return Response.json({ error: 'file is required' }, { status: 400 });
|
|
|
|
const spokenLanguages = (body?.getAll('spokenLanguages') ?? []).map(String);
|
|
|
|
try {
|
|
const result = await transcribeAudio({ file, whisperUrl: cfg.url, spokenLanguages });
|
|
return Response.json(result);
|
|
} catch (err) {
|
|
console.error('[transcribe] transcription failed', err);
|
|
return Response.json(
|
|
{ error: err instanceof Error ? err.message : 'Transcription failed' },
|
|
{ status: 502 },
|
|
);
|
|
}
|
|
}
|
|
|
|
const server = Bun.serve({
|
|
port,
|
|
hostname: '127.0.0.1',
|
|
// A phone's voice memo is small; this just needs to be well above any single recording.
|
|
maxRequestBodySize: 1024 * 1024 * 1024,
|
|
/*
|
|
No idle timeout. This server holds a connection open while whisper works, and nothing flows on it in
|
|
the meantime — which is exactly what an idle timeout kills.
|
|
|
|
Unset, Bun applies its own ceiling and drops the platform's connection at around 255s. That was the
|
|
last of three hops still doing it: the proxy's outbound fetch and the platform's inbound timeout were
|
|
both fixed on 2026-08-24 and a 42-minute recording STILL died at 4m16s, because this one had not been.
|
|
whisperx went on and finished it, and the result was thrown away.
|
|
|
|
With diarization on, align runs at roughly 1.5x the audio duration, so an hour-long episode needs
|
|
ninety minutes of processing. There is no ceiling that is both safe and high enough — 255s is Bun's
|
|
maximum anyway, so "raise it" was never available. 0 disables it.
|
|
*/
|
|
idleTimeout: 0,
|
|
async fetch(req) {
|
|
const url = new URL(req.url);
|
|
|
|
const officerUser = req.headers.get('X-Officer-User');
|
|
const userId = Number(officerUser);
|
|
if (!officerUser || !Number.isInteger(userId) || userId <= 0) {
|
|
return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 });
|
|
}
|
|
|
|
if (url.pathname === '/_health') {
|
|
const cfg = await getConfig();
|
|
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) });
|
|
}
|
|
|
|
if (url.pathname === '/config') {
|
|
try {
|
|
if (req.method === 'GET') return await handleConfigGet();
|
|
if (req.method === 'PUT' || req.method === 'POST') return await handleConfigPut(req);
|
|
return Response.json({ error: 'method not allowed' }, { status: 405 });
|
|
} catch (err) {
|
|
console.error(`[transcribe] ${req.method} /config failed`, err);
|
|
return Response.json({ error: 'internal error' }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
if (url.pathname === '/transcribe') {
|
|
if (req.method !== 'POST') return Response.json({ error: 'method not allowed' }, { status: 405 });
|
|
return handleTranscribe(req);
|
|
}
|
|
|
|
return Response.json({ error: 'not found' }, { status: 404 });
|
|
},
|
|
});
|
|
|
|
console.log(`[transcribe] listening on 127.0.0.1:${port} (whisper.cpp URL configured from PUT /config)`);
|
|
|
|
type ReplyFn = (msg: SidecarEvent) => void;
|
|
|
|
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
|
switch (cmd.type) {
|
|
case 'ping':
|
|
reply({ type: 'pong', id: cmd.id });
|
|
break;
|
|
default:
|
|
reply({
|
|
type: 'error',
|
|
id: (cmd as SidecarCommand).id,
|
|
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
const connection = createSidecarConnector({
|
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
|
name: 'transcribe',
|
|
handles: ['transcribe'],
|
|
onCommand(cmd, reply) {
|
|
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
|
},
|
|
onConnected() {
|
|
connection.send({ type: 'transcribe:server', port });
|
|
console.log(`[transcribe] reported server port ${port} to API`);
|
|
},
|
|
});
|
|
|
|
function shutdown(signal: string) {
|
|
console.log(`[transcribe] ${signal} received, shutting down...`);
|
|
try {
|
|
server.stop(true);
|
|
} catch {
|
|
/* already stopped */
|
|
}
|
|
connection.destroy();
|
|
process.exit(0);
|
|
}
|
|
|
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
process.on('SIGINT', () => shutdown('SIGINT'));
|