433 lines
21 KiB
TypeScript
433 lines
21 KiB
TypeScript
/**
|
|
* pi ASR extension — speech-to-text through the NVIDIA Speech NIM realtime WebSocket API.
|
|
*
|
|
* Tools:
|
|
* asr_transcribe transcribe an audio/video file (wav, mp3, m4a, aac, ogg, flac, opus, mp4, mov, mkv)
|
|
* asr_health pre-flight: health/live + health/ready + the model the NIM actually advertises
|
|
* Command:
|
|
* /asr <file> [--lang xx-YY] [--words] [--speed N] [--start S] [--out transcript.txt]
|
|
*
|
|
* Config via env (built-in defaults otherwise):
|
|
* ASR_NIM_URL https://ai.iaminsane.online/openai/streaming-asr
|
|
* ASR_API_KEY key sent as `Authorization: Bearer ...` on every HTTPS/WS call
|
|
*/
|
|
|
|
import { existsSync, statSync } from "node:fs";
|
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
import type { AgentToolResult, ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
import { Text } from "@mariozechner/pi-tui";
|
|
import { Type } from "@sinclair/typebox";
|
|
import {
|
|
DEFAULT_BASE_URL, RealtimeAsrClient, hasFfmpeg, loadPcm16, listAudioInputs,
|
|
type MicRecording, type TranscriptResult, type WordInfo,
|
|
} from "../src/client.js";
|
|
|
|
interface TranscribeParams {
|
|
path: string;
|
|
language?: string;
|
|
model?: string;
|
|
words?: boolean;
|
|
punctuation?: boolean;
|
|
start?: number;
|
|
speed?: number;
|
|
pace?: boolean;
|
|
out?: string;
|
|
}
|
|
|
|
interface AsrDetails {
|
|
path: string;
|
|
text: string;
|
|
seconds: number;
|
|
wallMs: number;
|
|
model: string | null;
|
|
sessionId: string;
|
|
wordCount: number;
|
|
segments: { transcript: string; isLast: boolean }[];
|
|
words?: WordInfo[];
|
|
outPath?: string;
|
|
partial?: string;
|
|
}
|
|
|
|
const wordCount = (t: string): number => (t.trim() ? t.trim().split(/\s+/).length : 0);
|
|
const rel = (p: string, cwd: string): string => {
|
|
const r = relative(cwd, p);
|
|
return r && !r.startsWith("..") && !isAbsolute(r) ? r : p;
|
|
};
|
|
|
|
export default function asrExtension(pi: ExtensionAPI) {
|
|
async function transcribeFile(
|
|
rawPath: string,
|
|
params: TranscribeParams,
|
|
cwd: string,
|
|
signal: AbortSignal | undefined,
|
|
onDelta?: (t: string) => void,
|
|
): Promise<{ result: TranscriptResult; wallMs: number; absPath: string }> {
|
|
const absPath = resolve(cwd, rawPath.replace(/^@+/, ""));
|
|
if (!existsSync(absPath)) throw new Error(`no such file: ${absPath}`);
|
|
if (statSync(absPath).isDirectory()) throw new Error(`${absPath} is a directory, expected an audio file`);
|
|
|
|
const client = new RealtimeAsrClient({
|
|
language: params.language ?? "en-US",
|
|
model: params.model || null,
|
|
punctuation: params.punctuation ?? true,
|
|
wordOffsets: params.words ?? false,
|
|
});
|
|
|
|
const t0 = Date.now();
|
|
const pcm = await loadPcm16(absPath, {
|
|
sampleRate: 16000, channels: 1,
|
|
start: params.start ?? 0, speed: params.speed ?? 1,
|
|
});
|
|
if (!pcm.length) throw new Error(`decoded ${absPath} to zero bytes of audio (corrupt or empty track?)`);
|
|
|
|
const result = await client.transcribe(pcm, { pace: params.pace ?? true, frameMs: 100, onDelta, signal });
|
|
if (signal?.aborted) throw new Error("transcription cancelled");
|
|
if (result.failed.length) {
|
|
throw new Error(`server reported a transcription failure: ${JSON.stringify(result.failed[0]).slice(0, 300)}`);
|
|
}
|
|
return { result, wallMs: Date.now() - t0, absPath };
|
|
}
|
|
|
|
async function runTranscribe(
|
|
params: TranscribeParams,
|
|
ctx: ExtensionContext,
|
|
onUpdate?: (r: AgentToolResult<AsrDetails>) => void,
|
|
signal?: AbortSignal,
|
|
): Promise<AgentToolResult<AsrDetails>> {
|
|
if (!params.path?.trim()) throw new Error("`path` is required");
|
|
if (signal?.aborted) throw new Error("transcription cancelled");
|
|
let lastPartial = "";
|
|
const started = Date.now();
|
|
|
|
const { result, wallMs, absPath } = await transcribeFile(params.path.trim(), params, ctx.cwd, signal, (t) => {
|
|
if (!t || t === lastPartial) return;
|
|
lastPartial = t;
|
|
onUpdate?.({
|
|
content: [{ type: "text", text: t }],
|
|
details: {
|
|
path: rel(absPath, ctx.cwd), text: t, seconds: 0, wallMs: Date.now() - started,
|
|
model: null, sessionId: "", wordCount: wordCount(t), segments: [], partial: t,
|
|
},
|
|
});
|
|
});
|
|
|
|
const outPath = params.out ? resolve(ctx.cwd, params.out.replace(/^@+/, "")) : undefined;
|
|
if (outPath) { await mkdir(dirname(outPath), { recursive: true }); await writeFile(outPath, `${result.text}\n`, "utf-8"); }
|
|
|
|
const details: AsrDetails = {
|
|
path: rel(absPath, ctx.cwd),
|
|
text: result.text,
|
|
seconds: result.seconds,
|
|
wallMs,
|
|
model: result.model,
|
|
sessionId: result.sessionId,
|
|
wordCount: wordCount(result.text),
|
|
segments: result.segments,
|
|
...(params.words ? { words: result.words } : {}),
|
|
...(outPath ? { outPath: rel(outPath, ctx.cwd) } : {}),
|
|
};
|
|
|
|
const summary = `${result.text || "(empty transcript)"}\n\n— ${details.wordCount} words, ${result.seconds}s audio, ${wallMs}ms wall, model=${result.model ?? "?"}${outPath ? `, wrote ${details.outPath}` : ""}`;
|
|
const timings = params.words && result.words.length
|
|
? `\n\nword timings:\n${result.words.map(w => ` ${(+w.start_time).toFixed(2).padStart(7)} → ${(+w.end_time).toFixed(2).padStart(7)} ${w.word}${w.confidence != null ? ` (${(+w.confidence).toFixed(2)})` : ""}`).join("\n")}`
|
|
: "";
|
|
return { content: [{ type: "text", text: summary + timings }], details };
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- tools -- */
|
|
pi.registerTool({
|
|
name: "asr_transcribe",
|
|
label: "ASR transcribe",
|
|
description: "Speech-to-text a local audio or video file via the NVIDIA Speech NIM realtime streaming endpoint (Parakeet/Nemotron streaming ASR). Accepts wav, mp3, m4a, aac, ogg, opus, flac, mp4, mov, mkv - anything ffmpeg can decode; non-16kHz/mono input is resampled automatically. Returns the final transcript, optionally with per-word timings.",
|
|
promptSnippet: "Transcribe an audio or video file to text (speech-to-text) with the streaming ASR server",
|
|
promptGuidelines: [
|
|
"Use asr_transcribe for any speech-to-text request (voice notes, meeting recordings, podcast clips, screen recordings) instead of hand-rolling ffmpeg/python.",
|
|
"asr_transcribe is real-time bound: budget roughly the duration of the audio. Pass speed=2 to halve wall time when slight accuracy loss is fine, or start=N to skip leading silence.",
|
|
"If asr_transcribe returns an empty transcript, run asr_health to confirm the server is up and the language/model are right.",
|
|
],
|
|
parameters: Type.Object({
|
|
path: Type.String({ description: "Audio/video file, relative to cwd or absolute (a leading @ is stripped)" }),
|
|
language: Type.Optional(Type.String({ description: "BCP-47 language tag, default en-US" })),
|
|
model: Type.Optional(Type.String({ description: "Override the ASR model name (default: whatever the server advertises)" })),
|
|
words: Type.Optional(Type.Boolean({ description: "Include per-word timings + confidence (default false)" })),
|
|
punctuation: Type.Optional(Type.Boolean({ description: "Server-side automatic punctuation (default true)" })),
|
|
start: Type.Optional(Type.Number({ description: "Skip the first N seconds" })),
|
|
speed: Type.Optional(Type.Number({ description: "Playback rate 0.5..4 (ffmpeg atempo); >1 is faster, slightly less accurate" })),
|
|
pace: Type.Optional(Type.Boolean({ description: "Stream at 1x realtime pacing (default true)" })),
|
|
out: Type.Optional(Type.String({ description: "Also write the plain transcript to this path" })),
|
|
}),
|
|
|
|
async execute(_id, params: TranscribeParams, signal, onUpdate, ctx): Promise<AgentToolResult<AsrDetails>> {
|
|
return runTranscribe(params, ctx, onUpdate as any, signal);
|
|
},
|
|
|
|
renderCall(args: any, theme: any) {
|
|
const bits = [
|
|
theme.fg("toolTitle", theme.bold("asr_transcribe ")),
|
|
theme.fg("muted", String(args?.path ?? "")),
|
|
];
|
|
if (args?.language && args.language !== "en-US") bits.push(theme.fg("dim", ` ${args.language}`));
|
|
if (args?.speed && args.speed !== 1) bits.push(theme.fg("dim", ` ${args.speed}x`));
|
|
if (args?.words) bits.push(theme.fg("dim", " +words"));
|
|
if (args?.out) bits.push(theme.fg("dim", ` → ${args.out}`));
|
|
return new Text(bits.join(""), 0, 0);
|
|
},
|
|
|
|
renderResult(result: any, options: any, theme: any) {
|
|
const d: AsrDetails | undefined = result?.details;
|
|
if (!d) return new Text("", 0, 0);
|
|
if (options?.isPartial && d.partial) return new Text(theme.fg("warning", `… ${d.partial}`), 0, 0);
|
|
const head = theme.fg("muted", `${d.path} ${d.seconds}s audio → ${d.wallMs}ms ${d.model ?? ""}`.trimEnd());
|
|
const body = d.text
|
|
? theme.fg("success", d.text)
|
|
: theme.fg("warning", "(empty transcript — check --lang / model, or run asr_health)");
|
|
let out = `${head}\n${body}`;
|
|
if (options?.expanded && d.words?.length) {
|
|
out += "\n" + d.words.slice(0, 50).map(w => theme.fg("dim", ` ${(+w.start_time).toFixed(2).padStart(7)} → ${(+w.end_time).toFixed(2).padStart(7)} ${w.word}`)).join("\n");
|
|
} else if (d.wordCount) {
|
|
out += " " + theme.fg("dim", `(${d.wordCount} words)`);
|
|
}
|
|
if (options?.expanded && d.outPath) out += "\n" + theme.fg("dim", ` wrote ${d.outPath}`);
|
|
return new Text(out, 0, 0);
|
|
},
|
|
});
|
|
|
|
pi.registerTool({
|
|
name: "asr_health",
|
|
label: "ASR health",
|
|
description:
|
|
"Check the ASR server before/while debugging: GET health/live + health/ready, and mint a realtime session to " +
|
|
"report the model, language and sample rate the server will actually use. Also verifies ffmpeg availability " +
|
|
"and reports the API key being used (masked).",
|
|
promptSnippet: "Check ASR server health and the currently advertised model/sample-rate config",
|
|
parameters: Type.Object({}),
|
|
|
|
async execute(_id, _params, _signal, _onUpdate, ctx): Promise<AgentToolResult<any>> {
|
|
const client = new RealtimeAsrClient();
|
|
const [live, ready, minted, ffmpeg, inputs] = await Promise.all([
|
|
client.health("live", 10000).catch((e: any) => ({ error: e?.message ?? String(e) })),
|
|
client.health("ready", 10000).catch((e: any) => ({ error: e?.message ?? String(e) })),
|
|
client.mint(10000).catch((e: any) => ({ error: e?.message ?? String(e) })),
|
|
hasFfmpeg(),
|
|
listAudioInputs().catch(() => [] as { index: number; name: string }[]),
|
|
]);
|
|
const ok = live?.status === "live" && ready?.status === "ready" && !minted?.error;
|
|
const key = client.cfg.apiKey;
|
|
const secs = minted?.client_secret ? Math.max(0, (minted.client_secret.expires_at ?? 0) - Math.floor(Date.now() / 1000)) : 0;
|
|
const text = [
|
|
`baseUrl ${process.env.ASR_NIM_URL ?? DEFAULT_BASE_URL}`,
|
|
`apiKey ${key ? `${key.slice(0, 5)}…${key.slice(-3)} (${key.length} chars)` : "NOT SET"}`,
|
|
`health/live ${JSON.stringify(live)}`,
|
|
`health/ready ${JSON.stringify(ready)}`,
|
|
minted?.error
|
|
? `mint ERROR ${minted.error}`
|
|
: [
|
|
`mint ${minted.id}`,
|
|
` model ${minted.input_audio_transcription?.model}`,
|
|
` language ${minted.input_audio_transcription?.language}`,
|
|
` audio ${minted.input_audio_format}, ${minted.input_audio_params?.sample_rate_hz}Hz x${minted.input_audio_params?.num_channels}`,
|
|
` ws token single-use, expires in ${secs}s (mint immediately before each connect)`,
|
|
].join("\n"),
|
|
`ffmpeg ${ffmpeg ? "available (any container/codec accepted)" : "MISSING — only 16kHz mono wav files will work"}`,
|
|
`mic inputs ${inputs.length ? inputs.map(d => `[${d.index}] ${d.name}`).join(", ") : ffmpeg ? "none enumerated (check Mic permission / AVFoundation build)" : "n/a (ffmpeg missing)"}`,
|
|
`note the realtime decoder runs at ~1x realtime; a 10min file takes ~10min (speed=2 halves it)`,
|
|
].join("\n");
|
|
if (!ok) throw new Error(`ASR server is not usable:\n${text}`);
|
|
return { content: [{ type: "text", text }], details: { ok, live, ready, minted, ffmpeg, inputs } };
|
|
},
|
|
|
|
renderResult(result: any, _options: any, theme: any) {
|
|
const d: any = result?.details;
|
|
if (!d) return new Text("", 0, 0);
|
|
const m = d.minted?.input_audio_transcription;
|
|
const p = d.minted?.input_audio_params;
|
|
return new Text([
|
|
d.ok ? theme.fg("success", "✓ ASR server live") : theme.fg("error", "✗ ASR server not usable"),
|
|
theme.fg("muted", ` model ${m?.model ?? d.minted?.error ?? "?"}`),
|
|
theme.fg("dim", ` ${p?.sample_rate_hz ?? "?"}Hz x${p?.num_channels ?? "?"} ${d.minted?.input_audio_format ?? ""} · ${m?.language ?? "?"} · ffmpeg ${d.ffmpeg ? "ok" : "MISSING"}`),
|
|
].join("\n"), 0, 0);
|
|
},
|
|
});
|
|
|
|
/* --------------------------------------------------------------- /asr --- */
|
|
pi.registerCommand("asr", {
|
|
description: "Transcribe an audio/video file: /asr <file> [--lang xx-YY] [--words] [--speed N] [--start S] [--out file.txt]",
|
|
handler: async (args: string, ctx: ExtensionContext) => {
|
|
const toks = args.trim().split(/\s+/).filter(Boolean);
|
|
if (!toks.length) {
|
|
ctx.ui.notify("usage: /asr <file> [--lang xx-YY] [--words] [--speed N] [--start S] [--out file.txt]", "info");
|
|
return;
|
|
}
|
|
const p: TranscribeParams = { path: "" };
|
|
for (let i = 0; i < toks.length; i++) {
|
|
const t = toks[i];
|
|
if (t === "--lang") p.language = toks[++i];
|
|
else if (t === "--words") p.words = true;
|
|
else if (t === "--speed") p.speed = Number.parseFloat(toks[++i]) || 1;
|
|
else if (t === "--start") p.start = Number.parseFloat(toks[++i]) || 0;
|
|
else if (t === "--out") p.out = toks[++i];
|
|
else if (!p.path) p.path = t;
|
|
}
|
|
if (!p.path) { ctx.ui.notify("no file given", "error"); return; }
|
|
|
|
let last = "";
|
|
ctx.ui.setStatus("asr", `transcribing ${p.path} …`);
|
|
try {
|
|
const r = await runTranscribe(p, ctx);
|
|
const d = r.details;
|
|
last = d.text || "(empty transcript)";
|
|
ctx.ui.notify(`${d.path}: ${d.wordCount} words, ${d.seconds}s audio in ${d.wallMs}ms`, "info");
|
|
if (ctx.hasUI) {
|
|
await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
|
|
const body = new Text([
|
|
theme.fg("toolTitle", theme.bold(` transcribed ${d.path} `)),
|
|
theme.fg("muted", `${d.seconds}s audio → ${d.wallMs}ms · ${d.model ?? "?"} · ${d.wordCount} words`),
|
|
"",
|
|
theme.fg(d.text ? "success" : "warning", d.text || "(empty transcript)"),
|
|
...(d.words?.length ? [theme.fg("dim", `\n${d.words.slice(0, 12).map(w => `${(+w.start_time).toFixed(2).padStart(7)} → ${(+w.end_time).toFixed(2).padStart(7)} ${w.word}`).join("\n")}`)] : []),
|
|
"",
|
|
theme.fg("dim", " enter/esc to close"),
|
|
].join("\n"), 1, 1);
|
|
body.onKey = (key: string) => { if (key === "return" || key === "escape" || key === " ") { done(undefined); return true; } return true; };
|
|
return body;
|
|
});
|
|
} else {
|
|
console.log(last);
|
|
}
|
|
} catch (e: any) {
|
|
ctx.ui.notify(`ASR failed: ${e?.message ?? e}`, "error");
|
|
} finally {
|
|
ctx.ui.setStatus("asr", undefined);
|
|
}
|
|
},
|
|
});
|
|
|
|
/* ------------------------------------------------------------- /dictate -- *
|
|
* macOS live dictation: ffmpeg AVFoundation mic capture -> the same realtime WS client.
|
|
* Toggle semantics (press to start, press again to stop + flush) because a plain terminal
|
|
* gives no key-release event for hold-to-talk without the Kitty keyboard protocol.
|
|
* Shared module state so the /dictate command and the ctrl+shift+d shortcut drive one session.
|
|
*/
|
|
let active: { rec: MicRecording; submit: boolean; ourTail: string } | null = null;
|
|
let starting = false;
|
|
/**
|
|
* Append-as-you-go composer. Dictation owns ONLY a suffix at the very end of the editor (the words
|
|
* heard so far). Each refresh strips exactly the suffix we appended last time from the *current*
|
|
* text and re-appends the grown tail — so we never overwrite the user's draft and never revive text
|
|
* they deleted: we derive from the live editor, not a start-of-session snapshot.
|
|
*/
|
|
function paintTail(ctx: ExtensionContext, prevOurs: string, tail: string): string {
|
|
const cur = ctx.ui.getEditorText() ?? "";
|
|
let prefix = cur;
|
|
if (prevOurs) {
|
|
if (cur.endsWith(prevOurs)) prefix = cur.slice(0, cur.length - prevOurs.length);
|
|
else { const i = cur.lastIndexOf(prevOurs); if (i >= 0) prefix = cur.slice(0, i) + cur.slice(i + prevOurs.length); }
|
|
}
|
|
const t = tail.trim();
|
|
const ours = t ? (prefix === "" || /\s$/.test(prefix) ? "" : " ") + t : "";
|
|
ctx.ui.setEditorText(prefix + ours);
|
|
return ours;
|
|
}
|
|
/** Drop the suffix we own, leaving the user's own text untouched (cancel / no-speech / submit). */
|
|
function stripTail(ctx: ExtensionContext, ours: string): void {
|
|
if (!ours) return;
|
|
const cur = ctx.ui.getEditorText() ?? "";
|
|
if (cur.endsWith(ours)) ctx.ui.setEditorText(cur.slice(0, cur.length - ours.length));
|
|
else { const i = cur.lastIndexOf(ours); if (i >= 0) ctx.ui.setEditorText(cur.slice(0, i) + cur.slice(i + ours.length)); }
|
|
}
|
|
|
|
async function resolveDevice(d?: string | null): Promise<string | null> {
|
|
if (!d) return null;
|
|
if (/^\d+$/.test(d)) {
|
|
const hit = (await listAudioInputs()).find(x => x.index === +d);
|
|
if (!hit) throw new Error(`no audio input at index ${d} — list them with: /dictate devices`);
|
|
return hit.name;
|
|
}
|
|
return d;
|
|
}
|
|
|
|
async function toggleDictation(ctx: ExtensionContext, opts: { device?: string | null; language?: string; submit?: boolean } = {}): Promise<void> {
|
|
if (!ctx.hasUI) { ctx.ui.notify("dictation needs the interactive TUI", "error"); return; }
|
|
|
|
// second press while live => stop, flush, finalise the composer with the deduped transcript.
|
|
if (active) {
|
|
const { rec, submit, ourTail } = active; active = null;
|
|
ctx.ui.setStatus("asr", undefined);
|
|
try {
|
|
const res = await rec.stop();
|
|
const text = res.text.trim();
|
|
if (rec.error) { stripTail(ctx, ourTail); ctx.ui.notify(`dictation error: ${rec.error}`, "error"); return; }
|
|
if (!text) { stripTail(ctx, ourTail); ctx.ui.notify("no speech detected (speak a little longer, or run asr_health)", "warning"); return; }
|
|
if (submit) { stripTail(ctx, ourTail); pi.sendUserMessage(text); }
|
|
else paintTail(ctx, ourTail, text); // swap our live suffix for the finalised text; user's own text is untouched
|
|
ctx.ui.notify(`dictated ${wordCount(text)} words (${res.seconds}s) → ${submit ? "sent" : "added"}`, "info");
|
|
} catch (e: any) {
|
|
rec.cancel(); stripTail(ctx, ourTail);
|
|
ctx.ui.notify(`dictation failed: ${e?.message ?? e}`, "error");
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (starting) return; // ignore a double-press during mint+handshake
|
|
starting = true;
|
|
const language = opts.language ?? process.env.ASR_DICTATE_LANGUAGE ?? "en-US";
|
|
let device: string | null;
|
|
try { device = await resolveDevice(opts.device); }
|
|
catch (e: any) { starting = false; ctx.ui.notify(`dictation: ${e?.message ?? e}`, "error"); return; }
|
|
|
|
// No start-of-session snapshot: dictation appends to the *live* editor text (paintTail/stripTail),
|
|
// so any edits or deletions the user makes while we record are preserved and never revived.
|
|
const client = new RealtimeAsrClient({ language, punctuation: true });
|
|
ctx.ui.setStatus("asr", "● listening …");
|
|
let rec: MicRecording;
|
|
try {
|
|
rec = await client.recordMic({
|
|
device,
|
|
// stream word-by-word: paintTail owns only our appended suffix, so the user's draft is preserved
|
|
// and anything they delete stays deleted (no snapshot is ever written back). liveText is cumulative.
|
|
onLive: (live) => { if (active) { active.ourTail = paintTail(ctx, active.ourTail, live); ctx.ui.setStatus("asr", `● ${live.trim().slice(-70) || "…listening"}`); } },
|
|
});
|
|
} catch (e: any) {
|
|
starting = false; ctx.ui.setStatus("asr", undefined);
|
|
ctx.ui.notify(`could not start mic capture: ${e?.message ?? e}\n(ffmpeg on PATH w/ AVFoundation; grant the terminal Microphone access in System Settings → Privacy & Security)`, "error");
|
|
return;
|
|
}
|
|
starting = false;
|
|
active = { rec, submit: !!opts.submit, ourTail: "" };
|
|
active.ourTail = paintTail(ctx, "", rec.liveText()); // reflect anything already recognised without overwriting the user's draft
|
|
ctx.ui.notify(`🎙 recording${device ? ` from “${device}”` : " (default mic)"} — speech streams into the prompt; ctrl+shift+d or /dictate to stop`, "info");
|
|
}
|
|
|
|
pi.registerShortcut("ctrl+shift+m", {
|
|
description: "Toggle macOS live dictation (press to start, press again to stop and paste into the prompt)",
|
|
handler: async (ctx) => { await toggleDictation(ctx); },
|
|
});
|
|
|
|
pi.registerCommand("dictate", {
|
|
description: "Live macOS dictation (mic → realtime ASR): /dictate [stop|devices] [--device <name|index>] [--lang xx-YY] [--submit]",
|
|
handler: async (args: string, ctx: ExtensionContext) => {
|
|
const toks = args.trim().split(/\s+/).filter(Boolean);
|
|
const head = (toks[0] ?? "").toLowerCase();
|
|
if (head === "devices") {
|
|
const ds = await listAudioInputs();
|
|
ctx.ui.notify(ds.length ? ds.map(d => `[${d.index}] ${d.name}`).join("\n") : "no audio inputs found (ffmpeg w/ AVFoundation? Mic permission?)", "info");
|
|
return;
|
|
}
|
|
if (head === "stop" || head === "cancel") {
|
|
if (!active) { ctx.ui.notify("not currently recording", "info"); return; }
|
|
await toggleDictation(ctx);
|
|
return;
|
|
}
|
|
let device: string | undefined, language: string | undefined, submit = false;
|
|
for (let i = 0; i < toks.length; i++) {
|
|
const t = toks[i];
|
|
if (t === "--device") device = toks[++i];
|
|
else if (t === "--lang") language = toks[++i];
|
|
else if (t === "--submit") submit = true;
|
|
}
|
|
await toggleDictation(ctx, { device, language, submit });
|
|
},
|
|
});
|
|
}
|