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 +21 -0
- package/README.md +331 -0
- package/bin/pi-web-voice.js +107 -0
- package/hook.cjs +40 -0
- package/lib/config.cjs +134 -0
- package/lib/context.cjs +312 -0
- package/lib/doctor.cjs +126 -0
- package/lib/patch.cjs +204 -0
- package/lib/providers.cjs +202 -0
- package/lib/routes.cjs +134 -0
- package/package.json +44 -0
- package/public/inject.js +526 -0
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 };
|