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,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