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 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. 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
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
- PI_VOICE_MODE=hold
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",
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, or press and hold",
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
- const areas = Array.from(document.querySelectorAll("textarea")).filter(
229
- (area) => area.offsetParent !== null,
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, two gestures. A quick tap latches recording on and the
329
- // next tap ends it; holding records only while held.
330
- let pressedAt = 0;
331
- let latched = false;
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
- const release = () => {
346
- if (this.state !== "recording" || latched) return;
347
- if (Date.now() - pressedAt < HOLD_THRESHOLD_MS) latched = true; // a tap
348
- else this.stop(); // a hold
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
  },