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.
107 lines
4.5 KiB
TypeScript
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);
|
|
});
|