Files
transcribe/tasks/transcribe-audio-file/index.ts
T
pastilhasandClaude Opus 5 887eed226b the transcription goes beside the audio, and nothing else moves
it created a dated directory, moved the recording into it and wrote the
markdown alongside. nobody asked for an archive. the thing you want from a
transcription is the text next to the recording, which is exactly what the
file viewer's transcribe button did — POST with saveNextTo, sibling .md,
done.

overwrites a previous transcription of the same file on purpose: it is
derived, running it again means you wanted it again, and a -2 beside it is
only a second file to choose between.

inline: true, so the file browser runs it and waits rather than making a
background job out of a call that takes seconds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 04:47:05 +00:00

89 lines
3.5 KiB
TypeScript

// 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 { 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 };
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' };
}
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}`);
// Beside the audio, same name, `.md`. Nothing is moved, nothing is created around it — this is the
// behaviour the file viewer's transcribe button had, and the one thing anybody wants from a
// transcription is the text next to the recording it came from.
//
// It overwrites a previous transcription of the same file deliberately: a transcription is derived,
// running it again means you wanted it again, and a `-2` beside it would only be a second file to
// read and choose between.
const transcriptPath = join(dirname(path), `${basename(path, extname(path))}.md`);
await Bun.write(transcriptPath, text);
console.log(transcriptPath);
}
main().catch((err) => {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
});