pi-web-voice 0.1.3 → 0.1.5
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/README.md +4 -4
- package/package.json +1 -1
- package/public/inject.js +36 -33
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Voice input for [pi-web](https://github.com/agegr/pi-web), added from the outside.
|
|
4
4
|
|
|
5
|
-
A microphone button appears in the chat composer.
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
A microphone button appears in the chat composer. Click it to start, click again to stop.
|
|
6
|
+
The transcript lands at your caret; you review it, then send. Keyboard, VoiceOver and the
|
|
7
|
+
accessibility API can press it too. pi-web is never modified: the
|
|
8
8
|
whole thing is one `--require` hook that injects a single `<script>` tag into HTML
|
|
9
9
|
responses and serves two routes of its own.
|
|
10
10
|
|
|
@@ -87,7 +87,7 @@ file, loaded by Node itself — no dependency, no parser of ours:
|
|
|
87
87
|
```sh
|
|
88
88
|
AZURE_SPEECH_ENDPOINT=https://my-resource.cognitiveservices.azure.com
|
|
89
89
|
AZURE_SPEECH_KEY=abc123...
|
|
90
|
-
|
|
90
|
+
PI_VOICE_PROVIDER=azure-speech
|
|
91
91
|
```
|
|
92
92
|
|
|
93
93
|
Anything already exported wins over the file, so `AZURE_SPEECH_KEY=other pi-web-voice`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-web-voice",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Voice input for pi-web. A NODE_OPTIONS hook that injects a microphone button into the chat composer and transcribes speech with Azure AI Speech, Azure OpenAI, or any OpenAI-compatible endpoint. No fork, no patching, no rebuild.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
package/public/inject.js
CHANGED
|
@@ -17,9 +17,6 @@
|
|
|
17
17
|
window.__PI_WEB_VOICE__ || {},
|
|
18
18
|
);
|
|
19
19
|
|
|
20
|
-
// A press shorter than this latches recording on, so a tap toggles and a
|
|
21
|
-
// long press is walkie-talkie. No setting needed: the gesture says which.
|
|
22
|
-
const HOLD_THRESHOLD_MS = 400;
|
|
23
20
|
const MAX_SECONDS = 180;
|
|
24
21
|
const SHORTCUT = "mod+shift+v";
|
|
25
22
|
|
|
@@ -89,24 +86,24 @@
|
|
|
89
86
|
const zh = (navigator.language || "").toLowerCase().startsWith("zh");
|
|
90
87
|
const T = zh
|
|
91
88
|
? {
|
|
92
|
-
idle: "语音输入 —
|
|
89
|
+
idle: "语音输入 — 点击开始,再点一次结束",
|
|
93
90
|
recording: "正在录音 — 点击停止",
|
|
94
|
-
holding: "松开结束录音",
|
|
95
91
|
working: "转写中…",
|
|
96
92
|
insecure: "浏览器只在 HTTPS 或 localhost 下允许使用麦克风",
|
|
97
93
|
denied: "麦克风权限被拒绝",
|
|
98
94
|
empty: "没有识别到语音",
|
|
99
95
|
failed: "转写失败",
|
|
96
|
+
noComposer: "找不到输入框,转写结果",
|
|
100
97
|
}
|
|
101
98
|
: {
|
|
102
|
-
idle: "Voice input — click,
|
|
99
|
+
idle: "Voice input — click to start, click again to stop",
|
|
103
100
|
recording: "Recording — click to stop",
|
|
104
|
-
holding: "Release to stop",
|
|
105
101
|
working: "Transcribing…",
|
|
106
102
|
insecure: "Microphone needs HTTPS or localhost",
|
|
107
103
|
denied: "Microphone permission denied",
|
|
108
104
|
empty: "No speech detected",
|
|
109
105
|
failed: "Transcription failed",
|
|
106
|
+
noComposer: "No composer found; transcript",
|
|
110
107
|
};
|
|
111
108
|
|
|
112
109
|
// ── audio ────────────────────────────────────────────────────────────────
|
|
@@ -224,10 +221,26 @@
|
|
|
224
221
|
|
|
225
222
|
// ── composer ─────────────────────────────────────────────────────────────
|
|
226
223
|
|
|
224
|
+
// "The last visible textarea" is not good enough: pi-web's workspace
|
|
225
|
+
// terminal is xterm.js, which keeps a hidden IME helper textarea mounted
|
|
226
|
+
// after the composer. Writing there drops the transcript at best, and at
|
|
227
|
+
// worst hands it to the shell.
|
|
228
|
+
function isComposerCandidate(area) {
|
|
229
|
+
if (area.offsetParent === null) return false;
|
|
230
|
+
if (area.classList.contains("xterm-helper-textarea")) return false;
|
|
231
|
+
return !area.closest(".xterm");
|
|
232
|
+
}
|
|
233
|
+
|
|
227
234
|
function findComposer() {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
);
|
|
235
|
+
// The button was mounted next to the composer's own toolbar, so walking up
|
|
236
|
+
// from it finds the right textarea even when the page holds several.
|
|
237
|
+
const button = document.getElementById(BUTTON_ID);
|
|
238
|
+
for (let node = button?.parentElement; node; node = node.parentElement) {
|
|
239
|
+
const nearby = Array.from(node.querySelectorAll("textarea")).filter(isComposerCandidate);
|
|
240
|
+
if (nearby.length) return nearby[nearby.length - 1];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const areas = Array.from(document.querySelectorAll("textarea")).filter(isComposerCandidate);
|
|
231
244
|
return areas[areas.length - 1] || null;
|
|
232
245
|
}
|
|
233
246
|
|
|
@@ -325,30 +338,16 @@
|
|
|
325
338
|
"transition:color .15s,background .15s,transform .1s",
|
|
326
339
|
].join(";");
|
|
327
340
|
|
|
328
|
-
// One button,
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
button.addEventListener("pointerdown", (event) => {
|
|
334
|
-
event.preventDefault();
|
|
335
|
-
if (this.state === "recording") {
|
|
336
|
-
latched = false;
|
|
337
|
-
this.stop();
|
|
338
|
-
return;
|
|
339
|
-
}
|
|
340
|
-
pressedAt = Date.now();
|
|
341
|
-
latched = false;
|
|
342
|
-
this.start();
|
|
343
|
-
});
|
|
341
|
+
// One button, one gesture: click to start, click again to stop. Going
|
|
342
|
+
// through `click` rather than pointer events is what makes the keyboard,
|
|
343
|
+
// VoiceOver and the accessibility API able to press it at all — none of
|
|
344
|
+
// them produce pointer events.
|
|
345
|
+
button.addEventListener("click", () => this.toggle());
|
|
344
346
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
};
|
|
350
|
-
button.addEventListener("pointerup", release);
|
|
351
|
-
button.addEventListener("pointercancel", release);
|
|
347
|
+
// A button steals focus from the composer on mousedown. Refusing that
|
|
348
|
+
// default keeps the caret where it was, and on a phone keeps the
|
|
349
|
+
// on-screen keyboard from collapsing under the composer.
|
|
350
|
+
button.addEventListener("mousedown", (event) => event.preventDefault());
|
|
352
351
|
|
|
353
352
|
anchor.parentElement.insertBefore(button, anchor);
|
|
354
353
|
this.button = button;
|
|
@@ -453,7 +452,10 @@
|
|
|
453
452
|
// Always inserted, never sent: a wrong term is one keystroke from
|
|
454
453
|
// being fixed, and Enter is right there when it is correct.
|
|
455
454
|
const textarea = findComposer();
|
|
455
|
+
// Never fail silently: a transcript with nowhere to go is shown
|
|
456
|
+
// rather than dropped, so it can still be copied by hand.
|
|
456
457
|
if (textarea) insertAtCaret(textarea, text);
|
|
458
|
+
else this.toast(`${T.noComposer}: ${text}`);
|
|
457
459
|
}
|
|
458
460
|
} catch (error) {
|
|
459
461
|
this.toast(`${T.failed}: ${error.message}`);
|
|
@@ -516,6 +518,7 @@
|
|
|
516
518
|
ui,
|
|
517
519
|
recorder,
|
|
518
520
|
config: CONFIG,
|
|
521
|
+
findComposer,
|
|
519
522
|
get sessionId() {
|
|
520
523
|
return sessionId;
|
|
521
524
|
},
|