extract speech-to-text into its own plugin
Moves platform STT out into a plugin that owns the whole whisper.cpp contract: sidecar, config (a JSON file under DATA_PATH/transcribe/, validated against the server before storing), a thin proxy router, a skill so an agent knows the contract without being told, and a script-mode file-browser task (not agentic — no model turn per audio file). sidecar/transcribe.ts and its test moved unchanged from src/servers/api/stt/ — every constant in it has a reason attached and none of it was touched. The platform-side removal (routes, dead useAudioRecording path, Settings screen, locale keys) lands in a separate, uncommitted change in the platform repo for review. Not yet reachable from the file browser: the platform does not load tasks from a plugin's directory yet (skills/affordances/notifications do). Ships anyway per instructions. Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
# Transcribe — the extraction record
|
||||
|
||||
Extracted from the platform on **2026-08-24**. `plugins/EXTRACTING-A-PLUGIN.md` is the runbook; this
|
||||
file is what it cost.
|
||||
|
||||
## What moved
|
||||
|
||||
| Was | Is now |
|
||||
| --------------------------------------------- | ---------------------------------------- |
|
||||
| `src/servers/api/stt/transcribe.ts` | `sidecar/transcribe.ts` — unchanged |
|
||||
| `src/servers/api/stt/transcribe.test.ts` | `sidecar/transcribe.test.ts` — unchanged |
|
||||
| `src/servers/api/server-settings/stt.ts` | `sidecar/config.ts` — rewritten |
|
||||
|
||||
`sidecar/transcribe.ts` is the reason this extraction exists at all: 416 lines of tuning — the two-pass
|
||||
detect-then-transcribe, 25 language priming prompts, the VAD widening, the paragraphing — each with a
|
||||
reason in its own comment, and none of it touched. Moving it, not rewriting it, was the instruction and
|
||||
the only defensible one: every constant in that file was chosen against a real recording at some point,
|
||||
and there is no way to re-derive that from reading the code cold.
|
||||
|
||||
## What did not move unchanged: config
|
||||
|
||||
The platform kept a single admin-only setting, `server-settings/stt` — one row in a JSON blob
|
||||
(`readServerSettings`/`writeServerSettings`), gated by the `server-admin` permission and reachable only
|
||||
by the owner. That shape does not exist here to move into: a plugin's config is its own file, not a slot
|
||||
in the platform's settings blob, and this plugin's own permission (`transcribe`) is grantable to anyone,
|
||||
not owner-only.
|
||||
|
||||
So `sidecar/config.ts` is new, not moved: a JSON file under `DATA_PATH/transcribe/`, following the
|
||||
`DATA_PATH/<plugin>/` convention `plugins/music/sidecar/indexer.ts` established, with the same
|
||||
validate-before-store behaviour the old `/server-settings/stt/test` route had (an empty multipart POST to
|
||||
`/inference` — Whisper always rejects it, but any response at all means the server exists).
|
||||
|
||||
**Deliberately not owner-only.** Photos' `/_config` holds a real credential (an Immich API key) and is
|
||||
still reachable by any account holding the permission at write; jellyfin's manifest, despite the
|
||||
`ownerOnly` field existing for exactly this shape, does not set it either. A Whisper URL is not a
|
||||
credential — there is nothing to steal by reading it, and nothing more dangerous than "the transcriptions
|
||||
go to a different server" by changing it — so this follows the same precedent rather than inventing a
|
||||
narrower one.
|
||||
|
||||
## What the platform lost
|
||||
|
||||
Deleted, not commented — see the platform-side diff for the full list: `src/servers/api/stt/`, the
|
||||
`server-settings/stt` router and its mount, `POST /chat/stt`, the dead `useAudioRecording` STT path
|
||||
(`blobToWav` stays; `DictateDialog.tsx` still needs it for something that was never STT — see below),
|
||||
`POST /file-browser/transcribe` and the FileViewer handler behind the button that was already hidden, the
|
||||
Settings → Speech to Text screen, and the orphaned locale keys across all six `officer-web` locales.
|
||||
|
||||
**One thing found and left alone.** `DictateDialog.tsx` in the file browser imports `blobToWav` from the
|
||||
same file `useAudioRecording`'s dead STT path lived in. It records audio and uploads the raw `.wav` — it
|
||||
never called Whisper, was never part of this feature, and stays in the platform exactly as it is. Deleting
|
||||
the whole file would have taken it down as collateral.
|
||||
|
||||
## The task: script, not agentic
|
||||
|
||||
The worked reference this shipped against (`~/dockers/officer.dev/seed-archive/tasks/transcribe-audio-file/`
|
||||
on alpha) is agentic: it runs its own detect-language pass before transcribing, and generates an
|
||||
archive-folder title from the transcription's contents. Both are wrong for a script task and were dropped
|
||||
on purpose rather than carried over — the detect step duplicates what the plugin's route already does
|
||||
server-side, and a generated title needs a model turn, which is the entire thing "script, not agentic" was
|
||||
asked for to avoid spending per file. The filing behaviour (a dated, slugged sibling directory, original
|
||||
moved in, transcript saved beside it) is the part worth keeping, so it survived; the slug now comes from
|
||||
the original filename instead of a generated title.
|
||||
|
||||
## What is NOT wired up yet
|
||||
|
||||
**The platform does not load tasks from a plugin's directory.** Skills, affordances and notifications are
|
||||
read from `plugins/<name>/` at runtime (`docs/plugin-capabilities.md`); tasks are not — `task-files.ts`
|
||||
only reads `$OFFICER_ITEMS_DIR/tasks/`. So `tasks/transcribe-audio-file/` ships here, is correct, and will
|
||||
not appear in the file browser's right-click menu until that loader lands. Not a defect in this plugin;
|
||||
recorded so nobody spends an evening looking for why it is missing.
|
||||
|
||||
## Still open
|
||||
|
||||
- **No `assets/icon.png`.** Falls back to the `AudioLines` lucide glyph.
|
||||
- **Single Whisper server, not a registry.** Unlike `photos_config` or `jellyfin_servers`, there is
|
||||
exactly one URL and no way to keep a second on hand. Revisit if that ever turns out to be wanted —
|
||||
nothing here forecloses it, but nothing asked for it either.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Transcribe
|
||||
|
||||
Officer's speech-to-text, talking to a self-hosted whisper.cpp server.
|
||||
|
||||
Transcribe does not run Whisper and ships no model. You point it at a whisper.cpp `/inference` server
|
||||
you already run and it forwards to it, tuned for one person talking into a phone: detect the spoken
|
||||
language, then transcribe faithfully in it — never translated, never guessed at in English when the
|
||||
audio is not.
|
||||
|
||||
## What you get
|
||||
|
||||
- **`POST /api/transcribe/transcribe`** — send an audio file, get back its text and the language Whisper
|
||||
detected. Two-pass under the hood (detect, then transcribe in that language) so a low-confidence guess
|
||||
never quietly defaults to English.
|
||||
- **A file-browser task.** Right-click an `.mp3`, `.wav` or `.m4a` and transcribe it without a model
|
||||
turn — the task is a plain script that calls this plugin's own route and files the result next to the
|
||||
original.
|
||||
- **A skill**, so an agent asked to transcribe something knows the route, the shape, and what to say when
|
||||
nothing is configured, without being told.
|
||||
|
||||
## What you need
|
||||
|
||||
**A whisper.cpp server you already run**, reachable from this machine, answering `POST /inference` —
|
||||
the same container the platform used to talk to directly. Nothing else. No system packages, no host
|
||||
binaries; the sidecar reaches it with `fetch`.
|
||||
|
||||
## Setup
|
||||
|
||||
```
|
||||
PUT /api/transcribe/config
|
||||
{ "url": "http://192.168.1.50:64201" }
|
||||
```
|
||||
|
||||
Validated against the server before it is stored — a wrong URL is a 400 with the reason, not a saved
|
||||
value that fails on the first real transcription.
|
||||
|
||||
## What this plugin deliberately does not have
|
||||
|
||||
- **No dashboard route, no dock tile.** There is nothing to look at — this is reached through the file
|
||||
browser's task menu and by an agent that has read the skill, not through a screen. Shipping no `web/`
|
||||
is a supported shape, not an oversight.
|
||||
- **No streaming.** One file in, one answer back. If you want a live microphone, that is a different
|
||||
feature built on top of this one, not something this plugin does today.
|
||||
- **No translation.** It transcribes in whatever language was spoken. Whisper's own translate mode is
|
||||
English-only, so honouring "translate into <language>" for anything else would mean silently
|
||||
handing back a rough English rendering of, say, Portuguese audio — worse than refusing.
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createSidecarProxy } from '@@/sidecar/create-proxy';
|
||||
import { mountPrefix } from '@@/plugins/manifest';
|
||||
import { manifest } from '../manifest';
|
||||
|
||||
// /api/transcribe/* — auth, then forward to officer-transcribe. No routes of its own and no Whisper
|
||||
// knowledge: the whole contract lives in ../sidecar/index.ts.
|
||||
//
|
||||
// ── The prefix is DERIVED, not written ──
|
||||
//
|
||||
// `mountPrefix` returns `/transcribe` for this first-party publisher; a literal '/api/transcribe' would
|
||||
// be right only by coincidence and wrong the moment anyone else publishes this plugin. Same reasoning as
|
||||
// `plugins/photos/api/router.ts` and `plugins/music/api/router.ts`.
|
||||
const proxy = createSidecarProxy({
|
||||
name: 'transcribe',
|
||||
prefix: `/api${mountPrefix({ appName: 'transcribe', manifest })}`,
|
||||
// whisper.cpp runs at roughly 8x realtime, so a long voice memo (the moved `transcribe.ts` was tuned
|
||||
// for recordings past 40 minutes, and disables Bun's own 300s fetch ceiling for exactly that reason)
|
||||
// can hold this hop open for minutes. 1800s gives it the same headroom on the platform->sidecar leg.
|
||||
timeoutSeconds: 1800,
|
||||
});
|
||||
|
||||
export const router = proxy.router;
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import type { PluginManifest } from '@@/plugins/manifest';
|
||||
|
||||
/**
|
||||
* Transcribe — Officer's speech-to-text, talking to a self-hosted whisper.cpp server.
|
||||
*
|
||||
* ── What this plugin is, and what it is not ──
|
||||
*
|
||||
* It does NOT run Whisper, and it does not ship a model. You point it at a whisper.cpp `/inference`
|
||||
* server you already run (the same container the platform used to talk to directly) and it forwards to
|
||||
* it. Nothing is transcribed here — the sidecar's whole job is the request shape: detect the language,
|
||||
* then transcribe faithfully in it, tuned for one person talking into a phone.
|
||||
*
|
||||
* Named for the feature, not the implementation — the same reasoning as `photos` not `immich`. Swapping
|
||||
* the backend for a different `/inference`-compatible server would not move the mount point or the route
|
||||
* shape.
|
||||
*
|
||||
* ── Where the connection lives ──
|
||||
*
|
||||
* A single URL, in a JSON file under `DATA_PATH/transcribe/`, not in Postgres and not in this
|
||||
* repository. There is only ever one Whisper server for the whole install — unlike `photos_config` or
|
||||
* `jellyfin_servers`, nobody has a reason to keep more than one — so a file the owner can also edit by
|
||||
* hand is simpler than a table with one row. See `sidecar/config.ts`.
|
||||
*
|
||||
* ── No `osDependencies` ──
|
||||
*
|
||||
* Nothing shells out. The sidecar reaches whisper.cpp with `fetch`, the same as the code it replaces did.
|
||||
*
|
||||
* ── No `web/` ──
|
||||
*
|
||||
* This plugin ships no dashboard route and no dock tile — `plugin.web` gates the tile, and a
|
||||
* backend-only plugin has nothing to pin one to. It is reached from a task's right-click menu and by an
|
||||
* agent that has read its skill, not by a screen.
|
||||
*/
|
||||
export const manifest: PluginManifest = {
|
||||
publisher: 'officerdev',
|
||||
version: '1.0.0',
|
||||
platform: '>=1.0.0',
|
||||
|
||||
label: 'Transcribe',
|
||||
summary: 'Speech-to-text over your own whisper.cpp server',
|
||||
icon: 'AudioLines',
|
||||
color: '#f97316',
|
||||
|
||||
permissions: [
|
||||
{
|
||||
key: 'transcribe',
|
||||
label: 'Transcribe',
|
||||
description: 'Turn audio into text over your own whisper.cpp server',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { join } from 'node:path';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { DATA_PATH } from '@@/data-path';
|
||||
|
||||
// The Whisper connection — a single URL, kept in a JSON file rather than a database row.
|
||||
//
|
||||
// There is only ever one whisper.cpp server for the whole install: unlike `photos_config` or
|
||||
// `jellyfin_servers`, nobody has a reason to keep more than one on hand, so a file the owner can also
|
||||
// edit by hand (or drop in with a provisioning script) is simpler than a table that will only ever hold
|
||||
// one row. See `plugins/music/sidecar/indexer.ts` for the same `DATA_PATH/<plugin>/` convention.
|
||||
//
|
||||
// Not committed anywhere: this file lives under DATA_PATH, outside both the platform's and this plugin's
|
||||
// git repositories. Config is data, not definition.
|
||||
|
||||
const CONFIG_DIR = join(DATA_PATH, 'transcribe');
|
||||
const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
|
||||
|
||||
export type TranscribeConfig = { url: string };
|
||||
|
||||
// Cached across requests; invalidated only by `setConfig`. A single small file re-read on every
|
||||
// transcription would be a needless stat + parse on the hot path.
|
||||
let cache: TranscribeConfig | null | undefined;
|
||||
|
||||
async function loadConfig(): Promise<TranscribeConfig | null> {
|
||||
const file = Bun.file(CONFIG_PATH);
|
||||
if (!(await file.exists())) return null;
|
||||
try {
|
||||
const parsed = (await file.json()) as Partial<TranscribeConfig>;
|
||||
return typeof parsed.url === 'string' && parsed.url ? { url: parsed.url } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConfig(): Promise<TranscribeConfig | null> {
|
||||
if (cache === undefined) cache = await loadConfig();
|
||||
return cache;
|
||||
}
|
||||
|
||||
export async function setConfig(config: TranscribeConfig): Promise<void> {
|
||||
await mkdir(CONFIG_DIR, { recursive: true });
|
||||
await Bun.write(CONFIG_PATH, JSON.stringify(config, null, 2));
|
||||
cache = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* The URL as the browser is allowed to see it.
|
||||
*
|
||||
* A whisper.cpp URL is never expected to carry credentials — the server has no auth of its own — but if
|
||||
* an owner points this at something behind a reverse proxy with basic auth baked into the URL, userinfo
|
||||
* (`user:pass@`) must not round-trip back out to `GET /config`. Stripped rather than trusted to stay
|
||||
* absent.
|
||||
*/
|
||||
export function redactUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
parsed.username = '';
|
||||
parsed.password = '';
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
export type ProbeResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Ask a whisper.cpp server whether it is really there.
|
||||
*
|
||||
* Mirrors the platform's old `POST /server-settings/stt/test`: an empty multipart body to `/inference`
|
||||
* always fails on the Whisper side, but ANY response — including its error — means the server exists and
|
||||
* speaks the expected protocol. A network-level failure (wrong host, nothing listening, timeout) is the
|
||||
* only thing that means "not reachable".
|
||||
*/
|
||||
export async function probeWhisper(url: string): Promise<ProbeResult> {
|
||||
const base = url.replace(/\/+$/, '');
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
try {
|
||||
await fetch(`${base}/inference`, { method: 'POST', body: new FormData(), signal: controller.signal });
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : 'could not reach the server' };
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
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,
|
||||
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'));
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { toParagraphs } from './transcribe';
|
||||
import type { WhisperSegment } from './transcribe';
|
||||
|
||||
// Whisper's own shape: leading space on every segment, times in seconds.
|
||||
const seg = (text: string, start: number, end: number, extra: Partial<WhisperSegment> = {}): WhisperSegment => ({
|
||||
text,
|
||||
start,
|
||||
end,
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe('toParagraphs', () => {
|
||||
it('joins segments spoken without a real pause into one paragraph', () => {
|
||||
const out = toParagraphs([seg(' I went to the shop.', 0, 2), seg(' It was closed.', 2.2, 4)]);
|
||||
expect(out).toBe('I went to the shop. It was closed.');
|
||||
});
|
||||
|
||||
it('starts a new paragraph after a long silence', () => {
|
||||
const out = toParagraphs([seg(' First thought.', 0, 2), seg(' Second thought.', 5, 7)]);
|
||||
expect(out).toBe('First thought.\n\nSecond thought.');
|
||||
});
|
||||
|
||||
it('does not break on a breath-length pause', () => {
|
||||
const out = toParagraphs([seg(' Hang on,', 0, 1), seg(' let me think.', 1.5, 3)]);
|
||||
expect(out).toBe('Hang on, let me think.');
|
||||
});
|
||||
|
||||
// The lone "." lines in the old response_format=text output came from segments like this.
|
||||
it('attaches stranded punctuation without a space in front of it', () => {
|
||||
const out = toParagraphs([seg(' See you tomorrow morning', 0, 2), seg(' .', 2.1, 2.2)]);
|
||||
expect(out).toBe('See you tomorrow morning.');
|
||||
});
|
||||
|
||||
it('drops segments that are probably silence decoded with low confidence', () => {
|
||||
const out = toParagraphs([
|
||||
seg(' Real speech here.', 0, 2),
|
||||
seg(' Thanks for watching!', 2.1, 3, { no_speech_prob: 0.95, avg_logprob: -1.8 }),
|
||||
seg(' More real speech.', 3.1, 5),
|
||||
]);
|
||||
expect(out).toBe('Real speech here. More real speech.');
|
||||
});
|
||||
|
||||
it('keeps a confident segment even when it looks like silence', () => {
|
||||
const out = toParagraphs([seg(' Definitely said.', 0, 2, { no_speech_prob: 0.95, avg_logprob: -0.2 })]);
|
||||
expect(out).toBe('Definitely said.');
|
||||
});
|
||||
|
||||
it('keeps segments that report neither probability', () => {
|
||||
const out = toParagraphs([seg(' No metadata at all.', 0, 2)]);
|
||||
expect(out).toBe('No metadata at all.');
|
||||
});
|
||||
|
||||
it('measures the pause from the last kept segment, not the dropped one', () => {
|
||||
const out = toParagraphs([
|
||||
seg(' Before.', 0, 2),
|
||||
seg(' Hallucinated.', 2.1, 9, { no_speech_prob: 0.9, avg_logprob: -2 }),
|
||||
seg(' After.', 9.1, 11),
|
||||
]);
|
||||
// The gap that matters is 2 -> 9.1, so these are separate thoughts.
|
||||
expect(out).toBe('Before.\n\nAfter.');
|
||||
});
|
||||
|
||||
it('collapses internal whitespace and ignores empty segments', () => {
|
||||
const out = toParagraphs([seg(' Too many\n spaces. ', 0, 2), seg(' ', 2.1, 2.2)]);
|
||||
expect(out).toBe('Too many spaces.');
|
||||
});
|
||||
|
||||
it('returns an empty string when every segment is filtered out', () => {
|
||||
expect(toParagraphs([])).toBe('');
|
||||
expect(toParagraphs([seg(' ', 0, 1)])).toBe('');
|
||||
});
|
||||
|
||||
it('falls back to no break when segments carry no timings', () => {
|
||||
const out = toParagraphs([{ text: ' One.' }, { text: ' Two.' }]);
|
||||
expect(out).toBe('One. Two.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,416 @@
|
||||
// Whisper transcription, always in the audio's own language.
|
||||
//
|
||||
// Detect the language first, then transcribe in it. Whisper's translate mode is English-only, so it
|
||||
// cannot honour "translate into <language>" for anything else — rather than silently returning a
|
||||
// rough English rendering of Portuguese audio, transcription is always faithful to the source.
|
||||
//
|
||||
// The detection must win when Whisper is confident. `spokenLanguages` only breaks ties on clips
|
||||
// Whisper is genuinely unsure about, where its low-confidence guess defaults to English.
|
||||
//
|
||||
// The transcription pass is tuned for one person talking into a phone, which is what this is mostly
|
||||
// used for. See the constants below: the defaults whisper.cpp ships are aimed at clean dictation and
|
||||
// produce loops, clipped words and breath-length paragraph breaks on real voice memos.
|
||||
//
|
||||
// Both requests pass `timeout: false`. Bun's fetch aborts at 300s by default, and whisper.cpp runs at
|
||||
// roughly 8x realtime, so anything over ~40 minutes of audio died with "The operation timed out"
|
||||
// about 20 seconds short of the answer. AbortSignal.timeout() does not raise that ceiling — only
|
||||
// disabling the built-in timeout does.
|
||||
|
||||
// Bun's fetch accepts `timeout: false` to disable its built-in 300s ceiling. The DOM RequestInit
|
||||
// type this project compiles against does not declare it, so widen it here rather than casting at
|
||||
// each call site.
|
||||
type BunRequestInit = RequestInit & { timeout?: boolean | number };
|
||||
|
||||
type TranscribeArgs = {
|
||||
file: File | Blob;
|
||||
whisperUrl: string;
|
||||
spokenLanguages: string[];
|
||||
};
|
||||
|
||||
export type TranscribeResult = {
|
||||
text: string;
|
||||
detectedLanguage: string;
|
||||
};
|
||||
|
||||
// Above this probability Whisper's own detection is taken as authoritative. Below it the clip is
|
||||
// treated as ambiguous and the user's spoken languages break the tie.
|
||||
const CONFIDENT_DETECTION = 0.5;
|
||||
|
||||
// whisper.cpp's "language" field uses full names ("english"); ISO codes live in
|
||||
// `language_probabilities`. Map covers what the user can pick in Profile settings.
|
||||
const LANG_NAME_TO_ISO: Record<string, string> = {
|
||||
english: 'en',
|
||||
portuguese: 'pt',
|
||||
spanish: 'es',
|
||||
french: 'fr',
|
||||
german: 'de',
|
||||
italian: 'it',
|
||||
dutch: 'nl',
|
||||
russian: 'ru',
|
||||
chinese: 'zh',
|
||||
japanese: 'ja',
|
||||
korean: 'ko',
|
||||
};
|
||||
|
||||
// Whisper reuses its own decoded output as the prompt for the next 30-second window, which is what
|
||||
// makes it fall into loops ("I know. I know. I know.") once it slips. `max_context` is the budget for
|
||||
// that carried text, and setting it to 0 stops the loops — but the same switch also disables the
|
||||
// initial prompt, since whisper.cpp gates both on `n_max_text_ctx > 0`.
|
||||
//
|
||||
// The way to keep the prompt and still starve the loop: `carry_initial_prompt` puts the prompt in a
|
||||
// static slot that is filled first and in full, and only the leftover budget goes to decoded text.
|
||||
// Every prompt below is comfortably longer than this budget, so there is never any leftover — the
|
||||
// vocabulary and style reach every window and none of the model's own output does.
|
||||
const PROMPT_CONTEXT_TOKENS = 96;
|
||||
|
||||
// Whisper imitates whatever it is primed with; it does not take instructions. So each prompt is a
|
||||
// worked example of the output wanted — one person talking, punctuated the way it should come back —
|
||||
// and nothing else. An earlier version opened by *describing* the format ("Commas separate clauses,
|
||||
// and apostrophes are written properly") and that backfired: priming words which never occur in
|
||||
// speech put them in play, and a character surname came back from a Monk episode as "Commas".
|
||||
// Everything here is ordinary conversational language for that reason.
|
||||
//
|
||||
// Each is written in its own language, since an English prompt on Portuguese audio biases the
|
||||
// output, and each deliberately runs past PROMPT_CONTEXT_TOKENS — see above for why the length is
|
||||
// load-bearing. Overflow is trimmed from the front, so nothing depends on the opening words.
|
||||
//
|
||||
// Languages without an entry fall through to no prompt at all, which is handled explicitly below.
|
||||
//
|
||||
// Every prompt uses its language's full diacritic set (and, for a non-Latin script, its own
|
||||
// punctuation — Japanese 。/、, Greek ; as a question mark, Arabic ، and ؟) in ordinary words, never
|
||||
// forced in. Omitting one, e.g. writing Swedish without å ä ö, teaches Whisper to drop it too.
|
||||
const TRANSCRIPT_PROMPTS: Record<string, string> = {
|
||||
en:
|
||||
'Hey, sorry for the late reply — I was stuck in a meeting all morning and only just got out. ' +
|
||||
"I had a look at the document you sent over, and honestly, I think it's mostly fine. There are " +
|
||||
"a couple of things I'd change in the second half, but nothing serious. The introduction reads " +
|
||||
'really well. Let me know when you have a few minutes and we can talk it through properly. ' +
|
||||
"I'm around all afternoon, so whenever suits you is fine by me. Thanks again for putting it " +
|
||||
'together, I know it took a while.',
|
||||
pt:
|
||||
'Olá, desculpa a demora a responder — estive numa reunião toda a manhã e só agora consegui ' +
|
||||
'sair. Já vi o documento que enviaste e, sinceramente, acho que está quase tudo bem. Há duas ' +
|
||||
'ou três coisas que mudava na segunda metade, mas nada de grave. A introdução está muito bem ' +
|
||||
'escrita. Diz-me quando tiveres uns minutos e falamos com calma. Estou por aqui toda a tarde, ' +
|
||||
'por isso quando te der jeito está bem para mim. Obrigado outra vez por teres tratado disto, ' +
|
||||
'sei que deu trabalho.',
|
||||
es:
|
||||
'Hola, perdona la tardanza en responder — he estado toda la mañana en una reunión y recién ahora ' +
|
||||
'he podido salir. Ya vi el documento que me enviaste y, la verdad, creo que está bastante bien. ' +
|
||||
'Hay un par de cosas que cambiaría en la segunda mitad, pero nada grave. La introducción está muy ' +
|
||||
'bien escrita. Avísame cuando tengas unos minutos y lo hablamos con calma. Estoy disponible toda ' +
|
||||
'la tarde, así que cuando te venga bien está perfecto para mí. Gracias otra vez por armarlo, sé ' +
|
||||
'que llevó tiempo.',
|
||||
de:
|
||||
'Hallo, entschuldige die späte Antwort — ich saß den ganzen Vormittag in einer Besprechung und ' +
|
||||
'bin gerade erst rausgekommen. Ich habe mir das Dokument angeschaut, das du geschickt hast, und ' +
|
||||
'ehrlich gesagt finde ich es größtenteils gut. In der zweiten Hälfte würde ich ein paar ' +
|
||||
'Kleinigkeiten ändern, aber nichts Ernstes. Die Einleitung liest sich wirklich gut. Sag mir ' +
|
||||
'Bescheid, wenn du später ein paar Minuten hast, dann können wir in Ruhe darüber reden. Ich bin ' +
|
||||
'den ganzen Nachmittag über da, also wann immer es dir passt, ist mir recht. Danke nochmal, dass ' +
|
||||
'du dir die Mühe gemacht hast, ich weiß, das hat Zeit gekostet.',
|
||||
fr:
|
||||
"Salut, désolé pour la réponse tardive — j'étais coincé en réunion toute la matinée et je viens " +
|
||||
"tout juste d'en sortir. J'ai regardé le document que tu as envoyé, et honnêtement, je trouve que " +
|
||||
"c'est plutôt bien. Il y a deux ou trois choses que je changerais dans la deuxième moitié, mais " +
|
||||
"rien de grave. L'introduction est vraiment bien écrite. Dis-moi quand tu as quelques minutes et " +
|
||||
"on en parle tranquillement. Je suis disponible tout l'après-midi, donc quand ça t'arrange, ça me " +
|
||||
"va. Merci encore de l'avoir préparé, je sais que ça a pris du temps.",
|
||||
sv:
|
||||
'Hej, förlåt att jag inte svarat tidigare — jag satt i möte hela förmiddagen och kom precis loss. ' +
|
||||
'Jag har tittat på dokumentet du skickade, och ärligt talat tycker jag att det mesta är bra. Det ' +
|
||||
'är några saker jag skulle ändra i andra halvan, men inget allvarligt. Inledningen är verkligen ' +
|
||||
'bra skriven. Säg till när du har några minuter så pratar vi igenom det ordentligt. Jag är ledig ' +
|
||||
'hela eftermiddagen, så när det passar dig passar det mig. Tack igen för att du satte ihop det, ' +
|
||||
'jag vet att det tog ett tag.',
|
||||
it:
|
||||
'Ciao, scusa il ritardo nella risposta — sono stato in riunione tutta la mattina e sono appena ' +
|
||||
"uscito. Ho dato un'occhiata al documento che mi hai mandato e, sinceramente, penso che vada bene " +
|
||||
'quasi tutto. Ci sono un paio di cose che cambierei nella seconda metà, ma niente di grave. ' +
|
||||
"L'introduzione è scritta davvero bene. Fammi sapere quando hai qualche minuto così ne parliamo " +
|
||||
'con calma. Sono libero tutto il pomeriggio, quindi va bene quando ti fa comodo. Grazie ancora ' +
|
||||
'per averlo messo insieme, so che ci è voluto tempo.',
|
||||
nl:
|
||||
'Hoi, sorry voor het late antwoord — ik zat de hele ochtend in een vergadering en ben er net pas ' +
|
||||
'uit. Ik heb naar het document gekeken dat je hebt gestuurd, en eerlijk gezegd vind ik het ' +
|
||||
'grotendeels goed. Er zijn een paar dingen die ik in de tweede helft zou veranderen, maar niets ' +
|
||||
'ergs. De inleiding leest heel prettig. Laat het me weten wanneer je een paar minuten hebt, dan ' +
|
||||
'bespreken we het rustig. Ik ben de hele middag beschikbaar, dus wanneer het jou uitkomt is voor ' +
|
||||
'mij prima. Bedankt nogmaals dat je het hebt samengesteld, ik weet dat het tijd heeft gekost.',
|
||||
pl:
|
||||
'Cześć, przepraszam za późną odpowiedź — siedziałem cały ranek na spotkaniu i w końcu udało mi ' +
|
||||
'się z niego wyrwać. Zerknąłem na dokument, który przesłałeś, i szczerze mówiąc, myślę, że ' +
|
||||
'większość jest w porządku. Jest kilka rzeczy, które zmieniłbym w drugiej połowie, ale nic ' +
|
||||
'poważnego. Wstęp jest naprawdę dobrze napisany. Daj znać, kiedy będziesz mieć chwilę, możemy to ' +
|
||||
'spokojnie omówić. Jestem wolny całe popołudnie, więc kiedy ci pasuje, mi też pasuje. Dzięki ' +
|
||||
'jeszcze raz za to, że to zebrałeś, wiem, że zajęło to trochę czasu.',
|
||||
da:
|
||||
'Hej, undskyld det sene svar — jeg sad i møde hele formiddagen og er lige kommet ud. Jeg har ' +
|
||||
'kigget på dokumentet, du sendte, og ærligt talt synes jeg det meste er fint. Der er et par ting, ' +
|
||||
'jeg ville ændre i anden halvdel, men ikke noget alvorligt. Indledningen er skrevet rigtig godt. ' +
|
||||
'Sig til, når du har et par minutter, så snakker vi ordentligt om det. Jeg er ledig hele ' +
|
||||
'eftermiddagen, så det passer mig, når det passer dig. Tak igen fordi du satte det sammen, jeg ' +
|
||||
'ved det tog tid.',
|
||||
no:
|
||||
'Hei, beklager det sene svaret — jeg satt i møte hele formiddagen og er akkurat ferdig. Jeg har ' +
|
||||
'sett på dokumentet du sendte, og ærlig talt synes jeg det meste er bra. Det er et par ting jeg ' +
|
||||
'ville endret i andre halvdel, men ingenting alvorlig. Innledningen er skrevet veldig bra. Si ' +
|
||||
'ifra når du har noen minutter, så tar vi en skikkelig prat om det. Jeg er ledig hele ' +
|
||||
'ettermiddagen, så det som passer deg passer meg. Takk igjen for at du satte det sammen, jeg vet ' +
|
||||
'det tok tid.',
|
||||
fi:
|
||||
'Hei, anteeksi myöhäinen vastaus — istuin koko aamupäivän palaverissa ja pääsin juuri äsken pois. ' +
|
||||
'Katsoin dokumentin, jonka lähetit, ja rehellisesti sanottuna suurin osa siitä on ihan hyvä. ' +
|
||||
'Loppupuoliskolla on pari asiaa, jotka muuttaisin, mutta ei mitään vakavaa. Johdanto on ' +
|
||||
'kirjoitettu tosi hyvin. Sano, kun sinulla on hetki aikaa, niin jutellaan siitä kunnolla. Olen ' +
|
||||
'vapaana koko iltapäivän, joten mikä tahansa sopii sinulle, sopii minulle. Kiitos vielä kerran, ' +
|
||||
'kun kokosit sen, tiedän että siihen meni aikaa.',
|
||||
tr:
|
||||
'Selam, geç cevap verdiğim için kusura bakma — bütün sabah toplantıdaydım ve az önce çıkabildim. ' +
|
||||
'Gönderdiğin dokümana baktım ve açıkçası çoğunlukla iyi olduğunu düşünüyorum. İkinci yarıda ' +
|
||||
'değiştireceğim birkaç şey var ama ciddi bir şey değil. Giriş kısmı gerçekten iyi yazılmış. ' +
|
||||
'Birkaç dakikan olduğunda haber ver, düzgünce konuşuruz. Bütün öğleden sonra müsaitim, yani sana ' +
|
||||
'uyan zaman bana da uyar. Bunu hazırladığın için tekrar teşekkürler, biliyorum zaman almıştır.',
|
||||
cs:
|
||||
'Ahoj, promiň za pozdní odpověď — celé dopoledne jsem byl na poradě a teprve teď jsem se dostal ' +
|
||||
'ven. Podíval jsem se na dokument, cos poslal, a upřímně řečeno, myslím, že většina je v pořádku. ' +
|
||||
'V druhé polovině bych pár věcí změnil, ale nic vážného. Úvod je napsaný fakt dobře. Dej vědět, ' +
|
||||
'až budeš mít pár minut, můžeme to pořádně probrat. Celé odpoledne mám volno, takže kdy se ti to ' +
|
||||
'bude hodit, mně to vyhovuje. Díky ještě jednou, žes to dal dohromady, vím, že to zabralo čas.',
|
||||
el:
|
||||
'Γεια σου, συγγνώμη για την καθυστερημένη απάντηση — ήμουν σε συνάντηση όλο το πρωί και μόλις ' +
|
||||
'τώρα βγήκα. Είδα το έγγραφο που έστειλες και, ειλικρινά, νομίζω πως είναι σχεδόν όλο εντάξει. ' +
|
||||
'Υπάρχουν δύο τρία πράγματα που θα άλλαζα στο δεύτερο μισό, αλλά τίποτα σοβαρό. Η εισαγωγή είναι ' +
|
||||
'γραμμένη πολύ καλά. Έχεις λίγα λεπτά να τα πούμε με την ησυχία μας; Είμαι ελεύθερος όλο το ' +
|
||||
'απόγευμα, οπότε όποτε σε βολεύει εμένα με βολεύει. Ευχαριστώ ξανά που το ετοίμασες, ξέρω πως ' +
|
||||
'πήρε χρόνο.',
|
||||
uk:
|
||||
'Привіт, вибач за пізню відповідь — я весь ранок сидів на нараді і щойно звідти вийшов. Подивився ' +
|
||||
'документ, який ти надіслав, і чесно кажучи, мої думки такі — майже все добре. У мене є пара ' +
|
||||
'речей, які я б змінив у другій половині, але нічого серйозного. Вступ написаний дуже добре. ' +
|
||||
'Напиши, коли матимеш кілька хвилин, і спокійно все обговоримо. Я вільний увесь день, тобто коли тобі зручно, ' +
|
||||
'мені теж підходить. Дякую ще раз, що зібрав це докупи, знаю, що це зайняло час.',
|
||||
ru:
|
||||
'Привет, извини, что долго не отвечал — всё утро просидел на встрече и только что освободился. ' +
|
||||
'Посмотрел документ, который ты прислал, и, честно говоря, думаю, что почти всё нормально. Есть ' +
|
||||
'пара моментов, которые я бы изменил во второй половине, но ничего серьёзного. Введение написано ' +
|
||||
'правда хорошо. Напиши, когда будет пара свободных минут, спокойно всё обсудим. Я свободен весь ' +
|
||||
'день, так что когда тебе удобно, мне тоже подходит. Спасибо ещё раз, что всё это собрал, знаю, ' +
|
||||
'что это заняло время.',
|
||||
ro:
|
||||
'Salut, scuze pentru răspunsul întârziat — am fost toată dimineața într-o ședință și abia acum am ' +
|
||||
'scăpat. M-am uitat pe documentul pe care mi l-ai trimis și, sincer, cred că e aproape totul ' +
|
||||
'bine. Sunt vreo două-trei lucruri pe care le-aș schimba în a doua jumătate, dar nimic grav. ' +
|
||||
'Introducerea e scrisă chiar bine. Spune-mi când ai câteva minute și vorbim pe îndelete despre ' +
|
||||
'asta. Sunt liber toată după-amiaza, deci oricând îți convine mie îmi convine. Mulțumesc încă o ' +
|
||||
'dată că ai pus totul cap la cap, știu că a durat.',
|
||||
hu:
|
||||
'Szia, bocs a késői válaszért — egész délelőtt gyűlésen ültem, és csak most sikerült ' +
|
||||
'kiszabadulnom. Megnéztem a dokumentumot, amit küldtél, és őszintén szólva szerintem szinte ' +
|
||||
'minden rendben van. Van pár dolog, amit megváltoztatnék a második felében, de semmi komoly. A ' +
|
||||
'bevezető nagyon jól van megírva. Szólj, ha van pár szabad perced, és nyugodtan átbeszéljük. ' +
|
||||
'Egész délután érek rá, szóval amikor neked jó, nekem is megfelel. Köszönöm még egyszer, hogy ' +
|
||||
'összeraktad, tudom, hogy hosszú időbe telt.',
|
||||
ja:
|
||||
'返信が遅くなってごめん。朝からずっと会議で、今やっと抜けられたところ。送ってもらった資料、見たよ。' +
|
||||
'正直、ほとんど問題ないと思う。後半のところで直したい部分が二つ三つあるけど、大したことじゃない。' +
|
||||
'導入の部分はすごくよく書けてると思う。時間があるときに教えて、ゆっくり話そう。' +
|
||||
'午後はずっと空いてるから、都合のいいときでいいよ。まとめてくれてありがとう、時間かかったよね。',
|
||||
zh:
|
||||
'抱歉这么晚才回你——我一早上都在开会,刚出来。你发的那份文件我看过了,说实话,大部分都挺好的。' +
|
||||
'后半部分有几个地方我想改一下,但都不是什么大问题。开头写得真的很不错。你什么时候有空跟我说一声,我们好好聊聊。' +
|
||||
'我整个下午都有空,所以你什么时候方便都行。再次谢谢你把这个整理出来,我知道挺花时间的。',
|
||||
ko:
|
||||
'답장이 늦어서 미안해. 오전 내내 회의하다가 방금 겨우 빠져나왔어. 보내준 문서 봤는데, 솔직히 거의 다 괜찮은 것 같아. 후반부에 고치고 싶은 부분이 두어 개 있긴 한데, ' +
|
||||
'심각한 건 아니야. 서론은 진짜 잘 쓴 것 같아. 시간 될 때 얘기해줘, 그럼 차분히 얘기해보자. 오후 내내 시간 있으니까 편한 때 아무 때나 괜찮아. 정리해줘서 다시 한번 ' +
|
||||
'고마워, 시간 좀 걸렸을 텐데.',
|
||||
ar:
|
||||
'مرحبًا، آسف على تأخري في الرد — كنت في اجتماع طوال الصباح ولتوّي خرجت منه. اطّلعت على المستند ' +
|
||||
'الذي أرسلته، وبصراحة أعتقد أن معظمه جيد. هناك بعض الأشياء التي أود تغييرها في النصف الثاني، لكن ' +
|
||||
'لا شيء خطير. المقدمة مكتوبة بشكل جيد جدًا. هل يمكن أن تخبرني متى تكون لديك بضع دقائق لنتحدث ' +
|
||||
'بهدوء؟ أنا متفرغ طوال بعد الظهر، فأي وقت يناسبك يناسبني. شكرًا مرة أخرى على إعدادك لهذا، أعرف أن ' +
|
||||
'الأمر استغرق وقتًا.',
|
||||
he:
|
||||
'היי, סליחה על התגובה המאוחרת — הייתי בישיבה כל הבוקר ורק עכשיו יצאתי. הסתכלתי על המסמך ששלחת, ' +
|
||||
'ובכנות, אני חושב שרוב הדברים בסדר. יש כמה דברים שהייתי משנה בחצי השני, אבל שום דבר רציני. הפתיחה ' +
|
||||
'כתובה ממש טוב. תגיד לי מתי יש לך כמה דקות ונדבר על זה בנחת. אני פנוי כל אחר הצהריים, אז מתי שנוח ' +
|
||||
'לך נוח גם לי. תודה שוב שסידרת את זה, אני יודע שזה לקח זמן.',
|
||||
hi:
|
||||
'हाय, देर से जवाब देने के लिए माफ़ करना — मैं पूरी सुबह मीटिंग में था और अभी-अभी निकल पाया। तुमने ' +
|
||||
'जो डॉक्यूमेंट भेजा था, वो देख लिया, और सच कहूं तो ज़्यादातर हिस्सा ठीक लग रहा है। दूसरे हिस्से ' +
|
||||
'में कुछ चीज़ें हैं जो मैं बदलना चाहूंगा, पर कुछ गंभीर नहीं है। शुरुआत बहुत अच्छी लिखी गई है। जब ' +
|
||||
'भी कुछ मिनट मिलें बता देना, फिर आराम से बात करते हैं। पूरी दोपहर मैं फ्री हूं, तो जब भी तुम्हें ' +
|
||||
'सही लगे मुझे ठीक है। इसे तैयार करने के लिए फिर से शुक्रिया, पता है इसमें वक़्त लगा होगा।',
|
||||
};
|
||||
|
||||
// Beam search rather than the default greedy decode (whisper.cpp ships `beam_size = -1`). It is the
|
||||
// single biggest quality lever here and it costs time, which this flow has — nothing waits on it.
|
||||
const BEAM_SIZE = 5;
|
||||
const BEST_OF = 5;
|
||||
|
||||
// The whisper container already runs silero VAD, but its defaults assume clean dictation. Widen the
|
||||
// segments so they break at real pauses instead of at breaths, and pad the edges so word onsets are
|
||||
// not clipped — clipped onsets are what shows up in the output as a word split down the middle.
|
||||
const VAD_TUNING: Record<string, string> = {
|
||||
vad_speech_pad_ms: '200', // default 30
|
||||
vad_min_silence_duration_ms: '500', // default 100 — a breath is not a sentence boundary
|
||||
vad_samples_overlap: '0.2', // default 0.1
|
||||
vad_max_speech_duration_s: '25', // default unbounded — keep a segment inside one 30s window
|
||||
};
|
||||
|
||||
// A silence this long reads as a new thought. Whisper reports segment times against the original
|
||||
// audio even when VAD is on (whisper.cpp maps them back through its VAD table), so these really are
|
||||
// gaps in the recording rather than artefacts of the filtering.
|
||||
const PARAGRAPH_GAP_SECONDS = 1.2;
|
||||
const MAX_PARAGRAPH_CHARS = 900;
|
||||
|
||||
// OpenAI Whisper's own silence rule: a segment the model thinks is probably not speech *and* decodes
|
||||
// with low confidence is a hallucination rather than a transcript. whisper.cpp applies a subtly
|
||||
// different form of this internally, so it is worth re-applying to the values it reports — this is
|
||||
// what removes the stray words that appear over music and room tone.
|
||||
const NO_SPEECH_PROB = 0.6;
|
||||
const LOW_CONFIDENCE_LOGPROB = -1.0;
|
||||
|
||||
export type WhisperSegment = {
|
||||
text?: string;
|
||||
start?: number;
|
||||
end?: number;
|
||||
no_speech_prob?: number;
|
||||
avg_logprob?: number;
|
||||
};
|
||||
|
||||
// Defaults are chosen so a segment missing either field is kept: dropping real speech is much worse
|
||||
// than keeping a stray word.
|
||||
const isHallucinatedSilence = (segment: WhisperSegment) =>
|
||||
(segment.no_speech_prob ?? 0) > NO_SPEECH_PROB && (segment.avg_logprob ?? 0) < LOW_CONFIDENCE_LOGPROB;
|
||||
|
||||
// Whisper emits each segment with a leading space and now and then strands trailing punctuation in a
|
||||
// segment of its own, which is why the old `response_format=text` output contained lone "." lines.
|
||||
const appendSegment = (paragraph: string, text: string) => {
|
||||
if (!paragraph) return text;
|
||||
return /^[.,;:!?…)\]]/.test(text) ? paragraph + text : `${paragraph} ${text}`;
|
||||
};
|
||||
|
||||
// Whisper returns one segment per utterance, not per paragraph. Rejoin them into prose and start a
|
||||
// new paragraph on a long pause, so a voice memo reads as something written rather than as a list of
|
||||
// fragments.
|
||||
export function toParagraphs(segments: WhisperSegment[]): string {
|
||||
const paragraphs: string[] = [];
|
||||
let current = '';
|
||||
let previousEnd: number | null = null;
|
||||
|
||||
for (const segment of segments) {
|
||||
const text = (segment.text ?? '').replace(/\s+/g, ' ').trim();
|
||||
if (!text || isHallucinatedSilence(segment)) continue;
|
||||
|
||||
const gap = previousEnd !== null && segment.start !== undefined ? segment.start - previousEnd : 0;
|
||||
// Only break an over-long paragraph where a sentence actually ended, never mid-thought.
|
||||
const overlong = current.length >= MAX_PARAGRAPH_CHARS && /[.!?…]["')\]]?$/.test(current);
|
||||
|
||||
if (current && (gap >= PARAGRAPH_GAP_SECONDS || overlong)) {
|
||||
paragraphs.push(current);
|
||||
current = '';
|
||||
}
|
||||
|
||||
current = appendSegment(current, text);
|
||||
if (segment.end !== undefined) previousEnd = segment.end;
|
||||
}
|
||||
|
||||
if (current) paragraphs.push(current);
|
||||
return paragraphs.join('\n\n');
|
||||
}
|
||||
|
||||
export async function transcribeAudio({
|
||||
file,
|
||||
whisperUrl,
|
||||
spokenLanguages,
|
||||
}: TranscribeArgs): Promise<TranscribeResult> {
|
||||
const base = whisperUrl.replace(/\/+$/, '');
|
||||
|
||||
// Step 1: detect language, and only fall back to the spoken-language bias when Whisper is unsure.
|
||||
const detectForm = new FormData();
|
||||
detectForm.append('file', file);
|
||||
detectForm.append('temperature', '0.0');
|
||||
detectForm.append('response_format', 'verbose_json');
|
||||
detectForm.append('detect_language', 'true');
|
||||
|
||||
const detectRes = await fetch(`${base}/inference`, {
|
||||
method: 'POST',
|
||||
body: detectForm,
|
||||
timeout: false,
|
||||
} as BunRequestInit);
|
||||
if (!detectRes.ok) throw new Error(`Language detection failed (${detectRes.status})`);
|
||||
const detectJson = (await detectRes.json()) as {
|
||||
language?: string;
|
||||
detected_language?: string;
|
||||
detected_language_probability?: number;
|
||||
language_probabilities?: Record<string, number>;
|
||||
};
|
||||
|
||||
const probs = detectJson.language_probabilities;
|
||||
const ranked = probs ? Object.entries(probs).sort((a, b) => b[1] - a[1]) : [];
|
||||
const [topCode, topProb] = ranked[0] ?? [];
|
||||
|
||||
let detectedLanguage: string;
|
||||
if (topCode && (topProb ?? 0) >= CONFIDENT_DETECTION) {
|
||||
// Whisper is sure. Trust it even when it is not a language the user speaks — that is exactly the
|
||||
// case translation exists for.
|
||||
detectedLanguage = topCode;
|
||||
} else if (ranked.length > 0 && spokenLanguages.length > 0) {
|
||||
// Genuinely ambiguous (usually a short clip). Break the tie toward a language the user speaks,
|
||||
// since Whisper's low-confidence guess defaults to English regardless of the audio.
|
||||
const bestSpoken = spokenLanguages
|
||||
.map((code) => [code, probs?.[code] ?? 0] as const)
|
||||
.sort((a, b) => b[1] - a[1])[0];
|
||||
detectedLanguage = bestSpoken?.[0] ?? topCode ?? 'en';
|
||||
} else if (topCode) {
|
||||
detectedLanguage = topCode;
|
||||
} else {
|
||||
// No probabilities field (non-whisper.cpp backend) — try to map the reported name.
|
||||
const raw = (detectJson.detected_language ?? detectJson.language ?? 'en').toLowerCase();
|
||||
detectedLanguage = LANG_NAME_TO_ISO[raw] ?? raw;
|
||||
}
|
||||
|
||||
// Step 2: transcribe in the detected language
|
||||
const transcribeForm = new FormData();
|
||||
transcribeForm.append('file', file);
|
||||
transcribeForm.append('language', detectedLanguage);
|
||||
transcribeForm.append('temperature', '0.0');
|
||||
transcribeForm.append('temperature_inc', '0.2');
|
||||
transcribeForm.append('beam_size', String(BEAM_SIZE));
|
||||
transcribeForm.append('best_of', String(BEST_OF));
|
||||
transcribeForm.append('suppress_nst', 'true');
|
||||
transcribeForm.append('response_format', 'verbose_json');
|
||||
// verbose_json re-runs a full language auto-detect unless this is set. Step 1 already answered
|
||||
// that question, so it is pure duplicated work on every transcription.
|
||||
transcribeForm.append('no_language_probabilities', 'true');
|
||||
// Segment timestamps drive the paragraphing below and are kept; per-token timestamps are extra
|
||||
// work on every segment and nothing here reads them.
|
||||
transcribeForm.append('token_timestamps', 'false');
|
||||
|
||||
const prompt = TRANSCRIPT_PROMPTS[detectedLanguage];
|
||||
if (prompt) {
|
||||
transcribeForm.append('prompt', prompt);
|
||||
transcribeForm.append('carry_initial_prompt', 'true');
|
||||
transcribeForm.append('max_context', String(PROMPT_CONTEXT_TOKENS));
|
||||
} else {
|
||||
// Nothing worth protecting the budget for, so close the loop-feeding path outright.
|
||||
transcribeForm.append('max_context', '0');
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(VAD_TUNING)) transcribeForm.append(key, value);
|
||||
|
||||
const res = await fetch(`${base}/inference`, {
|
||||
method: 'POST',
|
||||
body: transcribeForm,
|
||||
timeout: false,
|
||||
} as BunRequestInit);
|
||||
if (!res.ok) throw new Error(`Transcription failed (${res.status})`);
|
||||
|
||||
const json = (await res.json()) as { text?: string; segments?: WhisperSegment[] };
|
||||
// Fall back to the flat text for any backend that does not return segments.
|
||||
const segments = json.segments ?? [];
|
||||
const text = segments.length > 0 ? toParagraphs(segments) : (json.text ?? '').trim();
|
||||
|
||||
return { text, detectedLanguage };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: Transcribe
|
||||
description: Use when you need to turn a recording into text — a voice memo, a meeting, dictation on disk. Carries the route, the request shape, and what to do when nothing is configured.
|
||||
version: 1
|
||||
---
|
||||
|
||||
Officer's speech-to-text runs entirely inside the `transcribe` plugin, over your own whisper.cpp server.
|
||||
Nothing else on the platform reads audio for you — a request that mentions a recording, a voice memo, a
|
||||
call, a lecture, means this plugin.
|
||||
|
||||
**There is no model turn here.** The plugin does the whole job itself: detect the spoken language, then
|
||||
transcribe faithfully in it. Do not narrate the audio yourself, do not summarize it in place of
|
||||
transcribing, and do not chain a translation step — the plugin is already faithful to the source language,
|
||||
and Whisper's translate mode is English-only, so anything past it would be a rough loss rather than a
|
||||
feature.
|
||||
|
||||
## Is it set up?
|
||||
|
||||
```
|
||||
GET /api/transcribe/config
|
||||
→ { "configured": true, "url": "http://192.168.1.50:64201" }
|
||||
→ { "configured": false, "url": null }
|
||||
```
|
||||
|
||||
If `configured` is `false`, say so plainly and stop — "speech-to-text isn't set up yet; point it at a
|
||||
whisper.cpp server with `PUT /api/transcribe/config`" — rather than attempting the transcription and
|
||||
reporting a generic failure. Setting it up is a one-time, deliberate act (it validates the URL against the
|
||||
server before storing it), not something to do silently on the caller's behalf without being asked.
|
||||
|
||||
```
|
||||
PUT /api/transcribe/config
|
||||
{ "url": "http://192.168.1.50:64201" }
|
||||
→ { "configured": true, "url": "http://192.168.1.50:64201" }
|
||||
→ 400 { "error": "…" } — url missing, malformed, or the server did not answer
|
||||
```
|
||||
|
||||
## Transcribing
|
||||
|
||||
```
|
||||
POST /api/transcribe/transcribe
|
||||
multipart/form-data:
|
||||
file — the audio file (mp3, wav, m4a, whatever whisper.cpp accepts)
|
||||
spokenLanguages — zero or more, e.g. spokenLanguages=en&spokenLanguages=pt
|
||||
|
||||
→ { "text": "…", "detectedLanguage": "en" }
|
||||
→ 400 { "error": "Whisper not configured — PUT /api/transcribe/config first" }
|
||||
→ 502 { "error": "…" } — the whisper.cpp server rejected or failed the request
|
||||
```
|
||||
|
||||
`spokenLanguages` only matters when Whisper's own detection is genuinely unsure — it breaks the tie
|
||||
toward a language you already know the speaker uses. Pass what you know; pass nothing and detection still
|
||||
works, it is just less informed on an ambiguous clip.
|
||||
|
||||
**This can take minutes** on a long recording — whisper.cpp runs at roughly 8x realtime, so a 40-minute
|
||||
memo is a couple of minutes of waiting, not a couple of seconds. Do not treat a slow response as a failure
|
||||
and retry; wait for it.
|
||||
|
||||
## The file-browser task
|
||||
|
||||
`tasks/transcribe-audio-file` in this plugin ships a right-click task for `.mp3`/`.wav`/`.m4a` files that
|
||||
calls this same route and files the result next to the original — a **script** task, deliberately, with no
|
||||
model turn per audio file. If you are asked to transcribe something already sitting in the file browser,
|
||||
prefer pointing the user at that over doing it yourself turn-by-turn.
|
||||
|
||||
## What this is not
|
||||
|
||||
Not a live dictation microphone — there is no streaming endpoint, only a file upload. Not a translator —
|
||||
it transcribes in whatever language was spoken, never into a different one. And it holds no memory of past
|
||||
transcriptions; every call is independent.
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: Transcribe Audio File
|
||||
description: Transcribe an audio file to text over your own whisper.cpp server, no model turn per file.
|
||||
category: Media
|
||||
version: 1
|
||||
mode: script
|
||||
inline: false
|
||||
language: typescript
|
||||
tags:
|
||||
- audio
|
||||
- transcription
|
||||
skills:
|
||||
- transcribe
|
||||
inputs:
|
||||
- name: file_path
|
||||
description: Path to the audio file to transcribe. Auto-filled from the file browser.
|
||||
required: true
|
||||
trigger:
|
||||
- type: file
|
||||
extensions:
|
||||
- mp3
|
||||
- wav
|
||||
- m4a
|
||||
---
|
||||
|
||||
# Transcribe Audio File
|
||||
|
||||
Sends the audio file to the `transcribe` plugin's own route — `POST /api/transcribe/transcribe` — and
|
||||
files the result next to the original.
|
||||
|
||||
A **script** task, deliberately: the plugin already does its own language detection and transcription
|
||||
server-side, so there is nothing here worth a model turn for. Running one per audio file would be a
|
||||
model call spent re-deriving what the plugin already returns.
|
||||
|
||||
## What it does
|
||||
|
||||
1. Read the audio file at `file_path` and the caller's spoken-language preferences
|
||||
(`GET /api/user/settings`).
|
||||
2. `POST` the file to `/api/transcribe/transcribe`. The plugin detects the language and transcribes
|
||||
faithfully in it — see `plugins/transcribe/skills/transcribe/SKILL.md` for the contract.
|
||||
3. Create a sibling directory named `<date>_<slug>`, where `<date>` is today in `YYYYMMDD` and `<slug>`
|
||||
is derived from the original file's own name (not a generated title — a script task gets no model
|
||||
turn to ask for one).
|
||||
4. Move the original audio file into that directory and save the transcription beside it as a
|
||||
Markdown file with the same base name.
|
||||
|
||||
## Before it runs
|
||||
|
||||
The plugin has to be configured — `PUT /api/transcribe/config` with a reachable whisper.cpp URL. If it
|
||||
is not, this fails with that told plainly rather than a generic error.
|
||||
|
||||
## What it deliberately does not do
|
||||
|
||||
The worked example this was adapted from (an agentic task) ran its own detect-language pass before
|
||||
transcribing, and generated a title from the transcription's contents. Both are dropped here: the
|
||||
detect-then-transcribe step is already what the plugin's route does internally, so repeating it would be
|
||||
redundant work against the same server. And a generated title needs a model turn, which is exactly what
|
||||
this task exists to avoid spending per file.
|
||||
@@ -0,0 +1,118 @@
|
||||
// Transcribe Audio File — a SCRIPT task, so this is a plain program, not an agent. It is materialized
|
||||
// alone into a temp dir and run with `bun run index.ts` (see platform/CLAUDE.md → "Task-runner data flow"),
|
||||
// which means it must be fully self-contained: no sibling imports, everything inline.
|
||||
//
|
||||
// Calls Officer's own API rather than talking to whisper.cpp directly — the platform hands every script
|
||||
// task OFFICER_API_URL / OFFICER_API_HOST / OFFICER_AUTH_TOKEN for exactly this (see
|
||||
// src/servers/api/tasks/task-api-env.ts). The transcribe plugin owns the Whisper contract; this script
|
||||
// only owns filing the result.
|
||||
|
||||
import { rename, mkdir } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { basename, dirname, join, extname } from 'node:path';
|
||||
|
||||
const filePath = process.env.INPUT_FILE_PATH;
|
||||
if (!filePath) {
|
||||
console.error('file_path is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const apiUrl = process.env.OFFICER_API_URL;
|
||||
const apiHost = process.env.OFFICER_API_HOST;
|
||||
const authToken = process.env.OFFICER_AUTH_TOKEN;
|
||||
if (!apiUrl || !apiHost || !authToken) {
|
||||
console.error('missing OFFICER_API_URL / OFFICER_API_HOST / OFFICER_AUTH_TOKEN — not running as a task?');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const authHeaders = { Authorization: `Bearer ${authToken}`, Host: apiHost };
|
||||
|
||||
const slugify = (name: string) =>
|
||||
name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
|
||||
async function getSpokenLanguages(): Promise<string[]> {
|
||||
try {
|
||||
const res = await fetch(`${apiUrl}/api/user/settings`, { headers: authHeaders });
|
||||
if (!res.ok) return [];
|
||||
const settings = (await res.json()) as { languages?: { spoken?: string[] } };
|
||||
return settings.languages?.spoken ?? [];
|
||||
} catch {
|
||||
// Not fatal — the plugin's own detection still runs, spoken languages only break a genuine tie.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function transcribe(path: string, spokenLanguages: string[]): Promise<{ text: string; detectedLanguage: string }> {
|
||||
const form = new FormData();
|
||||
form.append('file', Bun.file(path), basename(path));
|
||||
for (const lang of spokenLanguages) form.append('spokenLanguages', lang);
|
||||
|
||||
const res = await fetch(`${apiUrl}/api/transcribe/transcribe`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders,
|
||||
body: form,
|
||||
});
|
||||
|
||||
const json = (await res.json().catch(() => null)) as { text?: string; detectedLanguage?: string; error?: string } | null;
|
||||
if (!res.ok || !json) {
|
||||
throw new Error(json?.error ?? `transcription failed (${res.status})`);
|
||||
}
|
||||
return { text: json.text ?? '', detectedLanguage: json.detectedLanguage ?? 'unknown' };
|
||||
}
|
||||
|
||||
/** `<archiveDir>/<name>`, walking `-2`, `-3`, … past a collision. Same shape as the file browser's own. */
|
||||
async function uniqueDir(parentDir: string, name: string): Promise<string> {
|
||||
let candidate = join(parentDir, name);
|
||||
let n = 2;
|
||||
while (existsSync(candidate)) {
|
||||
candidate = join(parentDir, `${name}-${n}`);
|
||||
n += 1;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const path = filePath!;
|
||||
if (!existsSync(path)) {
|
||||
console.error(`no such file: ${path}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const spokenLanguages = await getSpokenLanguages();
|
||||
|
||||
console.log(`Transcribing ${basename(path)}…`);
|
||||
const { text, detectedLanguage } = await transcribe(path, spokenLanguages);
|
||||
console.log(`Detected language: ${detectedLanguage}`);
|
||||
|
||||
const parentDir = dirname(path);
|
||||
const ext = extname(path);
|
||||
const base = basename(path, ext);
|
||||
|
||||
const now = new Date();
|
||||
const date = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
|
||||
// No model turn to ask for a title (see TASK.md), so the slug comes from the original filename rather
|
||||
// than the transcription's contents.
|
||||
const slug = slugify(base) || 'recording';
|
||||
|
||||
const archiveDir = await uniqueDir(parentDir, `${date}_${slug}`);
|
||||
await mkdir(archiveDir, { recursive: true });
|
||||
|
||||
const archivedAudioPath = join(archiveDir, basename(path));
|
||||
await rename(path, archivedAudioPath);
|
||||
|
||||
const transcriptPath = join(archiveDir, `${base}.md`);
|
||||
await Bun.write(transcriptPath, text);
|
||||
|
||||
console.log(`Filed under ${archiveDir}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user