pi-web-voice 0.1.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pi-web-voice contributors
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 ADDED
@@ -0,0 +1,331 @@
1
+ # pi-web-voice
2
+
3
+ Voice input for [pi-web](https://github.com/agegr/pi-web), added from the outside.
4
+
5
+ A microphone button appears in the chat composer. Tap it to start and tap again to stop,
6
+ or press and hold to talk and release when done — the gesture decides, there is nothing
7
+ to configure. The transcript lands at your caret; you review it, then send. pi-web is never modified: the
8
+ whole thing is one `--require` hook that injects a single `<script>` tag into HTML
9
+ responses and serves two routes of its own.
10
+
11
+ ```
12
+ you speak ──▶ browser records 16 kHz WAV ──▶ POST /__voice/transcribe
13
+
14
+ Azure AI Speech (MAI-Transcribe-2)
15
+ Azure OpenAI (gpt-4o-transcribe)
16
+ OpenAI · Groq · local whisper.cpp
17
+
18
+ transcript ◀──────────────┘ inserted at the caret
19
+ ```
20
+
21
+ **Why a hook instead of a fork:** pi-web ships as a prebuilt Next.js app with no source
22
+ in the npm package, so there is nothing to patch cleanly. This attaches at the Node
23
+ layer instead, which means `npm update -g @agegr/pi-web` needs no re-apply.
24
+
25
+ ## Quick start
26
+
27
+ ```bash
28
+ npm install -g pi-web-voice
29
+
30
+ pi-web-voice init # writes ~/.pi/agent/voice.env, mode 0600
31
+ # uncomment one backend in it and add the key
32
+ pi-web-voice doctor # proves the key, region and model before you look for a mic bug
33
+ pi-web-voice # starts pi-web with the microphone button
34
+ ```
35
+
36
+ `pi-web-voice` starts pi-web with the hook and passes every argument through, so
37
+ `pi-web-voice -p 8080` works. With no key at all it runs a mock backend, which is enough
38
+ to prove the button and the round trip.
39
+
40
+ Nothing is compiled and there are no dependencies, so `npx pi-web-voice` works too, and a
41
+ `git clone` plus `npm install -g .` works if you would rather run from source.
42
+
43
+ You should see this on startup, and a microphone next to the image-attach button:
44
+
45
+ ```
46
+ [pi-web-voice] active · provider=azure-speech · context=project
47
+ ```
48
+
49
+ ## Running it as a service
50
+
51
+ If pi-web already runs under launchd or systemd, add one environment variable to the
52
+ service rather than changing how it starts. `hook-path` prints the value regardless of
53
+ where npm installed the package:
54
+
55
+ ```bash
56
+ pi-web-voice hook-path
57
+ # /opt/homebrew/lib/node_modules/pi-web-voice/hook.cjs
58
+ ```
59
+
60
+ launchd — add to `EnvironmentVariables` in the plist, then reload it:
61
+
62
+ ```xml
63
+ <key>NODE_OPTIONS</key>
64
+ <string>--require /opt/homebrew/lib/node_modules/pi-web-voice/hook.cjs</string>
65
+ ```
66
+
67
+ ```bash
68
+ launchctl bootout gui/$(id -u)/com.agegr.pi-web
69
+ lsof -ti:30141 | xargs kill -9 2>/dev/null; pkill -f next-server; sleep 2
70
+ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.agegr.pi-web.plist
71
+ ```
72
+
73
+ `launchctl kickstart -k` restarts the job from launchd's in-memory copy and will **not**
74
+ pick up plist changes. It also leaves the `next start` child orphaned holding the port,
75
+ which is what the second line clears.
76
+
77
+ systemd — `Environment=NODE_OPTIONS=--require /path/to/hook.cjs`, then
78
+ `systemctl --user daemon-reload && systemctl --user restart pi-web`.
79
+
80
+ Removing that one variable disables everything; pi-web itself was never modified.
81
+
82
+ ## Where the key goes
83
+
84
+ `~/.pi/agent/voice.env`, next to pi's own configuration. It is a plain `KEY=value`
85
+ file, loaded by Node itself — no dependency, no parser of ours:
86
+
87
+ ```sh
88
+ AZURE_SPEECH_ENDPOINT=https://my-resource.cognitiveservices.azure.com
89
+ AZURE_SPEECH_KEY=abc123...
90
+ PI_VOICE_MODE=hold
91
+ ```
92
+
93
+ Anything already exported wins over the file, so `AZURE_SPEECH_KEY=other pi-web-voice`
94
+ overrides it for one run without editing anything. Keep it `chmod 600`; the hook warns
95
+ if it is not.
96
+
97
+ ## ⚠️ The microphone needs HTTPS or localhost
98
+
99
+ This is a browser rule, not something this project can work around. `http://localhost`
100
+ and `http://127.0.0.1` are fine. **A plain-HTTP LAN address such as
101
+ `http://192.168.1.10:30141` is not** — the button will report that the microphone is
102
+ unavailable. Pick one:
103
+
104
+ | Situation | Fix |
105
+ | --- | --- |
106
+ | Remote machine, you are on a desktop | `ssh -L 30141:127.0.0.1:30141 host` and use `http://localhost:30141` |
107
+ | Phone or tablet on your LAN | Terminate HTTPS in front: [Tailscale Serve](https://tailscale.com/kb/1312/serve), Caddy with an internal CA, or any reverse proxy with a certificate |
108
+ | Desktop Chrome/Edge, testing only | Launch with `--unsafely-treat-insecure-origin-as-secure=http://192.168.1.10:30141` |
109
+
110
+ iOS Safari has no override, so a real certificate is the only path there.
111
+
112
+ ## Backends
113
+
114
+ Set `PI_VOICE_PROVIDER`, or let it be inferred from whichever credentials exist.
115
+
116
+ ### `azure-speech` — Azure AI Speech fast transcription (recommended)
117
+
118
+ Runs MAI-Transcribe-2, which does automatic language identification, code switching,
119
+ and **keyword biasing** — the combination that matters when you mix Chinese and English
120
+ and say project names out loud. The mined vocabulary is sent as `phraseList.phrases`,
121
+ which takes up to 500 entries, so the whole list fits.
122
+
123
+ > **Region matters.** MAI-Transcribe is only served from `eastus`, `northeurope`,
124
+ > `southeastasia`, `westus`, and `westus2`. A resource elsewhere is rejected. Create one
125
+ > in a supported region:
126
+ >
127
+ > ```bash
128
+ > az cognitiveservices account create --name my-speech --resource-group my-rg \
129
+ > --kind AIServices --sku S0 --location westus2 --yes
130
+ > az cognitiveservices account show --name my-speech --resource-group my-rg \
131
+ > --query properties.endpoint -o tsv
132
+ > az cognitiveservices account keys list --name my-speech --resource-group my-rg \
133
+ > --query key1 -o tsv
134
+ > ```
135
+
136
+ | Variable | Default | Notes |
137
+ | --- | --- | --- |
138
+ | `AZURE_SPEECH_ENDPOINT` | — | Resource name or full `https://…cognitiveservices.azure.com` |
139
+ | `AZURE_SPEECH_KEY` | — | Resource key |
140
+
141
+ ### `azure-openai` — gpt-transcribe, gpt-4o-transcribe, whisper
142
+
143
+ | Variable | Default |
144
+ | --- | --- |
145
+ | `AZURE_OPENAI_ENDPOINT` | — |
146
+ | `AZURE_OPENAI_API_KEY` | — |
147
+ | `PI_VOICE_DEPLOYMENT` | `gpt-4o-transcribe` |
148
+
149
+ The request shape follows the deployment name:
150
+
151
+ - **`gpt-transcribe`** uses the v1 surface and gets the vocabulary as structured
152
+ `keywords[]`, plus `languages[]` derived from the browser's `Accept-Language`. No token
153
+ budget to fight, and nothing a model could mistake for an instruction.
154
+ - **`gpt-4o-transcribe`** and whisper use the classic deployment path with a `prompt`.
155
+ Whisper reads only the last 224 tokens of it, so the list is trimmed to fit. Note that
156
+ `gpt-4o-transcribe` version `2025-03-20` retires on 15 October 2026.
157
+
158
+ ### `openai` — OpenAI, Groq, or a local server
159
+
160
+ | Variable | Default |
161
+ | --- | --- |
162
+ | `PI_VOICE_OPENAI_BASE_URL` | `https://api.openai.com/v1` |
163
+ | `OPENAI_API_KEY` | — |
164
+ | `PI_VOICE_OPENAI_MODEL` | `whisper-1` |
165
+
166
+ Groq: `PI_VOICE_OPENAI_BASE_URL=https://api.groq.com/openai/v1` with
167
+ `PI_VOICE_OPENAI_MODEL=whisper-large-v3`. A local `whisper.cpp` server works the same way.
168
+
169
+ ### `mock`
170
+
171
+ Returns a fixed string describing the audio it received. Use it to confirm the button,
172
+ the recorder, and the round trip work before adding credentials.
173
+
174
+ ## Check the backend before blaming the microphone
175
+
176
+ ```bash
177
+ pi-web-voice doctor # sends a generated tone
178
+ pi-web-voice doctor recording.wav # or your own audio
179
+ ```
180
+
181
+ It prints the resolved settings with the key masked, the vocabulary it would send, and
182
+ either a transcript or a diagnosis — `401` wrong key, `404` wrong resource or region,
183
+ `400` a model that region does not serve. An empty transcript from the generated tone is
184
+ expected and still proves the credentials work.
185
+
186
+ ## Vocabulary comes from your conversation
187
+
188
+ A hand-written term list goes stale the moment you start a new project, so the
189
+ vocabulary is mined per request instead:
190
+
191
+ 1. The page reports which session the tab is showing, captured from pi-web's own
192
+ `EventSource("/api/agent/<id>/events")` call, plus the working directory. No guessing
193
+ from "most recent session".
194
+ 2. The hook reads that session's JSONL — the same files pi-web reads — and scores terms
195
+ with a distinctive written shape: `camelCase`, `kebab-case`, `file.ext`, `a/b/c`,
196
+ acronyms, and short backtick spans. Ordinary words are skipped; a speech model gets
197
+ those right already.
198
+ 3. What you typed yourself counts more than what the assistant wrote, and recent text
199
+ counts more than old text.
200
+ 4. The conversation you are in outweighs the project's older ones. With no session yet,
201
+ the project's history is all there is. With neither, nothing is sent — no invented
202
+ vocabulary.
203
+
204
+ Ranking is by frequency, not recency. Recency was tried and measured worse: a few turns
205
+ on a side topic evicted the project's durable vocabulary, and the budget is small.
206
+
207
+ ### Measured phrase-list limits
208
+
209
+ The published phrase-list guidance suggests up to 500 entries. Probing the West US
210
+ endpoint says otherwise:
211
+
212
+ | Model | Phrase list | `transcribeStyle` |
213
+ | --- | --- | --- |
214
+ | `MAI-Transcribe-2` | **50** | supported |
215
+ | `MAI-Transcribe-1.5` | **200** | rejected |
216
+ | `MAI-Transcribe-1` | not supported at all | rejected |
217
+
218
+ The count is of **words, not entries** — a two-word phrase costs two slots — so only
219
+ single-word terms are mined, and a rejected vocabulary is retried once without the
220
+ phrase list rather than losing the recording.
221
+
222
+ MAI-Transcribe-2 is preferred over 1.5 despite the smaller budget, because the larger one
223
+ does not buy anything: on the same recording, 1.5 with 200 terms produced `hook c js` and
224
+ `phrase list`, worse than its own no-vocabulary baseline and twice as slow.
225
+
226
+ ### Backends measured against each other
227
+
228
+ Three Chinese-English sentences full of identifiers, same mined vocabulary, list prices:
229
+
230
+ | | MAI-Transcribe-2 | gpt-transcribe |
231
+ | --- | --- | --- |
232
+ | Exact technical strings | good | **better** — the only one to get `MAI-Transcribe-2` intact |
233
+ | Punctuation | none | **adds it** |
234
+ | Dropped content | none observed | **dropped a clause once**, silently |
235
+ | Latency | **~1.0 s** | ~2.6 s |
236
+ | Price | $0.36 / audio hour | **$0.27 / audio hour** |
237
+
238
+ Vocabulary is what decides accuracy, not the backend: without it, the same clip came back
239
+ as `hookcjs` from one and `Hugging CJS` from the other. With it, both produced `hook.cjs`.
240
+
241
+ Silent omission is the reason this is not a clear win. Garbled text is visible in the
242
+ composer and gets fixed; a missing clause is not. Try both on your own recordings —
243
+ these samples were synthesized speech, which articulates far more cleanly than anyone
244
+ actually dictating.
245
+
246
+ Inspect it any time:
247
+
248
+ ```bash
249
+ curl 'http://127.0.0.1:30141/__voice/terms?session=<id>'
250
+ curl 'http://127.0.0.1:30141/__voice/terms?cwd=/path/to/project'
251
+ ```
252
+
253
+ | Variable | Default | Meaning |
254
+ | --- | --- | --- |
255
+
256
+ **Thinking blocks, tool arguments and tool results are never read** — they are noisy and
257
+ they are where secrets live. Anything resembling a credential is dropped as well: known
258
+ key prefixes, hex digests, base64 blobs, and long separator-free mixed strings.
259
+
260
+ ## Every setting
261
+
262
+ Nine variables, eight of which are credentials for three mutually exclusive backends.
263
+ That leaves one. Anything with one correct answer — route prefix, API versions, model
264
+ name, transcription style, timeouts, context window sizes, keyboard shortcut, and where
265
+ the key file lives — is a constant in `lib/config.cjs`, not a knob.
266
+
267
+ **Credentials** — set one group; the backend is chosen from whichever is present.
268
+
269
+ | Variable | For |
270
+ | --- | --- |
271
+ | `AZURE_SPEECH_ENDPOINT`, `AZURE_SPEECH_KEY` | Azure AI Speech (MAI-Transcribe-2) |
272
+ | `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_KEY`, `PI_VOICE_DEPLOYMENT` | Azure OpenAI |
273
+ | `PI_VOICE_OPENAI_BASE_URL`, `OPENAI_API_KEY`, `PI_VOICE_OPENAI_MODEL` | OpenAI, Groq, local |
274
+
275
+ **Behaviour**
276
+
277
+ | Variable | Default | Meaning |
278
+ | --- | --- | --- |
279
+ | `PI_VOICE_PROVIDER` | inferred | `azure-speech`, `azure-openai`, `openai`, `mock`. Only needed to break a tie, force the mock, or A/B two backends |
280
+
281
+ The recognition language is never set, on purpose: automatic language identification and
282
+ mid-sentence code switching only work when it is left off.
283
+
284
+ ## How it works
285
+
286
+ 1. `hook.cjs` patches `http.Server.prototype.emit` in the pi-web process.
287
+ 2. Requests under `/__voice/` are answered by the hook — `inject.js` and `transcribe`.
288
+ 3. Every other request is forwarded untouched, except that `text/html` responses gain
289
+ one `<script>` tag. JSON, static assets, file uploads, and the SSE event stream are
290
+ passed through unbuffered and byte-for-byte.
291
+ 4. `public/inject.js` mounts the button, records with `AudioContext`, encodes 16 kHz
292
+ mono PCM WAV in the page, and writes the result into the composer through the
293
+ `HTMLTextAreaElement` value setter so React sees the change.
294
+
295
+ `inject.js` is read from disk on every request, so editing it takes effect on reload —
296
+ no restart, and any agent session you have running stays alive.
297
+
298
+ ## Privacy
299
+
300
+ Audio goes from your browser to your own pi-web origin, and from there to the
301
+ speech backend you configured. Credentials stay on the server; the page is only told
302
+ which provider is active. Nothing is written to disk and nothing else is contacted.
303
+
304
+ ## Compatibility
305
+
306
+ Verified against pi-web `0.8.11` (pi `0.84.3`). The only version-sensitive part is the
307
+ button anchor in `inject.js`, which looks for the image-attach button by title and falls
308
+ back to the model selector, then to the send button's row. If a future pi-web moves
309
+ things, that one function is what needs adjusting — the hook itself only depends on
310
+ Node's HTTP API.
311
+
312
+ ## Tests
313
+
314
+ ```bash
315
+ npm test # HTTP interception, no pi-web needed
316
+ node test/e2e-edge.mjs http://127.0.0.1:31141 # real pi-web + real browser
317
+ ```
318
+
319
+ The end-to-end test drives headless Edge over the DevTools protocol: it waits for the
320
+ button to mount, feeds the recorder a synthetic audio stream (headless browsers have no
321
+ microphone), and asserts that the transcript reaches the composer. Set `BROWSER` to use
322
+ a different Chromium binary.
323
+
324
+ ## Uninstall
325
+
326
+ Drop the `NODE_OPTIONS` variable, or stop using `pi-web-voice` to launch. Nothing was
327
+ installed into pi-web, so there is nothing to revert.
328
+
329
+ ## License
330
+
331
+ MIT
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ /**
5
+ * Convenience launcher: starts pi-web with the voice hook preloaded.
6
+ *
7
+ * pi-web-voice # same as `pi-web`
8
+ * pi-web-voice -p 8080 # arguments are passed straight through
9
+ *
10
+ * Equivalent to setting NODE_OPTIONS yourself; this just spares you the path.
11
+ */
12
+
13
+ const { spawn } = require("node:child_process");
14
+ const fs = require("node:fs");
15
+ const os = require("node:os");
16
+ const path = require("node:path");
17
+
18
+ const args = process.argv.slice(2);
19
+ const hook = path.join(__dirname, "..", "hook.cjs");
20
+
21
+ // `pi-web-voice doctor [file.wav]` checks the speech backend and exits.
22
+ if (args[0] === "doctor") {
23
+ const { doctor } = require("../lib/doctor.cjs");
24
+ doctor(args.slice(1)).then((code) => process.exit(code));
25
+ return;
26
+ }
27
+
28
+ // `pi-web-voice hook-path` prints the absolute path to the hook, so a service
29
+ // definition can be written without knowing where npm put the package.
30
+ if (args[0] === "hook-path") {
31
+ console.log(hook);
32
+ return;
33
+ }
34
+
35
+ // `pi-web-voice init` creates the key file, which is all a new machine needs
36
+ // beyond installing the package.
37
+ if (args[0] === "init") {
38
+ const { ENV_FILE } = require("../lib/config.cjs");
39
+ if (fs.existsSync(ENV_FILE)) {
40
+ console.log(`${ENV_FILE} already exists, leaving it alone.`);
41
+ } else {
42
+ fs.mkdirSync(path.dirname(ENV_FILE), { recursive: true });
43
+ fs.writeFileSync(
44
+ ENV_FILE,
45
+ `# pi-web-voice keys. Anything exported in your shell overrides these.
46
+
47
+ # Azure AI Speech — MAI-Transcribe-2. Regions: eastus, northeurope,
48
+ # southeastasia, westus, westus2.
49
+ #AZURE_SPEECH_ENDPOINT=https://my-resource.cognitiveservices.azure.com
50
+ #AZURE_SPEECH_KEY=
51
+
52
+ # Azure OpenAI — gpt-transcribe. Paste the full transcriptions URL from the
53
+ # portal; it is used verbatim, api-version and all.
54
+ #AZURE_OPENAI_ENDPOINT=https://my-resource.openai.azure.com/openai/deployments/gpt-transcribe/audio/transcriptions?api-version=2025-03-01-preview
55
+ #AZURE_OPENAI_API_KEY=
56
+ #PI_VOICE_DEPLOYMENT=gpt-transcribe
57
+
58
+ # OpenAI, Groq, or a local whisper server.
59
+ #PI_VOICE_OPENAI_BASE_URL=https://api.groq.com/openai/v1
60
+ #OPENAI_API_KEY=
61
+ #PI_VOICE_OPENAI_MODEL=whisper-large-v3
62
+ `,
63
+ { mode: 0o600 },
64
+ );
65
+ console.log(`Created ${ENV_FILE} (0600). Uncomment one backend and add its key.`);
66
+ }
67
+ console.log(`Then check it with: pi-web-voice doctor`);
68
+ return;
69
+ }
70
+
71
+ if (args[0] === "--help" || args[0] === "-h") {
72
+ console.log(`pi-web-voice — voice input for pi-web
73
+
74
+ pi-web-voice [pi-web args] start pi-web with the microphone button
75
+ pi-web-voice init create ~/.pi/agent/voice.env
76
+ pi-web-voice doctor [f.wav] check the speech backend
77
+ pi-web-voice hook-path print the --require path for a service file
78
+
79
+ Home: ${os.homedir()}/.pi/agent/voice.env`);
80
+ return;
81
+ }
82
+
83
+ const quoted = hook.includes(" ") ? `"${hook}"` : hook;
84
+
85
+ const env = {
86
+ ...process.env,
87
+ NODE_OPTIONS: `${process.env.NODE_OPTIONS ? `${process.env.NODE_OPTIONS} ` : ""}--require ${quoted}`,
88
+ };
89
+
90
+ const child = spawn("pi-web", args, { env, stdio: "inherit", shell: process.platform === "win32" });
91
+
92
+ child.on("error", (error) => {
93
+ if (error.code === "ENOENT") {
94
+ console.error("[pi-web-voice] cannot find \"pi-web\". Install it with: npm i -g @agegr/pi-web");
95
+ process.exit(127);
96
+ }
97
+ console.error(`[pi-web-voice] ${error.message}`);
98
+ process.exit(1);
99
+ });
100
+
101
+ const forward = (signal) => child.kill(signal);
102
+ process.on("SIGINT", forward);
103
+ process.on("SIGTERM", forward);
104
+ child.on("exit", (code, signal) => {
105
+ if (signal) process.kill(process.pid, signal);
106
+ else process.exit(code ?? 0);
107
+ });
package/hook.cjs ADDED
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * pi-web-voice — entry point for `node --require`.
5
+ *
6
+ * Usage:
7
+ * NODE_OPTIONS="--require /path/to/pi-web-voice/hook.cjs" pi-web
8
+ *
9
+ * The hook attaches to any HTTP server created in this process (pi-web runs
10
+ * `next start`, which uses node:http). It does two things and nothing else:
11
+ *
12
+ * 1. Serves its own routes under a prefix (default `/__voice`).
13
+ * 2. Injects one <script> tag into HTML responses.
14
+ *
15
+ * pi-web itself is never modified, so upgrading pi-web needs no re-apply.
16
+ */
17
+
18
+ const { loadConfig } = require("./lib/config.cjs");
19
+ const { install } = require("./lib/patch.cjs");
20
+ const { createRouter } = require("./lib/routes.cjs");
21
+
22
+ // A single process may load the hook more than once (parent + child).
23
+ // Only the process that actually serves HTTP matters, and install() is
24
+ // idempotent, but guard anyway so logs are not duplicated.
25
+ if (!global.__PI_WEB_VOICE_INSTALLED__) {
26
+ global.__PI_WEB_VOICE_INSTALLED__ = true;
27
+
28
+ const config = loadConfig();
29
+
30
+ install({
31
+ prefix: config.prefix,
32
+ tag: `<script src="${config.prefix}/inject.js"></script>`,
33
+ handleRoute: createRouter(config),
34
+ onError: (error) => console.error("[pi-web-voice]", error),
35
+ onListen: () =>
36
+ console.log(
37
+ `[pi-web-voice] active · provider=${config.provider}`,
38
+ ),
39
+ });
40
+ }
package/lib/config.cjs ADDED
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const os = require("node:os");
5
+ const path = require("node:path");
6
+
7
+ /**
8
+ * Configuration is environment-only, and deliberately small. Everything that
9
+ * has one sensible answer is a constant below rather than a knob.
10
+ *
11
+ * Credentials pick the backend on their own: set the Azure Speech pair and you
12
+ * are on MAI-Transcribe-2. PI_VOICE_PROVIDER only exists to break a tie, or to
13
+ * force `mock` while testing.
14
+ *
15
+ * Keys live in ~/.pi/agent/voice.env, beside pi's own configuration. Anything
16
+ * already exported takes precedence, which is also how you override one for a
17
+ * single run.
18
+ */
19
+
20
+ const ENV_FILE = path.join(os.homedir(), ".pi", "agent", "voice.env");
21
+
22
+ let loadedEnvFile = null;
23
+
24
+ function loadEnvFile() {
25
+ if (loadedEnvFile !== null) return loadedEnvFile;
26
+ loadedEnvFile = "";
27
+ if (!fs.existsSync(ENV_FILE)) return loadedEnvFile;
28
+
29
+ try {
30
+ // Node applies the same rule as --env-file: the real environment wins.
31
+ process.loadEnvFile(ENV_FILE);
32
+ loadedEnvFile = ENV_FILE;
33
+
34
+ const mode = fs.statSync(ENV_FILE).mode & 0o077;
35
+ if (mode !== 0 && process.platform !== "win32") {
36
+ console.warn(`[pi-web-voice] ${ENV_FILE} is readable by others; chmod 600 it`);
37
+ }
38
+ } catch (error) {
39
+ console.error(`[pi-web-voice] cannot read ${ENV_FILE}: ${error.message}`);
40
+ }
41
+ return loadedEnvFile;
42
+ }
43
+
44
+ // Not worth configuring: one correct value each.
45
+ const PREFIX = "/__voice";
46
+ const SPEECH_MODEL = "MAI-Transcribe-2";
47
+ const SPEECH_API_VERSION = "2025-10-15";
48
+ const SPEECH_STYLE = "clean"; // dictation wants the fillers gone
49
+ const OPENAI_API_VERSION = "2024-10-21";
50
+ const MAX_TERMS = 400; // further capped per provider
51
+ const CONTEXT_BYTES = 256 * 1024; // read from the end of each session file
52
+ const PROJECT_SESSIONS = 5; // past sessions consulted alongside the current one
53
+ const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
54
+ const TIMEOUT_MS = 120_000;
55
+
56
+ function text(...values) {
57
+ for (const value of values) {
58
+ if (typeof value === "string" && value.trim()) return value.trim();
59
+ }
60
+ return "";
61
+ }
62
+
63
+ function list(value) {
64
+ return String(value ?? "")
65
+ .split(/[,\n]/)
66
+ .map((entry) => entry.trim())
67
+ .filter(Boolean);
68
+ }
69
+
70
+ /** Accepts a bare resource name, a resource URL, or a full endpoint URL. */
71
+ function azureEndpoint(value, suffix) {
72
+ const raw = text(value);
73
+ if (!raw) return "";
74
+ if (/^https?:\/\//i.test(raw)) return raw.replace(/\/+$/, "");
75
+ return `https://${raw}.${suffix}`;
76
+ }
77
+
78
+ function loadConfig() {
79
+ const envFile = loadEnvFile();
80
+ const env = process.env;
81
+
82
+ const provider = text(
83
+ env.PI_VOICE_PROVIDER,
84
+ env.AZURE_SPEECH_KEY ? "azure-speech" : "",
85
+ env.AZURE_OPENAI_API_KEY ? "azure-openai" : "",
86
+ env.OPENAI_API_KEY ? "openai" : "",
87
+ "mock",
88
+ );
89
+
90
+ return {
91
+ prefix: PREFIX,
92
+ provider,
93
+ envFile,
94
+
95
+ // The vocabulary sent with each request is mined from the conversation you
96
+ // are in, plus that project's recent ones; see lib/context.cjs.
97
+ context: {
98
+ maxTerms: MAX_TERMS,
99
+ bytes: CONTEXT_BYTES,
100
+ sessions: PROJECT_SESSIONS,
101
+ },
102
+
103
+ // Azure AI Speech fast transcription — MAI-Transcribe-2.
104
+ azureSpeech: {
105
+ endpoint: azureEndpoint(env.AZURE_SPEECH_ENDPOINT, "cognitiveservices.azure.com"),
106
+ key: text(env.AZURE_SPEECH_KEY),
107
+ model: SPEECH_MODEL,
108
+ apiVersion: SPEECH_API_VERSION,
109
+ style: SPEECH_STYLE,
110
+ },
111
+
112
+ // Azure OpenAI — gpt-transcribe, gpt-4o-transcribe, whisper.
113
+ // The endpoint may be a bare resource name, a resource URL, or the full
114
+ // transcriptions URL copied out of the portal. Azure has shipped several
115
+ // shapes of this path, so a complete URL is taken at its word.
116
+ azureOpenAI: {
117
+ endpoint: azureEndpoint(env.AZURE_OPENAI_ENDPOINT, "openai.azure.com"),
118
+ key: text(env.AZURE_OPENAI_API_KEY),
119
+ deployment: text(env.PI_VOICE_DEPLOYMENT, "gpt-transcribe"),
120
+ apiVersion: OPENAI_API_VERSION,
121
+ },
122
+
123
+ // Anything speaking the OpenAI audio API: OpenAI, Groq, local whisper.cpp.
124
+ openai: {
125
+ baseUrl: text(env.PI_VOICE_OPENAI_BASE_URL, "https://api.openai.com/v1"),
126
+ key: text(env.OPENAI_API_KEY),
127
+ model: text(env.PI_VOICE_OPENAI_MODEL, "whisper-1"),
128
+ },
129
+
130
+ limits: { maxBytes: MAX_UPLOAD_BYTES, timeoutMs: TIMEOUT_MS },
131
+ };
132
+ }
133
+
134
+ module.exports = { loadConfig, ENV_FILE };