@xynogen/pix-runtime 0.10.2 → 0.13.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 +21 -0
- package/README.md +209 -12
- package/package.json +12 -1
- package/src/audio.ts +298 -0
- package/src/binaries/catalog.ts +279 -0
- package/src/binaries/ensure.ts +294 -0
- package/src/binaries/index.ts +43 -0
- package/src/binaries/resolve.ts +199 -0
- package/src/binaries/store.ts +149 -0
- package/src/binaries-tab.ts +364 -0
- package/src/exec.ts +365 -0
- package/src/extension.ts +35 -3
- package/src/hashline.check.mjs +11 -0
- package/src/hashline.ts +71 -0
- package/src/icon-catalog.ts +1 -0
- package/src/migrations.ts +41 -10
- package/src/os.ts +250 -0
- package/src/paths.ts +87 -0
- package/src/persistence.ts +122 -28
- package/src/pix-command.ts +132 -17
- package/src/platform.ts +75 -0
- package/src/runtime.ts +126 -80
- package/src/safe-path.ts +101 -0
- package/src/sections/index.ts +16 -0
- package/src/sections/pretty.ts +37 -2
- package/src/sections/services.ts +71 -0
- package/src/testing.ts +19 -3
- package/src/user-shell.ts +51 -0
- package/src/which.ts +21 -4
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
|
@@ -10,24 +10,21 @@ See `DESIGN.md` for the full contract.
|
|
|
10
10
|
## What it does
|
|
11
11
|
|
|
12
12
|
- Versioned, sparse `pix.json` (`$version: 1`) — defaults resolve in code.
|
|
13
|
-
- Typed sections: `collapse`, `pretty`, `io`, `compaction`, `optimizer`, `gate`.
|
|
13
|
+
- Typed sections: `collapse`, `pretty`, `io`, `compaction`, `optimizer`, `gate`, `fetch`, `search`, `voice`, `toolbox`.
|
|
14
|
+
- `/pix` has a Footer tab for `pretty.footer` visibility.
|
|
15
|
+
- Startup imports legacy service config files and archives them only after a successful save.
|
|
14
16
|
- Atomic writes behind a serialized in-process queue and a short-lived
|
|
15
17
|
cross-process lock. A failed write leaves the old file intact.
|
|
16
18
|
- Immutable, deeply frozen config snapshots with a monotonic revision.
|
|
17
19
|
- Typed, path-filtered change events.
|
|
18
20
|
- One-time migration of legacy unversioned config and the `optimizer.json`
|
|
19
21
|
sidecar.
|
|
20
|
-
- The `/pix` shared-settings command.
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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.
|
|
22
|
+
- The `/pix` shared-settings command, with **Settings** and **Binaries** tabs.
|
|
23
|
+
- One catalog, resolver and downloader for every external command pix runs
|
|
24
|
+
(`binaries`), plus shared `paths` and `platform` helpers.
|
|
25
|
+
- **User shell** — user `!` commands run through PowerShell on Windows (pwsh 7,
|
|
26
|
+
else 5.1), and through `$SHELL` on Linux/macOS (zsh sources `.zshrc` so
|
|
27
|
+
aliases expand).
|
|
31
28
|
|
|
32
29
|
## Usage
|
|
33
30
|
|
|
@@ -74,6 +71,176 @@ Collapse policy helpers:
|
|
|
74
71
|
import { shouldCollapse, collapseDelayMs } from "@xynogen/pix-runtime/collapse";
|
|
75
72
|
```
|
|
76
73
|
|
|
74
|
+
## Paths and platform
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { agentDir, binDir, cacheDir, homeDir, projectDir, tempDir } from "@xynogen/pix-runtime/paths";
|
|
78
|
+
import { currentPlatform, hostPlatform } from "@xynogen/pix-runtime/platform";
|
|
79
|
+
|
|
80
|
+
agentDir(); // PI_CODING_AGENT_DIR (~-expanded) or ~/.pi/agent, same as Pi's getAgentDir
|
|
81
|
+
binDir(); // <agentDir>/bin, the folder where Pi downloads fd/rg and pix downloads its tools
|
|
82
|
+
cacheDir(); // $XDG_CACHE_HOME/pi or ~/.cache/pi (never relies on HOME alone)
|
|
83
|
+
projectDir(cwd); // <cwd>/.pi, Pi's project config dir. projectDir() gives the relative .pi
|
|
84
|
+
tempDir(); // os.tmpdir(): TMPDIR on POSIX, TEMP/TMP on Windows. Shared, so delete only your own entries
|
|
85
|
+
currentPlatform(); // { os, arch, libc?, wsl, termux, exe }
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Every helper takes an optional env, so tests can use a temporary agent dir.
|
|
89
|
+
|
|
90
|
+
## Binaries
|
|
91
|
+
|
|
92
|
+
Every external command a pix package runs is listed in one catalog
|
|
93
|
+
(`src/binaries/catalog.ts`). The catalog records which packages use each
|
|
94
|
+
command, which OS needs it, an install hint, and, for a few commands, a trusted
|
|
95
|
+
GitHub release.
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
import { ensureTool, requireTool, resolveTool } from "@xynogen/pix-runtime/binaries";
|
|
99
|
+
|
|
100
|
+
resolveTool("git"); // sync, no network: { path, source } | undefined
|
|
101
|
+
requireTool("ssh"); // same, but throws BinaryMissingError with the install hint
|
|
102
|
+
await ensureTool("hunk", { onStatus }); // downloads into <agentDir>/bin when missing
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**Resolve order:** `binary.json` path → `<agentDir>/bin` → known install dirs (`system`, e.g. Git Bash on Windows) → PATH. `ensureTool`
|
|
106
|
+
then downloads if the catalog has a release for this host.
|
|
107
|
+
|
|
108
|
+
If `binary.json` names a file that doesn't exist, the entry is **broken**. pix
|
|
109
|
+
never silently falls back to another copy.
|
|
110
|
+
|
|
111
|
+
| Downloaded when missing | Source | Checksum |
|
|
112
|
+
|---|---|---|
|
|
113
|
+
| `rtk` (Windows, Linux, macOS) | `rtk-ai/rtk` latest release | `checksums.txt` |
|
|
114
|
+
| `hunk` (Windows, Linux, macOS) | `modem-dev/hunk` latest release | `SHA256SUMS` |
|
|
115
|
+
| `aria2c` (Windows only) | official `aria2/aria2` release | none published |
|
|
116
|
+
| `ffmpeg` (Windows, Linux, ~120 MB) | `BtbN/FFmpeg-Builds` lgpl | `checksums.sha256` |
|
|
117
|
+
|
|
118
|
+
Everything else, including `rg`, is only checked and never downloaded.
|
|
119
|
+
Pi itself downloads `rg` and `fd`. pix does not run `fd`, so the catalog does not list it.
|
|
120
|
+
|
|
121
|
+
The downloader:
|
|
122
|
+
|
|
123
|
+
- finds the latest version through the `/releases/latest` redirect;
|
|
124
|
+
- extracts with the system `tar`/`unzip`;
|
|
125
|
+
- works in a unique temp folder and cleans it up afterwards;
|
|
126
|
+
- shares one download between concurrent calls for the same binary;
|
|
127
|
+
- respects `PI_OFFLINE`.
|
|
128
|
+
|
|
129
|
+
Progress is reported only through `onStatus`. Use
|
|
130
|
+
`reportToolStatus(ctx.ui)` from `@xynogen/pix-pretty/tool-status` so every
|
|
131
|
+
package shows downloads the same way.
|
|
132
|
+
|
|
133
|
+
### Running a binary — `./exec`
|
|
134
|
+
|
|
135
|
+
Packages never start a catalogued binary by bare name. `./exec` resolves it
|
|
136
|
+
through the order above, then runs it:
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
import { runTool, runToolSync, spawnTool } from "@xynogen/pix-runtime/exec";
|
|
140
|
+
|
|
141
|
+
const r = await runTool("git", ["status", "--porcelain"], { cwd, timeoutMs: 2_000 });
|
|
142
|
+
// { code, stdout, stderr, stdoutBytes, timedOut, tool: { path, source } }
|
|
143
|
+
|
|
144
|
+
const child = spawnTool("ssh", args, { stdio: ["ignore", "pipe", "pipe"] }); // Node ChildProcess
|
|
145
|
+
const sync = runToolSync("npm", ["root", "-g"], { timeoutMs: 10_000 });
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
- `runTool` downloads first when the binary is missing and has a recipe
|
|
149
|
+
(progress via `onStatus`). `spawnTool` and `runToolSync` never download.
|
|
150
|
+
- A missing binary throws `BinaryMissingError` with the install hint. A
|
|
151
|
+
non-zero exit resolves normally, so check `code`.
|
|
152
|
+
- Windows `.cmd`/`.bat` shims (`npm`, `npx`, `pi`) run through
|
|
153
|
+
`cmd.exe /d /s /c` with strict quoting, since Node cannot spawn them
|
|
154
|
+
directly. Spaces, quotes, `&|<>^%` and parentheses survive intact.
|
|
155
|
+
- Timeouts and aborts kill the whole process tree on Windows
|
|
156
|
+
(`taskkill /T`), so a wrapped shim does not keep running.
|
|
157
|
+
|
|
158
|
+
### OS jobs — `./os`
|
|
159
|
+
|
|
160
|
+
Jobs that need a different program on each OS sit behind one call. Each
|
|
161
|
+
program still resolves through `binary.json`:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
import { openTarget, readClipboardImage, runGit } from "@xynogen/pix-runtime/os";
|
|
165
|
+
|
|
166
|
+
await openTarget(url, { app: process.env.BROWSER }); // open / cmd start / xdg-open / wslview
|
|
167
|
+
const img = readClipboardImage(); // PowerShell (Windows, WSL) / wl-paste / xclip
|
|
168
|
+
const branch = await runGit(["branch", "--show-current"], { cwd }); // stdout | null
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`runGit` returns `null` for any git failure (not a repo, timeout, abort) but
|
|
172
|
+
rejects with `BinaryMissingError` when git itself is missing. Background
|
|
173
|
+
features pass that to `warnBinaryMissing(ctx.ui, err)` from
|
|
174
|
+
`@xynogen/pix-pretty/tool-status`, which shows one warning per binary per
|
|
175
|
+
session, pointing at the `/pix` Binaries tab.
|
|
176
|
+
|
|
177
|
+
`scripts/binaries.test.ts` fails CI when a package starts a catalogued binary
|
|
178
|
+
by bare name.
|
|
179
|
+
|
|
180
|
+
### Audio — `./audio`
|
|
181
|
+
|
|
182
|
+
One job per function. Callers never see a program name or an OS branch.
|
|
183
|
+
ffmpeg does every job it can on Linux (PulseAudio/PipeWire), macOS
|
|
184
|
+
(AVFoundation/AudioToolbox) and Windows (DirectShow). ffmpeg has no audio
|
|
185
|
+
output on Windows, so playback there uses the built-in PowerShell MediaPlayer.
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
import { listMicrophones, playAudio, startRecording } from "@xynogen/pix-runtime/audio";
|
|
189
|
+
|
|
190
|
+
const mics = await listMicrophones(); // first entry is "default". No ffmpeg: only "default"
|
|
191
|
+
const rec = startRecording("default", { onLevel, onExit, onStatus }); // mono 16 kHz wav
|
|
192
|
+
await rec.stop(); // rec.path is the wav. { meterOnly: true } writes nothing
|
|
193
|
+
await playAudio(file, { signal }); // resolves when the sound ends
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Safe output paths — `./safe-path`
|
|
197
|
+
|
|
198
|
+
`validateOutputPath(absPath)` checks a model-chosen write target before the
|
|
199
|
+
write. It rejects null bytes, system dirs (`/etc`, `/proc` … or
|
|
200
|
+
`C:\Windows`, `Program Files`), secret dirs under home (`.ssh`, `.aws`,
|
|
201
|
+
`.gnupg`, GitHub CLI), symlinks and Windows junctions, existing directories,
|
|
202
|
+
and paths with no writable ancestor. Matching is per path segment, and
|
|
203
|
+
case-insensitive on Windows.
|
|
204
|
+
|
|
205
|
+
### `~/.pi/agent/binary.json`
|
|
206
|
+
|
|
207
|
+
This file is user configuration. It holds only the binaries you override. The
|
|
208
|
+
file does not exist until you set a path. The `/pix` Binaries tab shows the
|
|
209
|
+
full catalog of what pix depends on.
|
|
210
|
+
|
|
211
|
+
```json
|
|
212
|
+
{
|
|
213
|
+
"$version": 1,
|
|
214
|
+
"ffmpeg": "D:/tools/ffmpeg/bin/ffmpeg.exe"
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
- A missing entry (or `null`) means automatic: pix looks in `bin`, then known install dirs, then PATH, then downloads.
|
|
219
|
+
- A path means pix always uses exactly that file.
|
|
220
|
+
|
|
221
|
+
pix writes to the file only in two cases:
|
|
222
|
+
|
|
223
|
+
- when you edit or reset a path in the `/pix` Binaries tab (reset removes the entry);
|
|
224
|
+
- to remove legacy `null` entries for catalog binaries from an older file.
|
|
225
|
+
|
|
226
|
+
Paths pix finds on its own are never written to it. Keys pix doesn't know are
|
|
227
|
+
kept. If the file contains invalid JSON, pix reports it and leaves the file
|
|
228
|
+
unchanged.
|
|
229
|
+
|
|
230
|
+
### `/pix` → Binaries tab
|
|
231
|
+
|
|
232
|
+
Press **Tab** / **Shift+Tab** to switch between Settings and Binaries. Each row
|
|
233
|
+
shows a status icon with a text label (ok / missing / broken / not used on this
|
|
234
|
+
OS), the resolved path, where it was found, and its version. Selecting a row
|
|
235
|
+
also shows which packages use it.
|
|
236
|
+
|
|
237
|
+
| Key | Action |
|
|
238
|
+
|---|---|
|
|
239
|
+
| **enter** | install (downloadable, missing) or re-check |
|
|
240
|
+
| **e** | set a path (saved to `binary.json`) |
|
|
241
|
+
| **d** | reset the entry to automatic (removes it from `binary.json`) |
|
|
242
|
+
| **r** | re-check all entries |
|
|
243
|
+
|
|
77
244
|
## Agent state and herdr notifications
|
|
78
245
|
|
|
79
246
|
### Agent-state coordinator
|
|
@@ -160,3 +327,33 @@ const { runtime, cleanup } = createIsolatedRuntime();
|
|
|
160
327
|
// ... exercise runtime against a temp agent dir ...
|
|
161
328
|
cleanup();
|
|
162
329
|
```
|
|
330
|
+
|
|
331
|
+
## Install
|
|
332
|
+
|
|
333
|
+
```bash
|
|
334
|
+
pi install npm:@xynogen/pix-runtime
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
> Foundation library. Feature packages install it as a dependency. Install it directly only when you build your own extension on it.
|
|
338
|
+
|
|
339
|
+
Standalone-installable: importing an accessor lazily creates the singleton even
|
|
340
|
+
without the extension factory. Installed via `pix-core` it registers `/pix` and
|
|
341
|
+
session hooks once.
|
|
342
|
+
|
|
343
|
+
## Full distro
|
|
344
|
+
|
|
345
|
+
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.
|
|
346
|
+
|
|
347
|
+
```bash
|
|
348
|
+
# Linux / macOS
|
|
349
|
+
curl -fsSL https://raw.githubusercontent.com/xynogen/pix-mono/main/scripts/install.sh | sh
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
```powershell
|
|
353
|
+
# Windows
|
|
354
|
+
irm https://raw.githubusercontent.com/xynogen/pix-mono/main/scripts/install.ps1 | iex
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
## License
|
|
358
|
+
|
|
359
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
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,14 +22,22 @@
|
|
|
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",
|
|
28
29
|
"./icon-catalog": "./src/icon-catalog.ts",
|
|
29
30
|
"./io": "./src/io.ts",
|
|
31
|
+
"./hashline": "./src/hashline.ts",
|
|
30
32
|
"./lfid": "./src/lfid.ts",
|
|
31
33
|
"./once": "./src/once.ts",
|
|
34
|
+
"./paths": "./src/paths.ts",
|
|
35
|
+
"./platform": "./src/platform.ts",
|
|
32
36
|
"./testing": "./src/testing.ts",
|
|
37
|
+
"./exec": "./src/exec.ts",
|
|
38
|
+
"./os": "./src/os.ts",
|
|
39
|
+
"./audio": "./src/audio.ts",
|
|
40
|
+
"./safe-path": "./src/safe-path.ts",
|
|
33
41
|
"./which": "./src/which.ts"
|
|
34
42
|
},
|
|
35
43
|
"keywords": [
|
|
@@ -56,5 +64,8 @@
|
|
|
56
64
|
"peerDependencies": {
|
|
57
65
|
"@earendil-works/pi-coding-agent": "*",
|
|
58
66
|
"@earendil-works/pi-tui": "*"
|
|
67
|
+
},
|
|
68
|
+
"dependencies": {
|
|
69
|
+
"xxhash-wasm": "1.1.0"
|
|
59
70
|
}
|
|
60
71
|
}
|
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
|
+
}
|