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/public/inject.js
ADDED
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-web-voice — browser side.
|
|
3
|
+
*
|
|
4
|
+
* Adds a microphone button to the pi-web composer. Recording is captured as
|
|
5
|
+
* 16 kHz mono PCM WAV in the page, posted to the hook's /transcribe route, and
|
|
6
|
+
* the returned text is inserted at the caret. Nothing is sent anywhere except
|
|
7
|
+
* the pi-web origin you are already talking to.
|
|
8
|
+
*/
|
|
9
|
+
(() => {
|
|
10
|
+
"use strict";
|
|
11
|
+
|
|
12
|
+
const CONFIG = Object.assign(
|
|
13
|
+
{
|
|
14
|
+
prefix: "/__voice",
|
|
15
|
+
provider: "mock",
|
|
16
|
+
},
|
|
17
|
+
window.__PI_WEB_VOICE__ || {},
|
|
18
|
+
);
|
|
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
|
+
const MAX_SECONDS = 180;
|
|
24
|
+
const SHORTCUT = "mod+shift+v";
|
|
25
|
+
|
|
26
|
+
const SAMPLE_RATE = 16000;
|
|
27
|
+
const BUTTON_ID = "pi-web-voice-button";
|
|
28
|
+
|
|
29
|
+
// ── which conversation is this tab on ────────────────────────────────────
|
|
30
|
+
//
|
|
31
|
+
// pi-web opens `new EventSource("/api/agent/<id>/events")` for the session it
|
|
32
|
+
// is showing, so watching that call gives the exact session id — no guessing
|
|
33
|
+
// from "most recent", and it follows every session switch. This runs before
|
|
34
|
+
// the app's own bundle, which is why the injected tag is not deferred.
|
|
35
|
+
|
|
36
|
+
const SESSION_URL = /\/api\/agent\/([^/?#]+)\/events/;
|
|
37
|
+
let sessionId = "";
|
|
38
|
+
let cwd = "";
|
|
39
|
+
|
|
40
|
+
function noteSession(url) {
|
|
41
|
+
const match = SESSION_URL.exec(String(url));
|
|
42
|
+
if (match) sessionId = decodeURIComponent(match[1]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The sidebar renders the working directory as a button title. */
|
|
46
|
+
function currentCwd() {
|
|
47
|
+
if (cwd) return cwd;
|
|
48
|
+
for (const button of document.querySelectorAll("button[title]")) {
|
|
49
|
+
const title = button.getAttribute("title") ?? "";
|
|
50
|
+
if (/^(\/[^\s]+|[A-Za-z]:\\[^\s]+)$/.test(title)) return title;
|
|
51
|
+
}
|
|
52
|
+
return "";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (window.EventSource) {
|
|
56
|
+
const NativeEventSource = window.EventSource;
|
|
57
|
+
const Patched = function EventSource(url, ...rest) {
|
|
58
|
+
noteSession(url);
|
|
59
|
+
return new NativeEventSource(url, ...rest);
|
|
60
|
+
};
|
|
61
|
+
Patched.prototype = NativeEventSource.prototype;
|
|
62
|
+
for (const key of ["CONNECTING", "OPEN", "CLOSED"]) Patched[key] = NativeEventSource[key];
|
|
63
|
+
window.EventSource = Patched;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Backup: the app also POSTs to /api/agent/<id> when sending a prompt, and
|
|
67
|
+
// /api/agent/new carries the working directory of a session about to exist.
|
|
68
|
+
const nativeFetch = window.fetch;
|
|
69
|
+
window.fetch = function fetch(input, init) {
|
|
70
|
+
try {
|
|
71
|
+
const url = typeof input === "string" ? input : input?.url;
|
|
72
|
+
const match = /\/api\/agent\/([^/?#]+)(?:$|\?)/.exec(String(url ?? ""));
|
|
73
|
+
if (match && match[1] !== "new" && match[1] !== "running") {
|
|
74
|
+
sessionId = decodeURIComponent(match[1]);
|
|
75
|
+
}
|
|
76
|
+
if (match && match[1] === "new" && typeof init?.body === "string") {
|
|
77
|
+
const parsed = JSON.parse(init.body);
|
|
78
|
+
if (typeof parsed.cwd === "string") cwd = parsed.cwd;
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
/* never let bookkeeping break a request */
|
|
82
|
+
}
|
|
83
|
+
return nativeFetch.call(this, input, init);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// pi-web ships English, Simplified Chinese and Traditional Chinese.
|
|
87
|
+
const ATTACH_TITLES = ["Attach image", "附加图片", "附加圖片"];
|
|
88
|
+
|
|
89
|
+
const zh = (navigator.language || "").toLowerCase().startsWith("zh");
|
|
90
|
+
const T = zh
|
|
91
|
+
? {
|
|
92
|
+
idle: "语音输入 — 点击开始,或按住说话",
|
|
93
|
+
recording: "正在录音 — 点击停止",
|
|
94
|
+
holding: "松开结束录音",
|
|
95
|
+
working: "转写中…",
|
|
96
|
+
insecure: "浏览器只在 HTTPS 或 localhost 下允许使用麦克风",
|
|
97
|
+
denied: "麦克风权限被拒绝",
|
|
98
|
+
empty: "没有识别到语音",
|
|
99
|
+
failed: "转写失败",
|
|
100
|
+
}
|
|
101
|
+
: {
|
|
102
|
+
idle: "Voice input — click, or press and hold",
|
|
103
|
+
recording: "Recording — click to stop",
|
|
104
|
+
holding: "Release to stop",
|
|
105
|
+
working: "Transcribing…",
|
|
106
|
+
insecure: "Microphone needs HTTPS or localhost",
|
|
107
|
+
denied: "Microphone permission denied",
|
|
108
|
+
empty: "No speech detected",
|
|
109
|
+
failed: "Transcription failed",
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// ── audio ────────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
/** Averages a Float32 buffer down to the target rate. */
|
|
115
|
+
function downsample(input, fromRate, toRate) {
|
|
116
|
+
if (fromRate === toRate) return input;
|
|
117
|
+
const ratio = fromRate / toRate;
|
|
118
|
+
const output = new Float32Array(Math.round(input.length / ratio));
|
|
119
|
+
for (let i = 0; i < output.length; i += 1) {
|
|
120
|
+
const start = Math.round(i * ratio);
|
|
121
|
+
const end = Math.min(Math.round((i + 1) * ratio), input.length);
|
|
122
|
+
let sum = 0;
|
|
123
|
+
for (let j = start; j < end; j += 1) sum += input[j];
|
|
124
|
+
output[i] = end > start ? sum / (end - start) : 0;
|
|
125
|
+
}
|
|
126
|
+
return output;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function encodeWav(samples, sampleRate) {
|
|
130
|
+
const buffer = new ArrayBuffer(44 + samples.length * 2);
|
|
131
|
+
const view = new DataView(buffer);
|
|
132
|
+
const ascii = (offset, text) => {
|
|
133
|
+
for (let i = 0; i < text.length; i += 1) view.setUint8(offset + i, text.charCodeAt(i));
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
ascii(0, "RIFF");
|
|
137
|
+
view.setUint32(4, 36 + samples.length * 2, true);
|
|
138
|
+
ascii(8, "WAVEfmt ");
|
|
139
|
+
view.setUint32(16, 16, true); // PCM chunk size
|
|
140
|
+
view.setUint16(20, 1, true); // format: PCM
|
|
141
|
+
view.setUint16(22, 1, true); // channels: mono
|
|
142
|
+
view.setUint32(24, sampleRate, true);
|
|
143
|
+
view.setUint32(28, sampleRate * 2, true); // byte rate
|
|
144
|
+
view.setUint16(32, 2, true); // block align
|
|
145
|
+
view.setUint16(34, 16, true); // bits per sample
|
|
146
|
+
ascii(36, "data");
|
|
147
|
+
view.setUint32(40, samples.length * 2, true);
|
|
148
|
+
|
|
149
|
+
let offset = 44;
|
|
150
|
+
for (let i = 0; i < samples.length; i += 1, offset += 2) {
|
|
151
|
+
const clamped = Math.max(-1, Math.min(1, samples[i]));
|
|
152
|
+
view.setInt16(offset, clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff, true);
|
|
153
|
+
}
|
|
154
|
+
return new Blob([buffer], { type: "audio/wav" });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const recorder = {
|
|
158
|
+
active: false,
|
|
159
|
+
stream: null,
|
|
160
|
+
context: null,
|
|
161
|
+
node: null,
|
|
162
|
+
chunks: [],
|
|
163
|
+
startedAt: 0,
|
|
164
|
+
|
|
165
|
+
async start() {
|
|
166
|
+
if (!window.isSecureContext) throw new Error(T.insecure);
|
|
167
|
+
if (!navigator.mediaDevices?.getUserMedia) throw new Error(T.insecure);
|
|
168
|
+
|
|
169
|
+
this.stream = await navigator.mediaDevices.getUserMedia({
|
|
170
|
+
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
|
|
171
|
+
});
|
|
172
|
+
this.context = new (window.AudioContext || window.webkitAudioContext)();
|
|
173
|
+
if (this.context.state === "suspended") await this.context.resume();
|
|
174
|
+
|
|
175
|
+
const source = this.context.createMediaStreamSource(this.stream);
|
|
176
|
+
// ScriptProcessor is deprecated but is the only node supported by every
|
|
177
|
+
// browser without shipping a separate worklet module.
|
|
178
|
+
this.node = this.context.createScriptProcessor(4096, 1, 1);
|
|
179
|
+
this.chunks = [];
|
|
180
|
+
this.startedAt = Date.now();
|
|
181
|
+
|
|
182
|
+
this.node.onaudioprocess = (event) => {
|
|
183
|
+
if (!this.active) return;
|
|
184
|
+
const input = event.inputBuffer.getChannelData(0);
|
|
185
|
+
this.chunks.push(downsample(input, this.context.sampleRate, SAMPLE_RATE));
|
|
186
|
+
if ((Date.now() - this.startedAt) / 1000 > MAX_SECONDS) ui.stop();
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// Route through a silent gain node so the graph runs without echoing
|
|
190
|
+
// the microphone back to the speakers.
|
|
191
|
+
const mute = this.context.createGain();
|
|
192
|
+
mute.gain.value = 0;
|
|
193
|
+
source.connect(this.node);
|
|
194
|
+
this.node.connect(mute);
|
|
195
|
+
mute.connect(this.context.destination);
|
|
196
|
+
|
|
197
|
+
this.active = true;
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
stop() {
|
|
201
|
+
this.active = false;
|
|
202
|
+
try {
|
|
203
|
+
this.node?.disconnect();
|
|
204
|
+
this.stream?.getTracks().forEach((track) => track.stop());
|
|
205
|
+
this.context?.close();
|
|
206
|
+
} catch {
|
|
207
|
+
/* teardown is best effort */
|
|
208
|
+
}
|
|
209
|
+
this.node = null;
|
|
210
|
+
this.stream = null;
|
|
211
|
+
this.context = null;
|
|
212
|
+
|
|
213
|
+
const total = this.chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
214
|
+
const merged = new Float32Array(total);
|
|
215
|
+
let offset = 0;
|
|
216
|
+
for (const chunk of this.chunks) {
|
|
217
|
+
merged.set(chunk, offset);
|
|
218
|
+
offset += chunk.length;
|
|
219
|
+
}
|
|
220
|
+
this.chunks = [];
|
|
221
|
+
return merged.length > 0 ? encodeWav(merged, SAMPLE_RATE) : null;
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// ── composer ─────────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
function findComposer() {
|
|
228
|
+
const areas = Array.from(document.querySelectorAll("textarea")).filter(
|
|
229
|
+
(area) => area.offsetParent !== null,
|
|
230
|
+
);
|
|
231
|
+
return areas[areas.length - 1] || null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function insertAtCaret(textarea, text) {
|
|
235
|
+
const start = textarea.selectionStart ?? textarea.value.length;
|
|
236
|
+
const end = textarea.selectionEnd ?? start;
|
|
237
|
+
const before = textarea.value.slice(0, start);
|
|
238
|
+
const after = textarea.value.slice(end);
|
|
239
|
+
const spacer = before && !/\s$/.test(before) ? " " : "";
|
|
240
|
+
const next = before + spacer + text + after;
|
|
241
|
+
|
|
242
|
+
// React tracks the previous value on the DOM node, so assigning `.value`
|
|
243
|
+
// directly is ignored. Going through the prototype setter and dispatching
|
|
244
|
+
// a real input event makes React pick the change up.
|
|
245
|
+
const setter = Object.getOwnPropertyDescriptor(
|
|
246
|
+
window.HTMLTextAreaElement.prototype,
|
|
247
|
+
"value",
|
|
248
|
+
).set;
|
|
249
|
+
setter.call(textarea, next);
|
|
250
|
+
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
|
251
|
+
|
|
252
|
+
const caret = before.length + spacer.length + text.length;
|
|
253
|
+
textarea.setSelectionRange(caret, caret);
|
|
254
|
+
textarea.focus();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
// ── button ───────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
// A filled glyph reads as an ordinary control; the outlined one looked
|
|
262
|
+
// greyed out next to pi-web's own toolbar icons.
|
|
263
|
+
const MIC_SVG = `<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 14.5a3.25 3.25 0 0 0 3.25-3.25v-6a3.25 3.25 0 0 0-6.5 0v6A3.25 3.25 0 0 0 12 14.5z"/><path d="M17.75 11a.85.85 0 0 0-1.7 0 4.05 4.05 0 0 1-8.1 0 .85.85 0 0 0-1.7 0 5.75 5.75 0 0 0 4.9 5.68v1.62h-1.9a.85.85 0 0 0 0 1.7h5.5a.85.85 0 0 0 0-1.7h-1.9v-1.62A5.75 5.75 0 0 0 17.75 11z"/></svg>`;
|
|
264
|
+
|
|
265
|
+
const SPINNER_SVG = `<svg class="pi-voice-spin" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9" opacity=".25"/><path d="M21 12a9 9 0 0 0-9-9"/></svg>`;
|
|
266
|
+
|
|
267
|
+
const STYLE_ID = "pi-web-voice-style";
|
|
268
|
+
|
|
269
|
+
function ensureStyles() {
|
|
270
|
+
if (document.getElementById(STYLE_ID)) return;
|
|
271
|
+
const style = document.createElement("style");
|
|
272
|
+
style.id = STYLE_ID;
|
|
273
|
+
style.textContent = `
|
|
274
|
+
@keyframes pi-voice-spin { to { transform: rotate(360deg); } }
|
|
275
|
+
@keyframes pi-voice-pulse { 0%,100% { opacity: 1; } 50% { opacity: .45; } }
|
|
276
|
+
.pi-voice-spin { animation: pi-voice-spin .8s linear infinite; transform-origin: 50% 50%; }
|
|
277
|
+
.pi-voice-pulse { animation: pi-voice-pulse 1.2s ease-in-out infinite; }
|
|
278
|
+
#${BUTTON_ID}:hover { background: var(--bg-hover); color: var(--text); }
|
|
279
|
+
#${BUTTON_ID}:active { transform: scale(.94); }
|
|
280
|
+
`;
|
|
281
|
+
document.head.appendChild(style);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const ui = {
|
|
285
|
+
button: null,
|
|
286
|
+
state: "idle", // idle | recording | working
|
|
287
|
+
timer: null,
|
|
288
|
+
|
|
289
|
+
findAnchor() {
|
|
290
|
+
for (const title of ATTACH_TITLES) {
|
|
291
|
+
const attach = document.querySelector(`button[title="${title}"]`);
|
|
292
|
+
if (attach) return attach;
|
|
293
|
+
}
|
|
294
|
+
return document.querySelector(".model-selector.is-toolbar");
|
|
295
|
+
},
|
|
296
|
+
|
|
297
|
+
mount() {
|
|
298
|
+
if (document.getElementById(BUTTON_ID)) return;
|
|
299
|
+
const anchor = this.findAnchor();
|
|
300
|
+
if (!anchor?.parentElement) return;
|
|
301
|
+
ensureStyles();
|
|
302
|
+
|
|
303
|
+
const button = document.createElement("button");
|
|
304
|
+
button.id = BUTTON_ID;
|
|
305
|
+
button.type = "button";
|
|
306
|
+
button.title = T.idle;
|
|
307
|
+
button.setAttribute("aria-label", T.idle);
|
|
308
|
+
button.style.cssText = [
|
|
309
|
+
"position:relative",
|
|
310
|
+
"display:flex",
|
|
311
|
+
"align-items:center",
|
|
312
|
+
"justify-content:center",
|
|
313
|
+
"gap:4px",
|
|
314
|
+
"min-width:26px",
|
|
315
|
+
"height:26px",
|
|
316
|
+
"padding:0 5px",
|
|
317
|
+
"background:none",
|
|
318
|
+
"border:none",
|
|
319
|
+
"color:var(--text-muted)",
|
|
320
|
+
"cursor:pointer",
|
|
321
|
+
"border-radius:5px",
|
|
322
|
+
"flex-shrink:0",
|
|
323
|
+
"font-size:11px",
|
|
324
|
+
"font-variant-numeric:tabular-nums",
|
|
325
|
+
"transition:color .15s,background .15s,transform .1s",
|
|
326
|
+
].join(";");
|
|
327
|
+
|
|
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
|
+
});
|
|
344
|
+
|
|
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);
|
|
352
|
+
|
|
353
|
+
anchor.parentElement.insertBefore(button, anchor);
|
|
354
|
+
this.button = button;
|
|
355
|
+
this.render();
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
render(extra = "") {
|
|
359
|
+
if (!this.button) return;
|
|
360
|
+
|
|
361
|
+
const spinning = this.state === "working";
|
|
362
|
+
const recording = this.state === "recording";
|
|
363
|
+
|
|
364
|
+
this.button.style.color = recording
|
|
365
|
+
? "#e5534b"
|
|
366
|
+
: spinning
|
|
367
|
+
? "var(--accent)"
|
|
368
|
+
: "var(--text-muted)";
|
|
369
|
+
this.button.style.background = recording ? "rgba(229,83,75,.12)" : "";
|
|
370
|
+
this.button.title = recording ? T.recording : spinning ? T.working : T.idle;
|
|
371
|
+
this.button.setAttribute("aria-label", this.button.title);
|
|
372
|
+
this.button.setAttribute("aria-busy", spinning ? "true" : "false");
|
|
373
|
+
|
|
374
|
+
const icon = spinning
|
|
375
|
+
? SPINNER_SVG
|
|
376
|
+
: recording
|
|
377
|
+
? MIC_SVG.replace("<svg ", '<svg class="pi-voice-pulse" ')
|
|
378
|
+
: MIC_SVG;
|
|
379
|
+
this.button.innerHTML = extra ? `${icon}<span>${extra}</span>` : icon;
|
|
380
|
+
},
|
|
381
|
+
|
|
382
|
+
toast(message, isError = true) {
|
|
383
|
+
const toast = document.createElement("div");
|
|
384
|
+
toast.textContent = message;
|
|
385
|
+
toast.style.cssText = [
|
|
386
|
+
"position:fixed",
|
|
387
|
+
"left:50%",
|
|
388
|
+
"bottom:80px",
|
|
389
|
+
"transform:translateX(-50%)",
|
|
390
|
+
"z-index:99999",
|
|
391
|
+
"padding:8px 14px",
|
|
392
|
+
"border-radius:8px",
|
|
393
|
+
"font-size:13px",
|
|
394
|
+
"color:#fff",
|
|
395
|
+
`background:${isError ? "#b4342c" : "#2f6f4f"}`,
|
|
396
|
+
"box-shadow:0 6px 24px rgba(0,0,0,.35)",
|
|
397
|
+
].join(";");
|
|
398
|
+
document.body.appendChild(toast);
|
|
399
|
+
setTimeout(() => toast.remove(), 4000);
|
|
400
|
+
},
|
|
401
|
+
|
|
402
|
+
async start() {
|
|
403
|
+
if (this.state !== "idle") return;
|
|
404
|
+
try {
|
|
405
|
+
await recorder.start();
|
|
406
|
+
} catch (error) {
|
|
407
|
+
const denied = error?.name === "NotAllowedError";
|
|
408
|
+
this.toast(denied ? T.denied : error.message || T.failed);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
this.state = "recording";
|
|
412
|
+
this.render("0:00");
|
|
413
|
+
this.timer = setInterval(() => {
|
|
414
|
+
const seconds = Math.floor((Date.now() - recorder.startedAt) / 1000);
|
|
415
|
+
this.render(`${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`);
|
|
416
|
+
}, 250);
|
|
417
|
+
},
|
|
418
|
+
|
|
419
|
+
async stop() {
|
|
420
|
+
if (this.state !== "recording") return;
|
|
421
|
+
clearInterval(this.timer);
|
|
422
|
+
const wav = recorder.stop();
|
|
423
|
+
this.state = "working";
|
|
424
|
+
this.render();
|
|
425
|
+
|
|
426
|
+
if (!wav) {
|
|
427
|
+
this.state = "idle";
|
|
428
|
+
this.render();
|
|
429
|
+
this.toast(T.empty);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
try {
|
|
434
|
+
const query = new URLSearchParams();
|
|
435
|
+
if (sessionId) query.set("session", sessionId);
|
|
436
|
+
const where = currentCwd();
|
|
437
|
+
if (where) query.set("cwd", where);
|
|
438
|
+
const suffix = query.toString() ? `?${query}` : "";
|
|
439
|
+
|
|
440
|
+
const response = await nativeFetch(`${CONFIG.prefix}/transcribe${suffix}`, {
|
|
441
|
+
method: "POST",
|
|
442
|
+
headers: { "content-type": "audio/wav" },
|
|
443
|
+
body: wav,
|
|
444
|
+
credentials: "include",
|
|
445
|
+
});
|
|
446
|
+
const result = await response.json();
|
|
447
|
+
if (!response.ok) throw new Error(result.error || T.failed);
|
|
448
|
+
|
|
449
|
+
const text = (result.text || "").trim();
|
|
450
|
+
if (!text) {
|
|
451
|
+
this.toast(T.empty);
|
|
452
|
+
} else {
|
|
453
|
+
// Always inserted, never sent: a wrong term is one keystroke from
|
|
454
|
+
// being fixed, and Enter is right there when it is correct.
|
|
455
|
+
const textarea = findComposer();
|
|
456
|
+
if (textarea) insertAtCaret(textarea, text);
|
|
457
|
+
}
|
|
458
|
+
} catch (error) {
|
|
459
|
+
this.toast(`${T.failed}: ${error.message}`);
|
|
460
|
+
} finally {
|
|
461
|
+
this.state = "idle";
|
|
462
|
+
this.render();
|
|
463
|
+
}
|
|
464
|
+
},
|
|
465
|
+
|
|
466
|
+
toggle() {
|
|
467
|
+
if (this.state === "recording") this.stop();
|
|
468
|
+
else if (this.state === "idle") this.start();
|
|
469
|
+
},
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
// ── wiring ───────────────────────────────────────────────────────────────
|
|
473
|
+
|
|
474
|
+
function matchesShortcut(event) {
|
|
475
|
+
const parts = SHORTCUT.toLowerCase().split("+");
|
|
476
|
+
const key = parts[parts.length - 1];
|
|
477
|
+
const wantMod = parts.includes("mod");
|
|
478
|
+
const wantShift = parts.includes("shift");
|
|
479
|
+
const wantAlt = parts.includes("alt");
|
|
480
|
+
const mod = event.metaKey || event.ctrlKey;
|
|
481
|
+
return (
|
|
482
|
+
event.key.toLowerCase() === key &&
|
|
483
|
+
mod === wantMod &&
|
|
484
|
+
event.shiftKey === wantShift &&
|
|
485
|
+
event.altKey === wantAlt
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
document.addEventListener("keydown", (event) => {
|
|
490
|
+
if (!matchesShortcut(event)) return;
|
|
491
|
+
event.preventDefault();
|
|
492
|
+
ui.toggle();
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
if ("mediaSession" in navigator) {
|
|
496
|
+
// Headphone play/pause — a squeeze on AirPods starts and stops recording.
|
|
497
|
+
try {
|
|
498
|
+
navigator.mediaSession.setActionHandler("play", () => ui.toggle());
|
|
499
|
+
navigator.mediaSession.setActionHandler("pause", () => ui.toggle());
|
|
500
|
+
} catch {
|
|
501
|
+
/* not supported here */
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// pi-web re-renders the composer on session switches, so keep re-mounting.
|
|
506
|
+
const observer = new MutationObserver(() => ui.mount());
|
|
507
|
+
const boot = () => {
|
|
508
|
+
ui.mount();
|
|
509
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
|
|
513
|
+
else boot();
|
|
514
|
+
|
|
515
|
+
window.__piWebVoice = {
|
|
516
|
+
ui,
|
|
517
|
+
recorder,
|
|
518
|
+
config: CONFIG,
|
|
519
|
+
get sessionId() {
|
|
520
|
+
return sessionId;
|
|
521
|
+
},
|
|
522
|
+
get cwd() {
|
|
523
|
+
return currentCwd();
|
|
524
|
+
},
|
|
525
|
+
};
|
|
526
|
+
})();
|