@xynogen/pix-runtime 0.10.1 → 0.12.2

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xynogen
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.
package/README.md CHANGED
@@ -17,17 +17,12 @@ See `DESIGN.md` for the full contract.
17
17
  - Typed, path-filtered change events.
18
18
  - One-time migration of legacy unversioned config and the `optimizer.json`
19
19
  sidecar.
20
- - The `/pix` shared-settings command.
21
-
22
- ## Install
23
-
24
- ```bash
25
- pi install npm:@xynogen/pix-runtime
26
- ```
27
-
28
- Standalone-installable: importing an accessor lazily creates the singleton even
29
- without the extension factory. Installed via `pix-core` it registers `/pix` and
30
- session hooks once.
20
+ - The `/pix` shared-settings command, with **Settings** and **Binaries** tabs.
21
+ - One catalog, resolver and downloader for every external command pix runs
22
+ (`binaries`), plus shared `paths` and `platform` helpers.
23
+ - **User shell** — user `!` commands run through PowerShell on Windows (pwsh 7,
24
+ else 5.1), and through `$SHELL` on Linux/macOS (zsh sources `.zshrc` so
25
+ aliases expand).
31
26
 
32
27
  ## Usage
33
28
 
@@ -74,6 +69,176 @@ Collapse policy helpers:
74
69
  import { shouldCollapse, collapseDelayMs } from "@xynogen/pix-runtime/collapse";
75
70
  ```
76
71
 
72
+ ## Paths and platform
73
+
74
+ ```ts
75
+ import { agentDir, binDir, cacheDir, homeDir, projectDir, tempDir } from "@xynogen/pix-runtime/paths";
76
+ import { currentPlatform, hostPlatform } from "@xynogen/pix-runtime/platform";
77
+
78
+ agentDir(); // PI_CODING_AGENT_DIR (~-expanded) or ~/.pi/agent, same as Pi's getAgentDir
79
+ binDir(); // <agentDir>/bin, the folder where Pi downloads fd/rg and pix downloads its tools
80
+ cacheDir(); // $XDG_CACHE_HOME/pi or ~/.cache/pi (never relies on HOME alone)
81
+ projectDir(cwd); // <cwd>/.pi, Pi's project config dir. projectDir() gives the relative .pi
82
+ tempDir(); // os.tmpdir(): TMPDIR on POSIX, TEMP/TMP on Windows. Shared, so delete only your own entries
83
+ currentPlatform(); // { os, arch, libc?, wsl, termux, exe }
84
+ ```
85
+
86
+ Every helper takes an optional env, so tests can use a temporary agent dir.
87
+
88
+ ## Binaries
89
+
90
+ Every external command a pix package runs is listed in one catalog
91
+ (`src/binaries/catalog.ts`). The catalog records which packages use each
92
+ command, which OS needs it, an install hint, and, for a few commands, a trusted
93
+ GitHub release.
94
+
95
+ ```ts
96
+ import { ensureTool, requireTool, resolveTool } from "@xynogen/pix-runtime/binaries";
97
+
98
+ resolveTool("git"); // sync, no network: { path, source } | undefined
99
+ requireTool("ssh"); // same, but throws BinaryMissingError with the install hint
100
+ await ensureTool("hunk", { onStatus }); // downloads into <agentDir>/bin when missing
101
+ ```
102
+
103
+ **Resolve order:** `binary.json` path → `<agentDir>/bin` → known install dirs (`system`, e.g. Git Bash on Windows) → PATH. `ensureTool`
104
+ then downloads if the catalog has a release for this host.
105
+
106
+ If `binary.json` names a file that doesn't exist, the entry is **broken**. pix
107
+ never silently falls back to another copy.
108
+
109
+ | Downloaded when missing | Source | Checksum |
110
+ |---|---|---|
111
+ | `rtk` (Windows, Linux, macOS) | `rtk-ai/rtk` latest release | `checksums.txt` |
112
+ | `hunk` (Windows, Linux, macOS) | `modem-dev/hunk` latest release | `SHA256SUMS` |
113
+ | `aria2c` (Windows only) | official `aria2/aria2` release | none published |
114
+ | `ffmpeg` (Windows, Linux, ~120 MB) | `BtbN/FFmpeg-Builds` lgpl | `checksums.sha256` |
115
+
116
+ Everything else, including `rg`, is only checked and never downloaded.
117
+ Pi itself downloads `rg` and `fd`. pix does not run `fd`, so the catalog does not list it.
118
+
119
+ The downloader:
120
+
121
+ - finds the latest version through the `/releases/latest` redirect;
122
+ - extracts with the system `tar`/`unzip`;
123
+ - works in a unique temp folder and cleans it up afterwards;
124
+ - shares one download between concurrent calls for the same binary;
125
+ - respects `PI_OFFLINE`.
126
+
127
+ Progress is reported only through `onStatus`. Use
128
+ `reportToolStatus(ctx.ui)` from `@xynogen/pix-pretty/tool-status` so every
129
+ package shows downloads the same way.
130
+
131
+ ### Running a binary — `./exec`
132
+
133
+ Packages never start a catalogued binary by bare name. `./exec` resolves it
134
+ through the order above, then runs it:
135
+
136
+ ```ts
137
+ import { runTool, runToolSync, spawnTool } from "@xynogen/pix-runtime/exec";
138
+
139
+ const r = await runTool("git", ["status", "--porcelain"], { cwd, timeoutMs: 2_000 });
140
+ // { code, stdout, stderr, stdoutBytes, timedOut, tool: { path, source } }
141
+
142
+ const child = spawnTool("ssh", args, { stdio: ["ignore", "pipe", "pipe"] }); // Node ChildProcess
143
+ const sync = runToolSync("npm", ["root", "-g"], { timeoutMs: 10_000 });
144
+ ```
145
+
146
+ - `runTool` downloads first when the binary is missing and has a recipe
147
+ (progress via `onStatus`). `spawnTool` and `runToolSync` never download.
148
+ - A missing binary throws `BinaryMissingError` with the install hint. A
149
+ non-zero exit resolves normally, so check `code`.
150
+ - Windows `.cmd`/`.bat` shims (`npm`, `npx`, `pi`) run through
151
+ `cmd.exe /d /s /c` with strict quoting, since Node cannot spawn them
152
+ directly. Spaces, quotes, `&|<>^%` and parentheses survive intact.
153
+ - Timeouts and aborts kill the whole process tree on Windows
154
+ (`taskkill /T`), so a wrapped shim does not keep running.
155
+
156
+ ### OS jobs — `./os`
157
+
158
+ Jobs that need a different program on each OS sit behind one call. Each
159
+ program still resolves through `binary.json`:
160
+
161
+ ```ts
162
+ import { openTarget, readClipboardImage, runGit } from "@xynogen/pix-runtime/os";
163
+
164
+ await openTarget(url, { app: process.env.BROWSER }); // open / cmd start / xdg-open / wslview
165
+ const img = readClipboardImage(); // PowerShell (Windows, WSL) / wl-paste / xclip
166
+ const branch = await runGit(["branch", "--show-current"], { cwd }); // stdout | null
167
+ ```
168
+
169
+ `runGit` returns `null` for any git failure (not a repo, timeout, abort) but
170
+ rejects with `BinaryMissingError` when git itself is missing. Background
171
+ features pass that to `warnBinaryMissing(ctx.ui, err)` from
172
+ `@xynogen/pix-pretty/tool-status`, which shows one warning per binary per
173
+ session, pointing at the `/pix` Binaries tab.
174
+
175
+ `scripts/binaries.test.ts` fails CI when a package starts a catalogued binary
176
+ by bare name.
177
+
178
+ ### Audio — `./audio`
179
+
180
+ One job per function. Callers never see a program name or an OS branch.
181
+ ffmpeg does every job it can on Linux (PulseAudio/PipeWire), macOS
182
+ (AVFoundation/AudioToolbox) and Windows (DirectShow). ffmpeg has no audio
183
+ output on Windows, so playback there uses the built-in PowerShell MediaPlayer.
184
+
185
+ ```ts
186
+ import { listMicrophones, playAudio, startRecording } from "@xynogen/pix-runtime/audio";
187
+
188
+ const mics = await listMicrophones(); // first entry is "default". No ffmpeg: only "default"
189
+ const rec = startRecording("default", { onLevel, onExit, onStatus }); // mono 16 kHz wav
190
+ await rec.stop(); // rec.path is the wav. { meterOnly: true } writes nothing
191
+ await playAudio(file, { signal }); // resolves when the sound ends
192
+ ```
193
+
194
+ ### Safe output paths — `./safe-path`
195
+
196
+ `validateOutputPath(absPath)` checks a model-chosen write target before the
197
+ write. It rejects null bytes, system dirs (`/etc`, `/proc` … or
198
+ `C:\Windows`, `Program Files`), secret dirs under home (`.ssh`, `.aws`,
199
+ `.gnupg`, GitHub CLI), symlinks and Windows junctions, existing directories,
200
+ and paths with no writable ancestor. Matching is per path segment, and
201
+ case-insensitive on Windows.
202
+
203
+ ### `~/.pi/agent/binary.json`
204
+
205
+ This file is user configuration. It holds only the binaries you override. The
206
+ file does not exist until you set a path. The `/pix` Binaries tab shows the
207
+ full catalog of what pix depends on.
208
+
209
+ ```json
210
+ {
211
+ "$version": 1,
212
+ "ffmpeg": "D:/tools/ffmpeg/bin/ffmpeg.exe"
213
+ }
214
+ ```
215
+
216
+ - A missing entry (or `null`) means automatic: pix looks in `bin`, then known install dirs, then PATH, then downloads.
217
+ - A path means pix always uses exactly that file.
218
+
219
+ pix writes to the file only in two cases:
220
+
221
+ - when you edit or reset a path in the `/pix` Binaries tab (reset removes the entry);
222
+ - to remove legacy `null` entries for catalog binaries from an older file.
223
+
224
+ Paths pix finds on its own are never written to it. Keys pix doesn't know are
225
+ kept. If the file contains invalid JSON, pix reports it and leaves the file
226
+ unchanged.
227
+
228
+ ### `/pix` → Binaries tab
229
+
230
+ Press **Tab** / **Shift+Tab** to switch between Settings and Binaries. Each row
231
+ shows a status icon with a text label (ok / missing / broken / not used on this
232
+ OS), the resolved path, where it was found, and its version. Selecting a row
233
+ also shows which packages use it.
234
+
235
+ | Key | Action |
236
+ |---|---|
237
+ | **enter** | install (downloadable, missing) or re-check |
238
+ | **e** | set a path (saved to `binary.json`) |
239
+ | **d** | reset the entry to automatic (removes it from `binary.json`) |
240
+ | **r** | re-check all entries |
241
+
77
242
  ## Agent state and herdr notifications
78
243
 
79
244
  ### Agent-state coordinator
@@ -160,3 +325,33 @@ const { runtime, cleanup } = createIsolatedRuntime();
160
325
  // ... exercise runtime against a temp agent dir ...
161
326
  cleanup();
162
327
  ```
328
+
329
+ ## Install
330
+
331
+ ```bash
332
+ pi install npm:@xynogen/pix-runtime
333
+ ```
334
+
335
+ > Foundation library. Feature packages install it as a dependency. Install it directly only when you build your own extension on it.
336
+
337
+ Standalone-installable: importing an accessor lazily creates the singleton even
338
+ without the extension factory. Installed via `pix-core` it registers `/pix` and
339
+ session hooks once.
340
+
341
+ ## Full distro
342
+
343
+ This package is part of [Pix](https://github.com/xynogen/pix-mono). The installer sets up Pi and the full distro. See [Install](https://github.com/xynogen/pix-mono#install) for the notes for each OS.
344
+
345
+ ```bash
346
+ # Linux / macOS
347
+ curl -fsSL https://raw.githubusercontent.com/xynogen/pix-mono/main/scripts/install.sh | sh
348
+ ```
349
+
350
+ ```powershell
351
+ # Windows
352
+ irm https://raw.githubusercontent.com/xynogen/pix-mono/main/scripts/install.ps1 | iex
353
+ ```
354
+
355
+ ## License
356
+
357
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-runtime",
3
- "version": "0.10.1",
3
+ "version": "0.12.2",
4
4
  "description": "Pix shared runtime — versioned pix.json config, atomic persistence, typed change events",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -22,6 +22,7 @@
22
22
  "exports": {
23
23
  ".": "./src/index.ts",
24
24
  "./atomic-write": "./src/atomic-write.ts",
25
+ "./binaries": "./src/binaries/index.ts",
25
26
  "./config": "./src/runtime.ts",
26
27
  "./sections": "./src/sections/index.ts",
27
28
  "./collapse": "./src/collapse.ts",
@@ -29,7 +30,13 @@
29
30
  "./io": "./src/io.ts",
30
31
  "./lfid": "./src/lfid.ts",
31
32
  "./once": "./src/once.ts",
33
+ "./paths": "./src/paths.ts",
34
+ "./platform": "./src/platform.ts",
32
35
  "./testing": "./src/testing.ts",
36
+ "./exec": "./src/exec.ts",
37
+ "./os": "./src/os.ts",
38
+ "./audio": "./src/audio.ts",
39
+ "./safe-path": "./src/safe-path.ts",
33
40
  "./which": "./src/which.ts"
34
41
  },
35
42
  "keywords": [
package/src/audio.ts ADDED
@@ -0,0 +1,298 @@
1
+ /**
2
+ * audio.ts — microphones, recording and playback, one job per function.
3
+ *
4
+ * Packages ask for the job ("list microphones", "record", "play this file") and
5
+ * never see a program name or an OS branch. ffmpeg does every job it can:
6
+ * PulseAudio/PipeWire on Linux, AVFoundation/AudioToolbox on macOS, DirectShow
7
+ * on Windows. ffmpeg has no audio output on Windows, so playback there uses the
8
+ * built-in PowerShell MediaPlayer. No other audio binary exists.
9
+ */
10
+
11
+ import { randomUUID } from "node:crypto";
12
+ import { join } from "node:path";
13
+ import { type EnsureOptions, ensureTool, type ToolStatus } from "./binaries/ensure.ts";
14
+ import { BinaryMissingError, lookupTool, resolveTool } from "./binaries/resolve.ts";
15
+ import { runTool, runToolSync, spawnTool } from "./exec.ts";
16
+ import type { OsOptions } from "./os.ts";
17
+ import { tempDir } from "./paths.ts";
18
+ import { currentPlatform, type HostPlatform } from "./platform.ts";
19
+
20
+ export interface Microphone {
21
+ /** Device id for {@link startRecording}. "default" follows the system default. */
22
+ id: string;
23
+ /** Readable name, e.g. "Headset - Nokia E1200 ANC". */
24
+ label: string;
25
+ }
26
+
27
+ const SYSTEM_DEFAULT: Microphone = { id: "default", label: "System default" };
28
+
29
+ /** The first entry names the system default input when the OS reports one. */
30
+ function withDefault(inputs: Microphone[], system?: string): Microphone[] {
31
+ return [
32
+ { id: "default", label: system ? `System default (${system})` : "System default" },
33
+ ...inputs,
34
+ ];
35
+ }
36
+
37
+ // ── Device lists (ffmpeg output parsers) ────────────────────────────────────
38
+
39
+ /** "Built-in Audio Analog Stereo" → "Built-in Audio". The channel layout is noise here. */
40
+ function product(description: string): string {
41
+ return description.replace(/\s+(Analog|Digital)?\s*(Mono|Stereo|Surround[\s\d.]*)$/i, "").trim();
42
+ }
43
+
44
+ /**
45
+ * Readable label: "<kind> - <product>", like a desktop sound menu.
46
+ * ponytail: ffmpeg gives only the name and description, not the form factor or
47
+ * port, so the kind is "Headset" for Bluetooth and "Microphone" otherwise.
48
+ */
49
+ export function microphoneLabel(id: string, description: string): string {
50
+ const name = product(description || id);
51
+ // "... Digital Microphone" already names its kind. Skip the prefix.
52
+ if (/microphone|headset|webcam|\bmic\b/i.test(name)) return name;
53
+ return `${id.startsWith("bluez_") ? "Headset" : "Microphone"} - ${name}`;
54
+ }
55
+
56
+ /**
57
+ * Linux: `ffmpeg -sources pulse` (stdout), without output monitors.
58
+ * Rows look like `* <name> [<description>] (none)`. `*` marks the system default.
59
+ */
60
+ export function parsePulseSources(output: string): Microphone[] {
61
+ let system: string | undefined;
62
+ const inputs: Microphone[] = [];
63
+ for (const [, star, id = "", description = ""] of output.matchAll(/^(\*)?\s*(\S+) \[(.*)\]/gm)) {
64
+ // An output monitor records what plays, not a microphone.
65
+ if (id.endsWith(".monitor")) continue;
66
+ const mic = { id, label: microphoneLabel(id, description) };
67
+ if (star) system = mic.label;
68
+ inputs.push(mic);
69
+ }
70
+ return withDefault(inputs, system);
71
+ }
72
+
73
+ /** Windows: `ffmpeg -list_devices true -f dshow -i dummy` (stderr). */
74
+ export function parseDshowDevices(output: string): Microphone[] {
75
+ const names = [...output.matchAll(/"([^"]+)" \(audio\)/g)].map((m) => m[1] as string);
76
+ return withDefault(
77
+ names.map((name) => ({ id: name, label: name })),
78
+ names[0],
79
+ );
80
+ }
81
+
82
+ /** macOS: `ffmpeg -f avfoundation -list_devices true -i ""` (stderr), audio section only. */
83
+ export function parseAvfoundationDevices(output: string): Microphone[] {
84
+ const audio = output.split(/AVFoundation audio devices:/)[1] ?? "";
85
+ const names = [...audio.matchAll(/\] \[\d+\] (.+?)(?:\s+\[uid:.*)?$/gm)].map((m) =>
86
+ (m[1] as string).trim(),
87
+ );
88
+ return withDefault(names.map((name) => ({ id: name, label: name })));
89
+ }
90
+
91
+ const LIST: Partial<Record<HostPlatform["os"], string[]>> = {
92
+ linux: ["-hide_banner", "-nostdin", "-sources", "pulse"],
93
+ win32: ["-hide_banner", "-nostdin", "-list_devices", "true", "-f", "dshow", "-i", "dummy"],
94
+ darwin: ["-hide_banner", "-nostdin", "-f", "avfoundation", "-list_devices", "true", "-i", ""],
95
+ };
96
+
97
+ /**
98
+ * Microphones for a picker. The first entry is always "default". Never
99
+ * downloads ffmpeg: without it, only the default shows.
100
+ */
101
+ export async function listMicrophones(opts: OsOptions = {}): Promise<Microphone[]> {
102
+ const host = opts.host ?? currentPlatform();
103
+ const args = LIST[host.os];
104
+ if (!args || !resolveTool("ffmpeg", { ...opts, host })) return [SYSTEM_DEFAULT];
105
+ const r = await runTool("ffmpeg", args, { env: opts.env, host, timeoutMs: 5000 });
106
+ // -sources prints to stdout. The dshow and avfoundation lists exit 1 and print to stderr.
107
+ if (host.os === "linux") return r.code === 0 ? parsePulseSources(r.stdout) : [SYSTEM_DEFAULT];
108
+ return host.os === "win32" ? parseDshowDevices(r.stderr) : parseAvfoundationDevices(r.stderr);
109
+ }
110
+
111
+ // ── Recording ───────────────────────────────────────────────────────────────
112
+
113
+ /**
114
+ * ffmpeg input args (`-f … -i …`) that record `device`. Sync, so a recording
115
+ * still starts on the keypress. dshow has no default device, so on Windows
116
+ * "default" maps to the first audio input (one ~0.3 s device scan).
117
+ */
118
+ export function microphoneInput(device: string, opts: OsOptions = {}): string[] {
119
+ const host = opts.host ?? currentPlatform();
120
+ if (host.os === "linux") return ["-f", "pulse", "-i", device];
121
+ // avfoundation takes "<video>:<audio>". An empty video part records audio only.
122
+ if (host.os === "darwin") return ["-f", "avfoundation", "-i", `:${device}`];
123
+ if (host.os !== "win32") throw new Error(`microphone recording is not supported on ${host.os}`);
124
+ let name = device;
125
+ if (name === "default") {
126
+ const r = runToolSync("ffmpeg", LIST.win32 ?? [], { env: opts.env, host, timeoutMs: 5000 });
127
+ name = parseDshowDevices(r.stderr)[1]?.id ?? "";
128
+ if (!name) throw new Error("no microphone found (ffmpeg dshow lists no audio input)");
129
+ }
130
+ return ["-f", "dshow", "-i", `audio=${name}`];
131
+ }
132
+
133
+ const LEVEL_FILTER = "astats=metadata=1:reset=1,ametadata=print:key=lavfi.astats.Overall.RMS_level";
134
+
135
+ /** Mono 16 kHz wav with a per-frame RMS level on stderr. No `output` means meter only. */
136
+ export function recordArgs(input: readonly string[], output?: string): string[] {
137
+ return [
138
+ "-hide_banner",
139
+ "-loglevel",
140
+ "info",
141
+ ...input,
142
+ "-ac",
143
+ "1",
144
+ "-ar",
145
+ "16000",
146
+ "-af",
147
+ LEVEL_FILTER,
148
+ ...(output ? ["-y", output] : ["-f", "null", "-"]),
149
+ ];
150
+ }
151
+
152
+ /** The latest RMS level in dB from ffmpeg's log, or undefined for silence (-inf). */
153
+ export function parseRmsDb(output: string): number | undefined {
154
+ const matches = [
155
+ ...output.matchAll(/(?:RMS level dB:\s*|lavfi\.astats\.Overall\.RMS_level=)(-?\d+(?:\.\d+)?)/g),
156
+ ];
157
+ const value = matches.at(-1)?.[1];
158
+ return value === undefined ? undefined : Number(value);
159
+ }
160
+
161
+ export interface RecordOptions extends OsOptions {
162
+ /** Input level in dB, about once per audio frame. */
163
+ onLevel?: (db: number) => void;
164
+ /** ffmpeg stopped before `stop()`, for example on a bad device. */
165
+ onExit?: (error: Error) => void;
166
+ /** Download progress when ffmpeg is missing. */
167
+ onStatus?: (s: ToolStatus) => void;
168
+ /** Meter only: stream the level and write nothing to disk. */
169
+ meterOnly?: boolean;
170
+ }
171
+
172
+ export interface Recording {
173
+ /** The wav file. Empty for a meter-only session. */
174
+ path: string;
175
+ /** Stop, wait for the file to close, and give the last level in dB. */
176
+ stop(): Promise<number | undefined>;
177
+ }
178
+
179
+ /**
180
+ * Recording starts on a keypress and must not block. A missing but
181
+ * downloadable ffmpeg starts a visible background download, and this call
182
+ * throws and asks the user to try again when the download ends.
183
+ */
184
+ function requireFfmpeg(purpose: string, opts: OsOptions & Pick<EnsureOptions, "onStatus">): void {
185
+ if (resolveTool("ffmpeg", opts)) return;
186
+ const hit = lookupTool("ffmpeg", opts);
187
+ if (hit.state === "missing" && hit.downloadable) {
188
+ void ensureTool("ffmpeg", opts).catch(() => undefined);
189
+ throw new Error(
190
+ `${purpose} needs ffmpeg — downloading it now (~120 MB); try again when it finishes.`,
191
+ );
192
+ }
193
+ throw new BinaryMissingError("ffmpeg", hit.state, hit.hint, `${purpose} needs ffmpeg`);
194
+ }
195
+
196
+ /** Keep the end of the ffmpeg log for the error message. The level meter writes a line per frame. */
197
+ const STDERR_TAIL = 4096;
198
+
199
+ /** Record `device` to a temp wav, or only meter it with `meterOnly`. Call `stop()` to end. */
200
+ export function startRecording(device: string, opts: RecordOptions = {}): Recording {
201
+ requireFfmpeg(opts.meterOnly ? "The microphone test" : "Microphone recording", opts);
202
+ const path = opts.meterOnly ? "" : join(tempDir(), `pix-stt-${randomUUID()}.wav`);
203
+ const child = spawnTool("ffmpeg", recordArgs(microphoneInput(device, opts), path || undefined), {
204
+ env: opts.env,
205
+ host: opts.host,
206
+ stdio: ["pipe", "ignore", "pipe"],
207
+ });
208
+ let stderr = "";
209
+ let lastLevel: number | undefined;
210
+ child.stderr.on("data", (data) => {
211
+ const text = String(data);
212
+ stderr = (stderr + text).slice(-STDERR_TAIL);
213
+ const level = parseRmsDb(text);
214
+ if (level === undefined) return;
215
+ lastLevel = level;
216
+ opts.onLevel?.(level);
217
+ });
218
+ let stopping = false;
219
+ const exit = new Promise<void>((resolve, reject) => {
220
+ child.once("error", reject);
221
+ child.once("exit", (code) =>
222
+ code === 0 ? resolve() : reject(new Error(stderr.trim() || `ffmpeg exited ${code}`)),
223
+ );
224
+ });
225
+ exit.then(
226
+ () =>
227
+ stopping ? undefined : opts.onExit?.(new Error("ffmpeg stopped before the recording ended")),
228
+ (error: Error) => (stopping ? undefined : opts.onExit?.(error)),
229
+ );
230
+ // An exited ffmpeg closes stdin. A late write must not crash Pi with EPIPE.
231
+ child.stdin.on("error", () => undefined);
232
+ return {
233
+ path,
234
+ async stop() {
235
+ stopping = true;
236
+ if (child.exitCode === null && child.signalCode === null) {
237
+ // "q" makes ffmpeg finish the wav header. A kill would leave a broken file.
238
+ child.stdin.write("q");
239
+ child.stdin.end();
240
+ }
241
+ await exit.catch((error) => {
242
+ if (!opts.meterOnly) throw error;
243
+ });
244
+ return lastLevel;
245
+ },
246
+ };
247
+ }
248
+
249
+ // ── Playback ────────────────────────────────────────────────────────────────
250
+
251
+ /**
252
+ * Windows: WPF MediaPlayer plays mp3/wav with no extra install.
253
+ * ponytail: waits NaturalDuration + 200 ms. If the end cuts off, raise the pad.
254
+ */
255
+ function mediaPlayerScript(path: string): string {
256
+ return [
257
+ "Add-Type -AssemblyName PresentationCore",
258
+ "$p = New-Object System.Windows.Media.MediaPlayer",
259
+ `$p.Open([uri]'${path.replace(/'/g, "''")}')`,
260
+ "for ($i = 0; -not $p.NaturalDuration.HasTimeSpan -and $i -lt 200; $i++) { Start-Sleep -Milliseconds 50 }",
261
+ "if (-not $p.NaturalDuration.HasTimeSpan) { exit 1 }",
262
+ "$p.Play()",
263
+ "Start-Sleep -Milliseconds ($p.NaturalDuration.TimeSpan.TotalMilliseconds + 200)",
264
+ "$p.Close()",
265
+ ].join("; ");
266
+ }
267
+
268
+ /** `[program, ...args]` that plays `path` once on `host`, or undefined when the OS has no player. */
269
+ export function playCommand(path: string, host: HostPlatform): string[] | undefined {
270
+ // -re sends samples at the real rate, so ffmpeg exits when the sound ends.
271
+ const ffmpeg = ["ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error", "-re", "-i", path];
272
+ if (host.os === "linux") return [...ffmpeg, "-f", "pulse", "pix"];
273
+ if (host.os === "darwin") return [...ffmpeg, "-f", "audiotoolbox", "-"];
274
+ if (host.os === "win32")
275
+ return ["powershell", "-NoProfile", "-NonInteractive", "-Command", mediaPlayerScript(path)];
276
+ return undefined;
277
+ }
278
+
279
+ export interface PlayOptions extends OsOptions {
280
+ signal?: AbortSignal;
281
+ /** Download progress when ffmpeg is missing. */
282
+ onStatus?: (s: ToolStatus) => void;
283
+ }
284
+
285
+ /** Play an audio file once. Resolves when playback ends. */
286
+ export async function playAudio(path: string, opts: PlayOptions = {}): Promise<void> {
287
+ const host = opts.host ?? currentPlatform();
288
+ const [name, ...args] = playCommand(path, host) ?? [];
289
+ if (!name) throw new Error(`audio playback is not supported on ${host.os}`);
290
+ const r = await runTool(name, args, {
291
+ env: opts.env,
292
+ host,
293
+ signal: opts.signal,
294
+ onStatus: opts.onStatus,
295
+ });
296
+ if (r.code !== 0)
297
+ throw new Error(r.stderr.trim() || `${name} exited with code ${r.code ?? "unknown"}`);
298
+ }