Files
transcribe/tasks/transcribe-audio-file/index.ts
T
pastilhas 8c0366fdbc the task takes a destination
output_dir, optional. absent, the text lands beside the recording as it
always has — that is what a person right-clicking a memo wants and it is
not changing. given, the transcript goes there instead, and the directory
is created if it does not exist.

for callers that are not a person clicking a file. a commander keeps its
results in its own directory, and the alternative was making every
commander move the file afterwards: a step in the loop forever, plus a
window where the file sits in the folder being watched.

marked `hidden` so the file browser neither shows it nor counts it as
something to ask about — without that, declaring it turned a direct
right-click run back into a modal with a field nobody clicking a file has
an answer for.
2026-08-25 04:56:43 +00:00

107 lines
4.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 { mkdir } from 'node:fs/promises';
import { basename, dirname, isAbsolute, 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}`);
/*
Where the text goes.
By default, beside the audio with the same name — the behaviour the file viewer's transcribe button
had, and what a person right-clicking a recording wants: the text turns up next to the thing it came
from.
`output_dir` overrides it, and exists for callers that are not a person clicking a file. A commander
keeps its results in its OWN directory: writing them into the folder it watches would put its work in
the middle of its subject, where nobody asked for it and where its next pass has to tell the two
apart. Passing a destination is one input; making every such caller move the file afterwards is a
step in every loop, forever, and a window where the file is in the wrong place.
Created if missing, so a caller does not have to make the directory first — the first run of a new
commander otherwise fails on a directory only this script knows the name of.
Either way it overwrites a previous transcription of the same recording, 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 outputDir = process.env.INPUT_OUTPUT_DIR?.trim();
const destDir = outputDir ? (isAbsolute(outputDir) ? outputDir : join(dirname(path), outputDir)) : dirname(path);
if (outputDir) await mkdir(destDir, { recursive: true });
const transcriptPath = join(destDir, `${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);
});