@maheidem/pi-audio-transcribe 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,32 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 maheidem
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ This project builds against and vendors patterns from works under the MIT
26
+ License, copyright their respective authors:
27
+
28
+ - @earendil-works/pi-coding-agent (pi coding agent), including the official
29
+ `subagent` and `plan-mode` extension examples and RPC documentation.
30
+ - @earendil-works/pi-tui.
31
+ - The local `/loop` extension (same workspace), from which
32
+ `ui/settings-panel.ts` is vendored.
package/README.md ADDED
@@ -0,0 +1,132 @@
1
+ # audio-transcribe
2
+
3
+ Speech-to-text for [pi](https://github.com/earendil-works/pi) using an **oMLX** server running a Qwen3-ASR model. Give pi an audio file; it transcribes, validates the speech, and persists a JSON transcript sidecar beside the original.
4
+
5
+ Built and verified against **oMLX 0.6.4** on `http://192.168.31.152:8123` with `Qwen3-ASR-1.7B-8bit`.
6
+
7
+ ## What you get
8
+
9
+ | Surface | Trigger |
10
+ |---|---|
11
+ | `transcribe_audio` tool | The model calls it when you reference an audio file |
12
+ | `/transcribe` command | You invoke it directly |
13
+ | `input` hook | Audio paths pasted into chat are auto-transcribed before the model sees them |
14
+
15
+ Per file: **pre-flight → ffprobe → ASR → cross-check pass → validation → atomic JSON sidecar** (`audio.m4a` → `audio.transcript.json`).
16
+
17
+ ## Usage
18
+
19
+ ```
20
+ /transcribe # interactive controls
21
+ /transcribe "/Users/me/Downloads/Medidas da gaveta.m4a"
22
+ /transcribe ./recordings --recursive --lang pt --force
23
+ /transcribe --status # scriptable diagnostics
24
+ ```
25
+
26
+ The bare command opens a responsive control panel in TUI mode. It manages the
27
+ input hook, validation pass, overwrite policy, language/model hints, server,
28
+ timeout, file-size limit, diagnostics, and reset. It can also launch a direct
29
+ transcription. Outside TUI mode the bare command emits status. Existing nested
30
+ forms remain the stable scripting interface.
31
+
32
+ Flags: `--lang <iso>`, `--model <id>`, `--no-crosscheck`, `--force`, `--recursive`.
33
+
34
+ Pasting a path into chat also just works, including the backslash-escaped form pi inserts on interactive paste:
35
+
36
+ ```
37
+ > what did I say in /Users/me/Downloads/Medidas\ da\ gaveta.m4a ?
38
+ ```
39
+
40
+ ## Configuration
41
+
42
+ `~/.pi/agent/audio-transcribe.json` (defaults shown):
43
+
44
+ ```json
45
+ {
46
+ "baseUrl": "http://192.168.31.152:8123/v1",
47
+ "language": null,
48
+ "model": null,
49
+ "timeoutMs": 300000,
50
+ "crossCheck": true,
51
+ "autoDetect": true,
52
+ "maxBytes": 0,
53
+ "force": false
54
+ }
55
+ ```
56
+
57
+ The file is created on first load, written atomically with owner-only permissions,
58
+ and a corrupt copy is preserved before defaults recover. The panel intentionally
59
+ hides API-key contents; add optional `"apiKey": "..."` by hand or use an
60
+ environment variable.
61
+
62
+ Precedence: defaults < config file < env (`OMLX_BASE_URL`, `OMLX_API_KEY`, `AUDIO_TRANSCRIBE_{MODEL,LANGUAGE,TIMEOUT_MS,CROSSCHECK,AUTODETECT,MAX_BYTES}`). `language: "auto"` or `null` means *send no hint* and let the server detect. Active environment overrides are called out in the panel instead of silently masking the saved file value.
63
+
64
+ ## Validation
65
+
66
+ Every transcript gets a deterministic verdict — `ok` / `review` / `no_speech` / `failed` — written into the sidecar with the individual checks.
67
+
68
+ **Structural:** speech present, repetition/hallucination loops (single-word stutter and multi-word), UTF-8/garble, duration coverage, words-per-second plausibility, language label vs content heuristics.
69
+
70
+ **Cross-check:** a second ASR pass using the language the server itself reported, compared by word-bigram similarity. Stable audio reproduces byte-for-byte, so divergence is a real instability signal rather than noise.
71
+
72
+ Measured on a real 102.9 s Portuguese recording: **verdict `ok`, confidence 1.0, cross-check 100 %, ~4.4 s** total (two passes).
73
+
74
+ ## Sidecar format (`pi-audio-transcript/v1`)
75
+
76
+ ```json
77
+ {
78
+ "schema": "pi-audio-transcript/v1",
79
+ "audio": { "path": "...", "bytes": 1679661, "sha256": "...", "mime": "audio/mp4",
80
+ "durationSeconds": 102.867729, "sampleRateHz": 48000, "channels": 1, "codec": "aac" },
81
+ "transcript": { "text": "Vou gravar aqui as medidas...", "words": 56, "characters": 337, "language": "pt" },
82
+ "validation": { "verdict": "ok", "confidence": 1, "checks": [...], "recommendations": [...],
83
+ "crossCheck": { "performed": true, "similarity": 1 }, "measurements": [...] },
84
+ "provider": { "name": "oMLX", "baseUrl": "...", "model": "Qwen3-ASR-1.7B-8bit", "engine": "audio_stt" }
85
+ }
86
+ ```
87
+
88
+ `sha256` lets a downstream consumer tell a stale sidecar from a fresh one if the audio changes. Writes are atomic (temp + rename), so an interrupted run never leaves a half-written transcript that looks authoritative.
89
+
90
+ `validation.measurements` is **additive derived data** — spelled-out numbers like *"vinte seis centímetros"* are also exposed as `{ "value": 26, "unit": "cm" }`. The `transcript.text` is never rewritten, so nothing you dictated is silently altered.
91
+
92
+ ## Verified server behaviour (this is what shapes the design)
93
+
94
+ These were measured against the live box, not assumed:
95
+
96
+ - **`duration` in the response is inference latency, not audio length.** A 102.87 s clip reported `duration: 1.72` — a 60× error. Real duration comes from ffprobe, falling back to `segments[].end` (which was accurate to 3 decimal places). The sidecar labels this explicitly.
97
+ - **Unsupported input returns HTTP 500** `unsupported file format`, not a 4xx. Files are therefore pre-flight-checked client-side so the message is actionable and no upload is wasted.
98
+ - **`word_timestamps=true` and `response_format` are accepted but ignored.** This extension makes no claim about word-level timing.
99
+ - **`language` casing is inconsistent** — `"Portuguese"` on auto-detect vs `"portuguese"` when hinted. Always normalized.
100
+ - **Silence is honest**: `text: ""`, `language: null`, `segments[].language: "None"` → surfaced as `no_speech`, not an error.
101
+ - **Formats confirmed working:** wav, mp3, m4a, aac, opus (WhatsApp voice notes).
102
+
103
+ ## Known limitations
104
+
105
+ - **Auto-detect was wrong on *synthetic* audio.** A TTS-generated Portuguese clip came back labelled `English` with a mangled transcript. On **real** recorded speech auto-detect was correct and stable, so the default stays auto-detect; the language check plus cross-check are the safety net, and a mismatch emits a `language=pt` recommendation rather than silently overriding you.
106
+ - **Heuristic language detection is shallow** — tuned for pt/en/es plus CJK and Cyrillic scripts. Portuguese and Spanish share markers, so `es`/`pt` can be confused on short clips.
107
+ - **No diarization and no word timestamps** — the server does not provide them.
108
+ - **`extractMeasurements` reads literal phrasing.** *"vinte seis centímetros ponto dois milímetros"* becomes `26 cm` + `2 mm`, not necessarily the `26.2 cm` you may have meant. Treat measurements as hints, not resolved values.
109
+ - **Passing mentions of audio paths get transcribed.** The `input` hook stats every candidate and skips missing/directory/non-audio, but a real path mentioned in passing *will* be transcribed. Set `autoDetect: false` if that is unwanted.
110
+ - Existing sidecars are skipped unless `force` is set, so re-transcribing after a config change needs `--force`.
111
+
112
+ ## Install
113
+
114
+ Published to npm as `@maheidem/pi-audio-transcribe`:
115
+
116
+ ```bash
117
+ pi install npm:@maheidem/pi-audio-transcribe@0.2.0
118
+ ```
119
+
120
+ or install from a local path / add the path to `packages` in `~/.pi/agent/settings.json`, then `/reload`.
121
+
122
+ Requires `ffprobe` (ffmpeg) on PATH for ground-truth duration; without it the extension still works and falls back to `segments[].end`.
123
+
124
+ ## Development
125
+
126
+ ```bash
127
+ npm install
128
+ npm test # 68 offline contracts in isolated HOME directories
129
+ npm run typecheck
130
+ ```
131
+
132
+ Architecture: `pipeline.ts` holds all transcription logic and deliberately imports **no pi**, which is what makes the whole flow testable against a fixture server that reproduces the real oMLX quirks. `settings.ts` owns canonical persistent actions, `ui/audio-panel.ts` derives the view, and `index.ts` remains the thin Pi adapter (tool / command / input hook). `omlx.ts` is the HTTP client, `validate.ts` the pure validation functions, `paths.ts` path parsing, and `sidecar.ts` atomic persistence + ffprobe.
package/config.ts ADDED
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Persistent configuration for audio-transcribe.
3
+ *
4
+ * Precedence (lowest first): defaults < JSON file < environment. The panel
5
+ * edits only the JSON layer and reports active environment overrides.
6
+ */
7
+
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { JsonStore } from "./lib/json-store.ts";
11
+
12
+ export const CONFIG_PATH = join(homedir(), ".pi", "agent", "audio-transcribe.json");
13
+
14
+ export interface TranscribeConfig {
15
+ /** Base URL of the oMLX server, including the /v1 suffix. */
16
+ baseUrl: string;
17
+ /** Optional bearer token sent as Authorization header. */
18
+ apiKey?: string;
19
+ /** Default language hint; null means server auto-detection. */
20
+ language: string | null;
21
+ /** Requested model id; null means discover an audio_stt engine. */
22
+ model: string | null;
23
+ /** Request timeout in milliseconds. */
24
+ timeoutMs: number;
25
+ /** Run a second ASR pass and compare it. */
26
+ crossCheck: boolean;
27
+ /** Auto-transcribe audio paths found in user input. */
28
+ autoDetect: boolean;
29
+ /** Skip files larger than this many bytes (0 = no limit). */
30
+ maxBytes: number;
31
+ /** Overwrite an existing sidecar instead of skipping it. */
32
+ force: boolean;
33
+ }
34
+
35
+ export const DEFAULT_CONFIG: TranscribeConfig = {
36
+ baseUrl: "http://192.168.31.152:8123/v1",
37
+ language: null,
38
+ model: null,
39
+ timeoutMs: 300_000,
40
+ crossCheck: true,
41
+ autoDetect: true,
42
+ maxBytes: 0,
43
+ force: false,
44
+ };
45
+
46
+ function record(value: unknown): Record<string, unknown> {
47
+ return value && typeof value === "object" && !Array.isArray(value)
48
+ ? (value as Record<string, unknown>)
49
+ : {};
50
+ }
51
+
52
+ function coerceBoolean(value: unknown, fallback: boolean): boolean {
53
+ if (typeof value === "boolean") return value;
54
+ if (typeof value === "string") {
55
+ const normalized = value.trim().toLowerCase();
56
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
57
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
58
+ }
59
+ return fallback;
60
+ }
61
+
62
+ function coerceNumber(value: unknown, fallback: number): number {
63
+ const parsed = typeof value === "string" ? Number(value) : value;
64
+ return typeof parsed === "number" && Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
65
+ }
66
+
67
+ /** Normalize a base URL to end with `/v1` and no trailing slash. */
68
+ export function normalizeBaseUrl(input: string): string {
69
+ let url = input.trim().replace(/\/+$/, "");
70
+ if (!url) return DEFAULT_CONFIG.baseUrl;
71
+ if (!/^https?:\/\//i.test(url)) url = `http://${url}`;
72
+ if (!/\/v1$/.test(url)) url = `${url}/v1`;
73
+ return url;
74
+ }
75
+
76
+ export function normalizeStoredConfig(
77
+ value: unknown,
78
+ defaults: TranscribeConfig = DEFAULT_CONFIG,
79
+ ): TranscribeConfig {
80
+ const input = record(value);
81
+ const language = input.language === null
82
+ ? null
83
+ : typeof input.language === "string"
84
+ ? (input.language.trim() && input.language.trim().toLowerCase() !== "auto" ? input.language.trim() : null)
85
+ : defaults.language;
86
+ const model = input.model === null
87
+ ? null
88
+ : typeof input.model === "string"
89
+ ? (input.model.trim() || null)
90
+ : defaults.model;
91
+ const apiKey = typeof input.apiKey === "string" && input.apiKey.trim()
92
+ ? input.apiKey.trim()
93
+ : defaults.apiKey;
94
+
95
+ return {
96
+ baseUrl: typeof input.baseUrl === "string" ? normalizeBaseUrl(input.baseUrl) : defaults.baseUrl,
97
+ apiKey,
98
+ language,
99
+ model,
100
+ timeoutMs: coerceNumber(input.timeoutMs, defaults.timeoutMs),
101
+ crossCheck: coerceBoolean(input.crossCheck, defaults.crossCheck),
102
+ autoDetect: coerceBoolean(input.autoDetect, defaults.autoDetect),
103
+ maxBytes: coerceNumber(input.maxBytes, defaults.maxBytes),
104
+ force: coerceBoolean(input.force, defaults.force),
105
+ };
106
+ }
107
+
108
+ function storeFor(path: string): JsonStore<TranscribeConfig> {
109
+ return new JsonStore<TranscribeConfig>({
110
+ path,
111
+ defaults: DEFAULT_CONFIG,
112
+ normalize: normalizeStoredConfig,
113
+ });
114
+ }
115
+
116
+ /** Read the persistent JSON layer, writing discoverable defaults if absent. */
117
+ export function loadStoredConfig(path: string = CONFIG_PATH): TranscribeConfig {
118
+ return storeFor(path).load();
119
+ }
120
+
121
+ export function saveConfig(config: TranscribeConfig, path: string = CONFIG_PATH): TranscribeConfig {
122
+ return storeFor(path).save(config);
123
+ }
124
+
125
+ export function resetConfig(path: string = CONFIG_PATH): TranscribeConfig {
126
+ return storeFor(path).reset();
127
+ }
128
+
129
+ function fromEnv(env: NodeJS.ProcessEnv): Partial<TranscribeConfig> {
130
+ const output: Partial<TranscribeConfig> = {};
131
+ const base = env.OMLX_BASE_URL ?? env.AUDIO_TRANSCRIBE_BASE_URL;
132
+ if (base) output.baseUrl = normalizeBaseUrl(base);
133
+ const key = env.OMLX_API_KEY ?? env.AUDIO_TRANSCRIBE_API_KEY;
134
+ if (key) output.apiKey = key;
135
+ if (env.AUDIO_TRANSCRIBE_MODEL !== undefined) output.model = env.AUDIO_TRANSCRIBE_MODEL.trim() || null;
136
+ if (env.AUDIO_TRANSCRIBE_LANGUAGE !== undefined) {
137
+ const value = env.AUDIO_TRANSCRIBE_LANGUAGE.trim();
138
+ output.language = !value || value.toLowerCase() === "auto" ? null : value;
139
+ }
140
+ if (env.AUDIO_TRANSCRIBE_TIMEOUT_MS !== undefined) {
141
+ output.timeoutMs = coerceNumber(env.AUDIO_TRANSCRIBE_TIMEOUT_MS, DEFAULT_CONFIG.timeoutMs);
142
+ }
143
+ if (env.AUDIO_TRANSCRIBE_CROSSCHECK !== undefined) {
144
+ output.crossCheck = coerceBoolean(env.AUDIO_TRANSCRIBE_CROSSCHECK, DEFAULT_CONFIG.crossCheck);
145
+ }
146
+ if (env.AUDIO_TRANSCRIBE_AUTODETECT !== undefined) {
147
+ output.autoDetect = coerceBoolean(env.AUDIO_TRANSCRIBE_AUTODETECT, DEFAULT_CONFIG.autoDetect);
148
+ }
149
+ if (env.AUDIO_TRANSCRIBE_MAX_BYTES !== undefined) {
150
+ output.maxBytes = coerceNumber(env.AUDIO_TRANSCRIBE_MAX_BYTES, DEFAULT_CONFIG.maxBytes);
151
+ }
152
+ return output;
153
+ }
154
+
155
+ export function environmentOverrideKeys(env: NodeJS.ProcessEnv = process.env): string[] {
156
+ const keys: string[] = [];
157
+ if (env.OMLX_BASE_URL !== undefined || env.AUDIO_TRANSCRIBE_BASE_URL !== undefined) keys.push("server");
158
+ if (env.OMLX_API_KEY !== undefined || env.AUDIO_TRANSCRIBE_API_KEY !== undefined) keys.push("API key");
159
+ if (env.AUDIO_TRANSCRIBE_MODEL !== undefined) keys.push("model");
160
+ if (env.AUDIO_TRANSCRIBE_LANGUAGE !== undefined) keys.push("language");
161
+ if (env.AUDIO_TRANSCRIBE_TIMEOUT_MS !== undefined) keys.push("timeout");
162
+ if (env.AUDIO_TRANSCRIBE_CROSSCHECK !== undefined) keys.push("cross-check");
163
+ if (env.AUDIO_TRANSCRIBE_AUTODETECT !== undefined) keys.push("input hook");
164
+ if (env.AUDIO_TRANSCRIBE_MAX_BYTES !== undefined) keys.push("max file size");
165
+ return keys;
166
+ }
167
+
168
+ /** Resolve defaults < persistent file < environment. */
169
+ export function resolveConfig(
170
+ env: NodeJS.ProcessEnv = process.env,
171
+ configPath: string = CONFIG_PATH,
172
+ ): TranscribeConfig {
173
+ return { ...loadStoredConfig(configPath), ...fromEnv(env) };
174
+ }
package/index.ts ADDED
@@ -0,0 +1,322 @@
1
+ /**
2
+ * audio-transcribe — oMLX speech-to-text for pi.
3
+ *
4
+ * Give pi an audio file, get a validated transcript persisted beside it.
5
+ *
6
+ * tool transcribe_audio — audio paths or directories
7
+ * command /transcribe — direct invocation; /transcribe --status for health
8
+ * hook input — auto-transcribes audio paths pasted into chat
9
+ *
10
+ * All the work lives in pipeline.ts (pi-free, unit-tested); this module only
11
+ * binds it to pi's tool / command / input surfaces.
12
+ *
13
+ * Server facts this is built against (oMLX 0.6.4, verified live on
14
+ * 192.168.31.152:8123 with Qwen3-ASR-1.7B-8bit):
15
+ * - `duration` in the response is INFERENCE LATENCY, not audio length
16
+ * (a 102.87 s clip reported 1.72). Real duration = ffprobe / segments[].end.
17
+ * - unsupported input returns HTTP 500, so file type is pre-checked here.
18
+ * - `word_timestamps` and `response_format` are accepted but ignored.
19
+ * - `language` casing varies ("Portuguese" auto vs "portuguese" hinted).
20
+ */
21
+
22
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
23
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
24
+ import { Type } from "typebox";
25
+
26
+ import { resolveConfig, type TranscribeConfig } from "./config.ts";
27
+ import { isAudioPath, listSttModels, serverHealth, type SttModel } from "./omlx.ts";
28
+ import { extractAudioPaths, expandPath, tokenizeArgs } from "./paths.ts";
29
+ import { expandInputs, formatSummary, resolveSttModel, transcribeOne, type FileResult, type PipelineCtx, type Setup } from "./pipeline.ts";
30
+ import { AudioSettingsController, type AudioSettingKey } from "./settings.ts";
31
+ import { buildAudioPanelSnapshot } from "./ui/audio-panel.ts";
32
+ import { SettingsPanel, type PanelResult } from "./ui/settings-panel.ts";
33
+
34
+ const TAG = "[audio-transcribe]";
35
+
36
+ /**
37
+ * Mode-aware output. `ctx.ui.notify` is a no-op in print/JSON mode
38
+ * (ctx.hasUI === false), so headless runs would silently lose every message.
39
+ */
40
+ function say(ctx: ExtensionContext, msg: string, level: "info" | "warning" | "error" = "info"): void {
41
+ if (ctx.hasUI) ctx.ui.notify(msg, level);
42
+ // eslint-disable-next-line no-console
43
+ else console.log(msg);
44
+ }
45
+
46
+ /** Adapt pi's ctx + exec into the pi-free pipeline context. */
47
+ function hostCtx(ctx: ExtensionContext, exec: (c: string, a: string[], o?: { signal?: AbortSignal }) => Promise<{ stdout: string; code: number }>, onProgress?: (m: string) => void): PipelineCtx {
48
+ return {
49
+ cwd: ctx.cwd,
50
+ exec,
51
+ signal: ctx.signal,
52
+ onProgress,
53
+ withQueue: (p, fn) => withFileMutationQueue(p, fn),
54
+ };
55
+ }
56
+
57
+ /** Merge per-call overrides onto the resolved config, ignoring undefined. */
58
+ function mergedConfig(overrides: Partial<TranscribeConfig>): TranscribeConfig {
59
+ const out: TranscribeConfig = { ...resolveConfig() };
60
+ if (overrides.language !== undefined) out.language = overrides.language;
61
+ if (overrides.model !== undefined) out.model = overrides.model;
62
+ if (overrides.crossCheck !== undefined) out.crossCheck = overrides.crossCheck;
63
+ if (overrides.force !== undefined) out.force = overrides.force;
64
+ if (overrides.baseUrl !== undefined) out.baseUrl = overrides.baseUrl;
65
+ if (overrides.apiKey !== undefined) out.apiKey = overrides.apiKey;
66
+ if (overrides.timeoutMs !== undefined) out.timeoutMs = overrides.timeoutMs;
67
+ if (overrides.autoDetect !== undefined) out.autoDetect = overrides.autoDetect;
68
+ if (overrides.maxBytes !== undefined) out.maxBytes = overrides.maxBytes;
69
+ return out;
70
+ }
71
+
72
+ export default function (pi: ExtensionAPI) {
73
+ // Cache model discovery briefly so a batch does not re-query the server.
74
+ let cached: Setup | null = null;
75
+ let cachedAt = 0;
76
+ async function getSetup(force = false): Promise<Setup> {
77
+ if (cached && !force && Date.now() - cachedAt < 60_000) return cached;
78
+ cached = await resolveSttModel(resolveConfig());
79
+ cachedAt = Date.now();
80
+ return cached;
81
+ }
82
+
83
+ async function reportStatus(ctx: ExtensionContext): Promise<void> {
84
+ const cfg = resolveConfig();
85
+ const health = await serverHealth(cfg.baseUrl, cfg.apiKey, 8_000, ctx.signal);
86
+ const setup = await getSetup(true);
87
+ const models = health.reachable
88
+ ? await listSttModels(cfg.baseUrl, cfg.apiKey, 8_000, ctx.signal).catch(() => [] as SttModel[])
89
+ : [];
90
+ say(
91
+ ctx,
92
+ [
93
+ `${TAG} status`,
94
+ ` server: ${cfg.baseUrl}`,
95
+ ` status: ${health.reachable ? `ok (oMLX ${health.version})` : `UNREACHABLE — ${health.error}`}`,
96
+ ` model: ${setup.model ?? "none discovered"}`,
97
+ ` resident: ${setup.modelInfo?.loaded ? "yes" : "no (cold load on first use)"}`,
98
+ ` language: ${cfg.language ?? "auto-detect (default)"}`,
99
+ ` cross-check: ${cfg.crossCheck ? "on" : "off"}`,
100
+ ` input hook: ${cfg.autoDetect ? "on" : "off"}`,
101
+ ` stt models: ${models.length ? models.map((model) => `${model.id}${model.loaded ? " *" : ""}`).join(", ") : "none"}`,
102
+ ].join("\n"),
103
+ health.reachable ? "info" : "error",
104
+ );
105
+ }
106
+
107
+ async function runTranscription(raw: string, ctx: ExtensionCommandContext): Promise<void> {
108
+ const overrides: Partial<TranscribeConfig> = {};
109
+ const paths: string[] = [];
110
+ let recursive = false;
111
+ const tokens = tokenizeArgs(raw);
112
+ for (let index = 0; index < tokens.length; index++) {
113
+ const token = tokens[index]!;
114
+ if (token === "--no-crosscheck") overrides.crossCheck = false;
115
+ else if (token === "--force") overrides.force = true;
116
+ else if (token === "--recursive") recursive = true;
117
+ else if (token.startsWith("--lang=")) overrides.language = token.slice(7);
118
+ else if (token.startsWith("--model=")) overrides.model = token.slice(8);
119
+ else if (token === "--lang") overrides.language = tokens[++index] ?? null;
120
+ else if (token === "--model") overrides.model = tokens[++index] ?? null;
121
+ else paths.push(expandPath(token, ctx.cwd));
122
+ }
123
+
124
+ const effective = mergedConfig(overrides);
125
+ let setup = await getSetup(Boolean(overrides.model));
126
+ setup = { ...setup, cfg: effective };
127
+ const files = await expandInputs(paths, ctx.cwd, recursive);
128
+ if (!files.length) {
129
+ say(ctx, `${TAG} no audio files matched: ${paths.join(", ") || "(no path given)"}`, "error");
130
+ return;
131
+ }
132
+
133
+ say(ctx, `${TAG} transcribing ${files.length} file(s) via ${setup.model ?? "…"}`, "info");
134
+ const host = hostCtx(ctx, (command, args, options) => pi.exec(command, args, options), (message) => say(ctx, `${TAG} ${message}`, "info"));
135
+ const results: FileResult[] = [];
136
+ for (const file of files) {
137
+ if (ctx.signal?.aborted) {
138
+ results.push({ input: file, audioPath: file, sidecarPath: null, skipped: true, skipReason: "cancelled" });
139
+ continue;
140
+ }
141
+ results.push(await transcribeOne(file, setup, host));
142
+ }
143
+ const done = results.filter((result) => !result.skipped && !result.error);
144
+ say(ctx, `${TAG}\n${formatSummary(results, setup, ctx.cwd)}`, "info");
145
+ for (const result of done) say(ctx, `\n--- ${result.audioPath.split("/").pop()} ---\n${result.text}`, "info");
146
+ }
147
+
148
+ async function openPanel(ctx: ExtensionCommandContext): Promise<void> {
149
+ if (ctx.mode !== "tui") {
150
+ await reportStatus(ctx);
151
+ return;
152
+ }
153
+
154
+ const controller = new AudioSettingsController();
155
+ let initialKey = "autoDetect";
156
+ for (;;) {
157
+ const result = await ctx.ui.custom<PanelResult>(
158
+ (tui, theme, keybindings, done) => new SettingsPanel({
159
+ theme,
160
+ keybindings,
161
+ initialKey,
162
+ snapshot: () => buildAudioPanelSnapshot(controller),
163
+ apply: (key, rawValue) => {
164
+ const error = controller.apply(key as AudioSettingKey, rawValue);
165
+ if (!error) cached = null;
166
+ return error;
167
+ },
168
+ activate: (key) => ["transcribe", "status", "reset"].includes(key)
169
+ ? { kind: "close", action: key }
170
+ : { kind: "error", message: `Unknown audio action: ${key}` },
171
+ requestRender: () => tui.requestRender(),
172
+ done,
173
+ }),
174
+ {
175
+ overlay: true,
176
+ overlayOptions: {
177
+ anchor: "center",
178
+ width: 78,
179
+ minWidth: 44,
180
+ maxHeight: "90%",
181
+ margin: 1,
182
+ },
183
+ },
184
+ );
185
+
186
+ if (!result?.action) return;
187
+ if (result.action === "status") {
188
+ await reportStatus(ctx);
189
+ initialKey = "status";
190
+ continue;
191
+ }
192
+ if (result.action === "transcribe") {
193
+ const path = await ctx.ui.input("Audio file or directory", "");
194
+ if (path?.trim()) await runTranscription(JSON.stringify(path.trim()), ctx);
195
+ return;
196
+ }
197
+ if (result.action === "reset") {
198
+ const confirmed = await ctx.ui.confirm("Reset Audio Transcribe?", "Restore every file-backed setting to its shipped default?");
199
+ if (confirmed) {
200
+ controller.reset();
201
+ cached = null;
202
+ say(ctx, `${TAG} settings reset to defaults.`);
203
+ }
204
+ initialKey = "reset";
205
+ }
206
+ }
207
+ }
208
+
209
+ pi.registerTool({
210
+ name: "transcribe_audio",
211
+ label: "Transcribe audio",
212
+ description:
213
+ "Transcribe audio files with the oMLX STT server (Qwen3-ASR), validate the speech, and persist a JSON sidecar beside each file as <name>.transcript.json. Accepts audio file paths, or directories whose audio files are transcribed. Use this whenever the user references an audio file, recording, or voice note and wants its spoken content — the read tool cannot open audio.",
214
+ promptSnippet: "Transcribe audio via oMLX STT with validation + JSON transcript sidecar beside the file",
215
+ promptGuidelines: [
216
+ "Use transcribe_audio for any audio file the user references instead of the read tool; it returns the transcript text and writes <name>.transcript.json next to the audio.",
217
+ "transcribe_audio defaults to server-side language auto-detection; pass language only when the user names one, or when a 'review' verdict recommends pinning it.",
218
+ "If transcribe_audio returns verdict 'review' or 'failed', report the reason and recommendations to the user instead of presenting the transcript as fact.",
219
+ "transcribe_audio skips files whose sidecar already exists unless force is true; re-read the existing .transcript.json before assuming a file was transcribed fresh.",
220
+ ],
221
+ parameters: Type.Object({
222
+ paths: Type.Array(Type.String(), { description: "Audio file paths, or directories containing audio" }),
223
+ language: Type.Optional(Type.String({ description: "ISO language hint such as 'pt' or 'en'. Omit for server auto-detect (the default)." })),
224
+ model: Type.Optional(Type.String({ description: "Override the STT model id. Omit to auto-discover the server's audio_stt engine." })),
225
+ crossCheck: Type.Optional(Type.Boolean({ description: "Re-transcribe and compare to measure stability. Defaults to config (true)." })),
226
+ force: Type.Optional(Type.Boolean({ description: "Overwrite existing sidecars. Default false." })),
227
+ recursive: Type.Optional(Type.Boolean({ description: "Recurse into directories. Default false." })),
228
+ }),
229
+
230
+ async execute(_id, params, signal, onUpdate, ctx) {
231
+ const cfg = mergedConfig({
232
+ ...(params.language !== undefined ? { language: params.language } : {}),
233
+ ...(params.model !== undefined ? { model: params.model } : {}),
234
+ ...(params.crossCheck !== undefined ? { crossCheck: params.crossCheck } : {}),
235
+ ...(params.force !== undefined ? { force: params.force } : {}),
236
+ });
237
+ let setup = await getSetup(Boolean(params.model));
238
+ setup = { ...setup, cfg };
239
+
240
+ const files = await expandInputs(params.paths, ctx.cwd, params.recursive ?? false);
241
+ if (!files.length) {
242
+ const h = await serverHealth(cfg.baseUrl, cfg.apiKey, 8_000, signal);
243
+ throw new Error(
244
+ `No audio files matched ${JSON.stringify(params.paths)}. ` +
245
+ (h.reachable ? `Server reachable (oMLX ${h.version}); STT model: ${setup.model ?? "none exposed"}.` : `Server unreachable: ${h.error}`),
246
+ );
247
+ }
248
+
249
+ const host = hostCtx(ctx, (c, a, o) => pi.exec(c, a, o), (m) => onUpdate?.({ content: [{ type: "text", text: m }], details: {} }));
250
+ const results: FileResult[] = [];
251
+ for (let i = 0; i < files.length; i++) {
252
+ if (signal?.aborted) {
253
+ results.push({ input: files[i]!, audioPath: files[i]!, sidecarPath: null, skipped: true, skipReason: "cancelled" });
254
+ continue;
255
+ }
256
+ onUpdate?.({ content: [{ type: "text", text: `${i + 1}/${files.length} ${files[i]!.split("/").pop()}` }], details: { index: i, total: files.length } });
257
+ results.push(await transcribeOne(files[i]!, setup, host));
258
+ }
259
+
260
+ const summary = formatSummary(results, setup, ctx.cwd);
261
+ const done = results.filter((r) => !r.skipped && !r.error);
262
+ const body = done.length
263
+ ? `${summary}\n\n--- TRANSCRIPTS ---\n${done.map((r) => `## ${(r.audioPath.split("/").pop() ?? r.audioPath)} [${r.verdict}, ${((r.confidence ?? 0) * 100).toFixed(0)}%, ${r.language ?? "?"}]\n${r.text}`).join("\n\n")}`
264
+ : summary;
265
+ return {
266
+ content: [{ type: "text", text: body }],
267
+ details: { model: setup.model, results: results.map(({ text, ...rest }) => rest) },
268
+ };
269
+ },
270
+ });
271
+
272
+ pi.registerCommand("transcribe", {
273
+ description: "Open audio-transcription controls or transcribe with /transcribe <path|dir> [flags]",
274
+ getArgumentCompletions: (prefix: string) => {
275
+ const options = ["--status", "--lang ", "--model ", "--no-crosscheck", "--force", "--recursive "]
276
+ .filter((option) => option.startsWith(prefix));
277
+ return options.length ? options.map((value) => ({ value, label: value })) : null;
278
+ },
279
+ async handler(args, ctx) {
280
+ const raw = (args ?? "").trim();
281
+ if (!raw) {
282
+ await openPanel(ctx);
283
+ return;
284
+ }
285
+ if (raw === "--status") {
286
+ await reportStatus(ctx);
287
+ return;
288
+ }
289
+ await runTranscription(raw, ctx);
290
+ },
291
+ });
292
+
293
+ // Auto-transcribe audio paths pasted into chat and hand the transcript to
294
+ // the model inline, so audio never reaches the model as an opaque path.
295
+ pi.on("input", async (event, ctx) => {
296
+ if (!resolveConfig().autoDetect) return { action: "continue" as const };
297
+ if (event.source === "extension") return { action: "continue" as const }; // avoid self-triggering
298
+ const found = extractAudioPaths(event.text ?? "");
299
+ if (!found.length) return { action: "continue" as const };
300
+
301
+ const setup = await getSetup();
302
+ if (!setup.model) return { action: "continue" as const };
303
+
304
+ const blocks: string[] = [];
305
+ for (const p of found) {
306
+ const abs = expandPath(p, ctx.cwd);
307
+ const st = await (await import("node:fs/promises")).stat(abs).catch(() => null);
308
+ if (!st || st.isDirectory() || !isAudioPath(abs)) continue; // ignore passing mentions of absent files
309
+ say(ctx, `${TAG} auto-transcribing ${abs.split("/").pop()}`, "info");
310
+ const r = await transcribeOne(abs, setup, hostCtx(ctx, (c, a, o) => pi.exec(c, a, o)));
311
+ if (r.error) blocks.push(`[audio-transcribe] ${abs}: FAILED — ${r.error}`);
312
+ else if (r.skipped) blocks.push(`[audio-transcribe] ${abs}: skipped — ${r.skipReason}`);
313
+ else {
314
+ const head = `[audio-transcribe] ${abs}\nverdict=${r.verdict} confidence=${((r.confidence ?? 0) * 100).toFixed(0)}% language=${r.language ?? "?"} duration=${r.durationSeconds?.toFixed(1) ?? "?"}s sidecar=${r.sidecarPath}`;
315
+ blocks.push(`${head}\ntranscript:\n${r.text}`);
316
+ if (r.recommendations?.length) blocks.push(`advice: ${r.recommendations.join(" | ")}`);
317
+ }
318
+ }
319
+ if (!blocks.length) return { action: "continue" as const };
320
+ return { action: "transform" as const, text: `${event.text}\n\n${blocks.join("\n\n")}` };
321
+ });
322
+ }