dsh-voice-mode 0.2.2 → 0.3.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/README.en.md +40 -32
- package/README.md +51 -28
- package/assets/architecture.svg +1 -1
- package/lib/client.js +1640 -333
- package/lib/index.js +950 -147
- package/lib/sense-worker.mjs +128 -0
- package/package.json +6 -2
- package/scripts/bench-asr.mjs +289 -0
- package/scripts/prefetch.mjs +20 -7
- package/scripts/verify.mjs +34 -0
package/lib/client.js
CHANGED
|
@@ -42,34 +42,35 @@ module.exports = __toCommonJS(client_exports);
|
|
|
42
42
|
var React = __toESM(require("react"), 1);
|
|
43
43
|
var import_react2 = require("react");
|
|
44
44
|
|
|
45
|
-
// src/
|
|
46
|
-
function
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
45
|
+
// src/resample.ts
|
|
46
|
+
function resampleLinear(src, srcRate, dstRate) {
|
|
47
|
+
if (srcRate === dstRate) return src;
|
|
48
|
+
if (src.length === 0) return src;
|
|
49
|
+
const ratio = srcRate / dstRate;
|
|
50
|
+
const outLen = Math.max(1, Math.floor(src.length / ratio));
|
|
51
|
+
const out = new Float32Array(outLen);
|
|
52
|
+
for (let i = 0; i < outLen; i++) {
|
|
53
|
+
const pos = i * ratio;
|
|
54
|
+
const i0 = Math.floor(pos);
|
|
55
|
+
const i1 = Math.min(i0 + 1, src.length - 1);
|
|
56
|
+
const frac = pos - i0;
|
|
57
|
+
out[i] = src[i0] + (src[i1] - src[i0]) * frac;
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
56
60
|
}
|
|
57
61
|
|
|
58
62
|
// src/asr.ts
|
|
63
|
+
var workletBlobUrl = null;
|
|
59
64
|
var SAMPLE_RATE = 16e3;
|
|
60
65
|
var SPEECH_RMS = 0.015;
|
|
61
66
|
var LEVEL_CEILING = 0.25;
|
|
62
67
|
var MAX_SEGMENT_MS = 3e4;
|
|
68
|
+
var MIN_SPEECH_MS = 250;
|
|
63
69
|
var PRE_PAD_MS = 250;
|
|
64
|
-
var PARTIAL_INTERVAL_MS =
|
|
70
|
+
var PARTIAL_INTERVAL_MS = 100;
|
|
65
71
|
var PARTIAL_MIN_S = 0.4;
|
|
66
72
|
var PARTIAL_MAX_S = 30;
|
|
67
73
|
var BUFFER_SIZE = 1024;
|
|
68
|
-
var INTERRUPT_LEVELS = {
|
|
69
|
-
0: { rms: 0.1, ms: 500 },
|
|
70
|
-
1: { rms: 0.06, ms: 400 },
|
|
71
|
-
2: { rms: 0.035, ms: 300 }
|
|
72
|
-
};
|
|
73
74
|
function createAsrEngine(config, sessionId) {
|
|
74
75
|
let state = "idle";
|
|
75
76
|
const stateListeners = /* @__PURE__ */ new Set();
|
|
@@ -84,29 +85,45 @@ function createAsrEngine(config, sessionId) {
|
|
|
84
85
|
};
|
|
85
86
|
const transcriptListeners = /* @__PURE__ */ new Set();
|
|
86
87
|
const partialListeners = /* @__PURE__ */ new Set();
|
|
87
|
-
const speechStartListeners = /* @__PURE__ */ new Set();
|
|
88
88
|
const levelListeners = /* @__PURE__ */ new Set();
|
|
89
|
+
const telemetryListeners = /* @__PURE__ */ new Set();
|
|
90
|
+
let utteranceEndAt = null;
|
|
91
|
+
const emitTelemetry = (stage) => {
|
|
92
|
+
const ev = { stage, at: Date.now() };
|
|
93
|
+
for (const fn of telemetryListeners) {
|
|
94
|
+
try {
|
|
95
|
+
fn(ev);
|
|
96
|
+
} catch {
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
};
|
|
89
100
|
let audioCtx = null;
|
|
90
101
|
let stream = null;
|
|
91
102
|
let processor = null;
|
|
103
|
+
let workletNode = null;
|
|
92
104
|
let active = false;
|
|
105
|
+
let stopRequested = false;
|
|
106
|
+
let startSeq = 0;
|
|
93
107
|
let inFlush = false;
|
|
94
108
|
let ctxRate = SAMPLE_RATE;
|
|
95
109
|
let speechActive = false;
|
|
96
110
|
let segment = [];
|
|
97
111
|
let segmentMs = 0;
|
|
112
|
+
let speechMs = 0;
|
|
98
113
|
let silenceMs = 0;
|
|
99
114
|
let prePad = [];
|
|
100
115
|
let holdActive = false;
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
let interruptCandidateMs = 0;
|
|
104
|
-
let bargeInDampingUntil = 0;
|
|
105
|
-
let sincePartialMs = 0;
|
|
116
|
+
const echo = config.echo;
|
|
117
|
+
let lastPollAt = 0;
|
|
106
118
|
let partialInFlight = false;
|
|
107
119
|
let segmentEpoch = 0;
|
|
108
120
|
let forcePending = false;
|
|
109
|
-
|
|
121
|
+
let uploadedSamples = 0;
|
|
122
|
+
let detectChunks = [];
|
|
123
|
+
let detectSent = 0;
|
|
124
|
+
let detectInFlight = false;
|
|
125
|
+
let detectGeneration = 0;
|
|
126
|
+
const asrUrl = (final, offset, epoch) => `${location.origin}${config.basePath.replace(/\/+$/, "")}/asr?sessionId=${encodeURIComponent(sessionId)}&final=${final ? 1 : 0}` + (offset !== void 0 ? `&offset=${offset}` : "") + (epoch !== void 0 ? `&epoch=${epoch}` : "");
|
|
110
127
|
const setState = (s) => {
|
|
111
128
|
state = s;
|
|
112
129
|
for (const fn of stateListeners) {
|
|
@@ -126,25 +143,36 @@ function createAsrEngine(config, sessionId) {
|
|
|
126
143
|
}
|
|
127
144
|
}
|
|
128
145
|
};
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
const
|
|
146
|
+
const sliceChunks = (chunks, from) => {
|
|
147
|
+
let total = 0;
|
|
148
|
+
for (const c of chunks) total += c.length;
|
|
149
|
+
const out = new Float32Array(Math.max(0, total - from));
|
|
150
|
+
if (out.length === 0) return out;
|
|
132
151
|
let off = 0;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
off
|
|
152
|
+
let acc = 0;
|
|
153
|
+
for (const c of chunks) {
|
|
154
|
+
if (off >= out.length) break;
|
|
155
|
+
const sub = c.subarray(Math.max(0, from - acc));
|
|
156
|
+
const n = Math.min(sub.length, out.length - off);
|
|
157
|
+
out.set(sub.subarray(0, n), off);
|
|
158
|
+
off += n;
|
|
159
|
+
acc += c.length;
|
|
136
160
|
}
|
|
137
161
|
return out;
|
|
138
162
|
};
|
|
163
|
+
const sliceSince = (from) => sliceChunks(segment, from);
|
|
139
164
|
const requestPartial = async () => {
|
|
140
165
|
if (partialInFlight || segment.length === 0) return;
|
|
141
|
-
const
|
|
166
|
+
const total = segment.reduce((n, c) => n + c.length, 0);
|
|
167
|
+
const seconds = total / SAMPLE_RATE;
|
|
142
168
|
if (seconds < PARTIAL_MIN_S || seconds > PARTIAL_MAX_S) return;
|
|
143
|
-
const
|
|
169
|
+
const from = uploadedSamples;
|
|
170
|
+
if (total - from <= 0) return;
|
|
171
|
+
const samples = sliceSince(from);
|
|
144
172
|
const epoch = segmentEpoch;
|
|
145
173
|
partialInFlight = true;
|
|
146
174
|
try {
|
|
147
|
-
let res = await fetch(asrUrl(false), {
|
|
175
|
+
let res = await fetch(asrUrl(false, from, epoch), {
|
|
148
176
|
method: "POST",
|
|
149
177
|
headers: { "content-type": "application/octet-stream" },
|
|
150
178
|
body: samples.buffer
|
|
@@ -154,7 +182,7 @@ function createAsrEngine(config, sessionId) {
|
|
|
154
182
|
const retry = await new Promise((resolve) => {
|
|
155
183
|
setTimeout(async () => {
|
|
156
184
|
try {
|
|
157
|
-
const r2 = await fetch(asrUrl(false), {
|
|
185
|
+
const r2 = await fetch(asrUrl(false, from, epoch), {
|
|
158
186
|
method: "POST",
|
|
159
187
|
headers: { "content-type": "application/octet-stream" },
|
|
160
188
|
body: samples.buffer
|
|
@@ -167,42 +195,97 @@ function createAsrEngine(config, sessionId) {
|
|
|
167
195
|
});
|
|
168
196
|
res = retry;
|
|
169
197
|
}
|
|
198
|
+
if (res.status === 403 && config.onSessionExpired) {
|
|
199
|
+
const recovered = await config.onSessionExpired();
|
|
200
|
+
if (recovered && epoch === segmentEpoch) {
|
|
201
|
+
uploadedSamples = 0;
|
|
202
|
+
try {
|
|
203
|
+
res = await fetch(asrUrl(false, 0, epoch), {
|
|
204
|
+
method: "POST",
|
|
205
|
+
headers: { "content-type": "application/octet-stream" },
|
|
206
|
+
body: sliceSince(0).buffer
|
|
207
|
+
});
|
|
208
|
+
} catch {
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
170
212
|
if (epoch !== segmentEpoch) return;
|
|
171
213
|
if (!res.ok) return;
|
|
172
214
|
const out = await res.json();
|
|
173
215
|
if (epoch !== segmentEpoch) return;
|
|
216
|
+
if (out.isSpeech !== void 0) config.onIsSpeech?.(out.isSpeech);
|
|
174
217
|
if (state === "loading-model") setState("speech");
|
|
175
|
-
|
|
176
|
-
if (matchWakeWord(out.text ?? "", wakeWord)) {
|
|
177
|
-
segmentEpoch++;
|
|
178
|
-
segment = [];
|
|
179
|
-
segmentMs = 0;
|
|
180
|
-
silenceMs = 0;
|
|
181
|
-
prePad = [];
|
|
182
|
-
sincePartialMs = 0;
|
|
183
|
-
await resetHostStream();
|
|
184
|
-
if (active) setState("listening");
|
|
185
|
-
}
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
218
|
+
uploadedSamples = Math.max(uploadedSamples, from + samples.length);
|
|
188
219
|
emit(partialListeners, out.text ?? "");
|
|
220
|
+
if (out.endpoint && active && speechActive && !holdActive) finalizeSegment();
|
|
189
221
|
} catch {
|
|
190
222
|
} finally {
|
|
191
223
|
partialInFlight = false;
|
|
192
224
|
}
|
|
193
225
|
};
|
|
226
|
+
const requestDetect = async () => {
|
|
227
|
+
if (detectInFlight) return;
|
|
228
|
+
let total = detectChunks.reduce((n, c) => n + c.length, 0);
|
|
229
|
+
const MAX_DETECT_PENDING = 30 * SAMPLE_RATE;
|
|
230
|
+
if (total - detectSent > MAX_DETECT_PENDING) {
|
|
231
|
+
detectSent = Math.max(0, total - MAX_DETECT_PENDING);
|
|
232
|
+
}
|
|
233
|
+
while (detectChunks.length > 0 && detectSent >= detectChunks[0].length) {
|
|
234
|
+
detectSent -= detectChunks[0].length;
|
|
235
|
+
detectChunks.shift();
|
|
236
|
+
}
|
|
237
|
+
total = detectChunks.reduce((n, c) => n + c.length, 0);
|
|
238
|
+
if (total - detectSent <= 0) return;
|
|
239
|
+
const samples = sliceChunks(detectChunks, detectSent);
|
|
240
|
+
const epoch = segmentEpoch;
|
|
241
|
+
const gen = detectGeneration;
|
|
242
|
+
detectInFlight = true;
|
|
243
|
+
try {
|
|
244
|
+
const res = await fetch(asrUrl(false, detectSent, epoch) + "&vadOnly=1", {
|
|
245
|
+
method: "POST",
|
|
246
|
+
headers: { "content-type": "application/octet-stream" },
|
|
247
|
+
body: samples.buffer,
|
|
248
|
+
signal: AbortSignal.timeout(5e3)
|
|
249
|
+
// 防服务端挂起长期锁死 detectInFlight(Important#2)
|
|
250
|
+
});
|
|
251
|
+
if (epoch !== segmentEpoch || gen !== detectGeneration) return;
|
|
252
|
+
if (!res.ok) return;
|
|
253
|
+
const out = await res.json();
|
|
254
|
+
if (epoch !== segmentEpoch || gen !== detectGeneration) return;
|
|
255
|
+
if (out.isSpeech !== void 0) config.onIsSpeech?.(out.isSpeech);
|
|
256
|
+
detectSent += samples.length;
|
|
257
|
+
while (detectChunks.length > 0 && detectSent >= detectChunks[0].length) {
|
|
258
|
+
detectSent -= detectChunks[0].length;
|
|
259
|
+
detectChunks.shift();
|
|
260
|
+
}
|
|
261
|
+
} catch {
|
|
262
|
+
} finally {
|
|
263
|
+
detectInFlight = false;
|
|
264
|
+
}
|
|
265
|
+
};
|
|
194
266
|
const resetHostStream = async () => {
|
|
195
267
|
try {
|
|
196
|
-
await fetch(`${asrUrl(false)}&reset=1`, { method: "POST" });
|
|
268
|
+
await fetch(`${asrUrl(false)}&reset=1`, { method: "POST", signal: AbortSignal.timeout(5e3) });
|
|
197
269
|
} catch {
|
|
198
270
|
}
|
|
199
271
|
};
|
|
200
|
-
const finalizeSegment = () => {
|
|
272
|
+
const finalizeSegment = (force = false) => {
|
|
201
273
|
if (segment.length === 0) return;
|
|
202
|
-
|
|
203
|
-
|
|
274
|
+
if (config.isPlaying?.() && !forcePending && !force) return;
|
|
275
|
+
if (utteranceEndAt === null) {
|
|
276
|
+
utteranceEndAt = Date.now();
|
|
277
|
+
emitTelemetry("utterance-end");
|
|
278
|
+
}
|
|
279
|
+
emitTelemetry("endpoint-fired");
|
|
280
|
+
const from = uploadedSamples;
|
|
281
|
+
const samples = sliceSince(from);
|
|
282
|
+
const recoverySegment = segment;
|
|
283
|
+
const epochSnapshot = segmentEpoch;
|
|
284
|
+
segmentEpoch++;
|
|
204
285
|
const meta = { force: forcePending };
|
|
205
286
|
forcePending = false;
|
|
287
|
+
speechMs = 0;
|
|
288
|
+
uploadedSamples = 0;
|
|
206
289
|
segment = [];
|
|
207
290
|
speechActive = false;
|
|
208
291
|
silenceMs = 0;
|
|
@@ -210,22 +293,41 @@ function createAsrEngine(config, sessionId) {
|
|
|
210
293
|
prePad = [];
|
|
211
294
|
setState("transcribing");
|
|
212
295
|
void (async () => {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
296
|
+
emitTelemetry("submitted");
|
|
297
|
+
const MAX_FINAL_ATTEMPTS = 3;
|
|
298
|
+
const restoreState = () => setState(active ? speechActive || holdActive ? "speech" : "listening" : "idle");
|
|
299
|
+
for (let attempt = 0; attempt < MAX_FINAL_ATTEMPTS; attempt++) {
|
|
300
|
+
if (attempt > 0) {
|
|
301
|
+
if (segmentEpoch !== epochSnapshot + 1) return;
|
|
302
|
+
await new Promise((r) => setTimeout(r, 500 * attempt));
|
|
303
|
+
}
|
|
304
|
+
const useFull = attempt > 0;
|
|
305
|
+
const off = useFull ? 0 : from;
|
|
306
|
+
const body = useFull ? sliceChunks(recoverySegment, 0).buffer : samples.buffer;
|
|
307
|
+
let res;
|
|
308
|
+
try {
|
|
309
|
+
res = await fetch(asrUrl(true, off, epochSnapshot), {
|
|
310
|
+
signal: AbortSignal.timeout(1e4),
|
|
311
|
+
method: "POST",
|
|
312
|
+
headers: { "content-type": "application/octet-stream" },
|
|
313
|
+
body
|
|
314
|
+
});
|
|
315
|
+
} catch {
|
|
316
|
+
restoreState();
|
|
317
|
+
if (attempt === MAX_FINAL_ATTEMPTS - 1) console.warn("[dsh-voice-mode] finalize fetch \u5F02\u5E38\uFF08\u91CD\u8BD5\u8017\u5C3D\uFF09");
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
219
320
|
if (res.status === 202) {
|
|
220
321
|
setState("loading-model");
|
|
221
322
|
res = await new Promise((resolve) => {
|
|
222
323
|
setTimeout(async () => {
|
|
223
324
|
try {
|
|
224
325
|
resolve(
|
|
225
|
-
await fetch(asrUrl(true), {
|
|
326
|
+
await fetch(asrUrl(true, off, epochSnapshot), {
|
|
327
|
+
signal: AbortSignal.timeout(1e4),
|
|
226
328
|
method: "POST",
|
|
227
329
|
headers: { "content-type": "application/octet-stream" },
|
|
228
|
-
body
|
|
330
|
+
body
|
|
229
331
|
})
|
|
230
332
|
);
|
|
231
333
|
} catch {
|
|
@@ -234,21 +336,53 @@ function createAsrEngine(config, sessionId) {
|
|
|
234
336
|
}, 5e3);
|
|
235
337
|
});
|
|
236
338
|
}
|
|
237
|
-
if (
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (
|
|
339
|
+
if (res.status === 202) {
|
|
340
|
+
if (attempt === MAX_FINAL_ATTEMPTS - 1) console.warn("[dsh-voice-mode] finalize \u6A21\u578B\u52A0\u8F7D\u8D85\u65F6\uFF08\u91CD\u8BD5\u8017\u5C3D\uFF09");
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (res.status === 403 && config.onSessionExpired) {
|
|
344
|
+
const recovered = await config.onSessionExpired();
|
|
345
|
+
if (recovered && segmentEpoch === epochSnapshot + 1) {
|
|
346
|
+
try {
|
|
347
|
+
res = await fetch(asrUrl(true, 0, epochSnapshot), {
|
|
348
|
+
signal: AbortSignal.timeout(1e4),
|
|
349
|
+
method: "POST",
|
|
350
|
+
headers: { "content-type": "application/octet-stream" },
|
|
351
|
+
body: sliceChunks(recoverySegment, 0).buffer
|
|
352
|
+
});
|
|
353
|
+
} catch {
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
restoreState();
|
|
358
|
+
if (!res.ok) {
|
|
359
|
+
if (attempt === MAX_FINAL_ATTEMPTS - 1) console.warn("[dsh-voice-mode] finalize 5xx\uFF08\u91CD\u8BD5\u8017\u5C3D\uFF09");
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
let out;
|
|
363
|
+
try {
|
|
364
|
+
out = await res.json();
|
|
365
|
+
} catch {
|
|
366
|
+
if (attempt === MAX_FINAL_ATTEMPTS - 1) console.warn("[dsh-voice-mode] finalize \u54CD\u5E94\u975E JSON\uFF08\u91CD\u8BD5\u8017\u5C3D\uFF09");
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (segmentEpoch !== epochSnapshot + 1) return;
|
|
242
370
|
if (out.text) emit(transcriptListeners, out.text, meta);
|
|
243
|
-
|
|
244
|
-
setState(active ? speechActive ? "speech" : "listening" : "idle");
|
|
245
|
-
emitError("recognitionFail");
|
|
371
|
+
return;
|
|
246
372
|
}
|
|
373
|
+
emitError("recognitionFail");
|
|
247
374
|
})();
|
|
248
375
|
};
|
|
376
|
+
let latestResidualRms = 0;
|
|
377
|
+
let echoFloorRms = 0;
|
|
378
|
+
let echoPeak = 0;
|
|
249
379
|
const handleAudio = (raw) => {
|
|
250
380
|
if (!active || inFlush) return;
|
|
251
|
-
|
|
381
|
+
let data = ctxRate !== SAMPLE_RATE ? resampleLinear(raw, ctxRate, SAMPLE_RATE) : raw;
|
|
382
|
+
if (echo) {
|
|
383
|
+
const ref = echo.windowAt(performance.now(), data.length);
|
|
384
|
+
data = echo.process(data, ref);
|
|
385
|
+
}
|
|
252
386
|
let sum = 0;
|
|
253
387
|
for (let i = 0; i < data.length; i++) sum += data[i] * data[i];
|
|
254
388
|
const rms = Math.sqrt(sum / data.length);
|
|
@@ -259,23 +393,25 @@ function createAsrEngine(config, sessionId) {
|
|
|
259
393
|
} catch {
|
|
260
394
|
}
|
|
261
395
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
}
|
|
275
|
-
}
|
|
396
|
+
const playingNow = config.isPlaying?.() ?? false;
|
|
397
|
+
const gateRatio = Math.pow(10, (config.echoGateDb ?? 6) / 20);
|
|
398
|
+
const peakDecay = Math.pow(0.9, durationMs / 64);
|
|
399
|
+
const floorAlpha = 1 - Math.pow(0.98, durationMs / 64);
|
|
400
|
+
if (playingNow) {
|
|
401
|
+
latestResidualRms = rms;
|
|
402
|
+
echoPeak = Math.max(echoPeak * peakDecay, rms);
|
|
403
|
+
}
|
|
404
|
+
const doubleTalk = playingNow && echoFloorRms > 0 && rms > echoFloorRms * gateRatio;
|
|
405
|
+
if (playingNow) {
|
|
406
|
+
if (echoFloorRms === 0) echoFloorRms = rms;
|
|
407
|
+
else if (!doubleTalk) echoFloorRms = echoFloorRms * (1 - floorAlpha) + rms * floorAlpha;
|
|
276
408
|
} else {
|
|
277
|
-
|
|
409
|
+
echoPeak = 0;
|
|
410
|
+
}
|
|
411
|
+
if (echo) {
|
|
412
|
+
echo.setFrozen(doubleTalk);
|
|
278
413
|
}
|
|
414
|
+
if (playingNow && !holdActive && config.mode !== "hold") detectChunks.push(data);
|
|
279
415
|
if (holdActive) {
|
|
280
416
|
if (!speechActive) {
|
|
281
417
|
speechActive = true;
|
|
@@ -285,46 +421,52 @@ function createAsrEngine(config, sessionId) {
|
|
|
285
421
|
silenceMs = 0;
|
|
286
422
|
segment.push(data);
|
|
287
423
|
if (segmentMs > MAX_SEGMENT_MS) finalizeSegment();
|
|
288
|
-
} else if (
|
|
289
|
-
if (rms > SPEECH_RMS) {
|
|
290
|
-
segmentMs += durationMs;
|
|
291
|
-
segment.push(data);
|
|
292
|
-
if (segmentMs > MAX_SEGMENT_MS) {
|
|
293
|
-
segment = [];
|
|
294
|
-
segmentMs = 0;
|
|
295
|
-
silenceMs = 0;
|
|
296
|
-
prePad = [];
|
|
297
|
-
void resetHostStream();
|
|
298
|
-
}
|
|
299
|
-
} else {
|
|
300
|
-
prePad.push(data);
|
|
301
|
-
let total = 0;
|
|
302
|
-
let cut = 0;
|
|
303
|
-
for (let i = prePad.length - 1; i >= 0; i--) {
|
|
304
|
-
total += prePad[i].length / SAMPLE_RATE * 1e3;
|
|
305
|
-
if (total > PRE_PAD_MS) {
|
|
306
|
-
cut = i + 1;
|
|
307
|
-
break;
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
if (cut > 0) prePad = prePad.slice(cut);
|
|
311
|
-
}
|
|
424
|
+
} else if (config.mode === "hold") {
|
|
312
425
|
} else if (rms > SPEECH_RMS) {
|
|
426
|
+
if (config.isPlaying?.()) {
|
|
427
|
+
if (speechActive) finalizeSegment(true);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
313
430
|
if (!speechActive) {
|
|
314
431
|
speechActive = true;
|
|
432
|
+
detectChunks = [];
|
|
433
|
+
detectSent = 0;
|
|
434
|
+
detectGeneration++;
|
|
435
|
+
utteranceEndAt = null;
|
|
315
436
|
setState("speech");
|
|
316
437
|
for (const p of prePad) segment.push(p);
|
|
317
438
|
prePad = [];
|
|
318
439
|
}
|
|
440
|
+
speechMs += durationMs;
|
|
319
441
|
segmentMs += durationMs;
|
|
320
442
|
silenceMs = 0;
|
|
321
443
|
segment.push(data);
|
|
322
444
|
if (segmentMs > MAX_SEGMENT_MS) finalizeSegment();
|
|
323
445
|
} else if (speechActive) {
|
|
446
|
+
if (utteranceEndAt === null) {
|
|
447
|
+
utteranceEndAt = Date.now();
|
|
448
|
+
emitTelemetry("utterance-end");
|
|
449
|
+
}
|
|
324
450
|
segmentMs += durationMs;
|
|
325
451
|
silenceMs += durationMs;
|
|
326
452
|
segment.push(data);
|
|
327
|
-
if (silenceMs > config.silenceMs)
|
|
453
|
+
if (silenceMs > config.silenceMs) {
|
|
454
|
+
if (speechMs >= MIN_SPEECH_MS) {
|
|
455
|
+
finalizeSegment();
|
|
456
|
+
} else {
|
|
457
|
+
segmentEpoch++;
|
|
458
|
+
segment = [];
|
|
459
|
+
speechActive = false;
|
|
460
|
+
speechMs = 0;
|
|
461
|
+
silenceMs = 0;
|
|
462
|
+
segmentMs = 0;
|
|
463
|
+
prePad = [];
|
|
464
|
+
utteranceEndAt = null;
|
|
465
|
+
uploadedSamples = 0;
|
|
466
|
+
void resetHostStream();
|
|
467
|
+
setState("listening");
|
|
468
|
+
}
|
|
469
|
+
}
|
|
328
470
|
} else {
|
|
329
471
|
prePad.push(data);
|
|
330
472
|
let total = 0;
|
|
@@ -338,34 +480,41 @@ function createAsrEngine(config, sessionId) {
|
|
|
338
480
|
}
|
|
339
481
|
if (cut > 0) prePad = prePad.slice(cut);
|
|
340
482
|
}
|
|
341
|
-
|
|
342
|
-
if (
|
|
343
|
-
|
|
344
|
-
|
|
483
|
+
const nowMs = Date.now();
|
|
484
|
+
if (nowMs - lastPollAt >= PARTIAL_INTERVAL_MS) {
|
|
485
|
+
if (playingNow && !speechActive && !holdActive) {
|
|
486
|
+
lastPollAt = nowMs;
|
|
487
|
+
void requestDetect();
|
|
488
|
+
} else if (speechActive || holdActive) {
|
|
489
|
+
lastPollAt = nowMs;
|
|
490
|
+
void requestPartial();
|
|
491
|
+
}
|
|
345
492
|
}
|
|
346
493
|
};
|
|
347
|
-
function resampleTo16k(src, srcRate) {
|
|
348
|
-
const ratio = srcRate / SAMPLE_RATE;
|
|
349
|
-
const outLen = Math.max(1, Math.floor(src.length / ratio));
|
|
350
|
-
const out = new Float32Array(outLen);
|
|
351
|
-
for (let i = 0; i < outLen; i++) {
|
|
352
|
-
const pos = i * ratio;
|
|
353
|
-
const i0 = Math.floor(pos);
|
|
354
|
-
const i1 = Math.min(i0 + 1, src.length - 1);
|
|
355
|
-
const frac = pos - i0;
|
|
356
|
-
out[i] = src[i0] + (src[i1] - src[i0]) * frac;
|
|
357
|
-
}
|
|
358
|
-
return out;
|
|
359
|
-
}
|
|
360
494
|
const startRecorder = async () => {
|
|
495
|
+
const mySeq = startSeq;
|
|
361
496
|
stream = await navigator.mediaDevices.getUserMedia({
|
|
362
497
|
audio: {
|
|
363
498
|
channelCount: 1,
|
|
364
499
|
echoCancellation: true,
|
|
365
|
-
|
|
366
|
-
|
|
500
|
+
// L0(研究核实):AGC/NS 是时变非线性,放在 AEC 前破坏线性回声路径假设——
|
|
501
|
+
// WebRTC GainController2 与 Speex 均把增益放 AEC 之后。禁用后原生 AEC 与
|
|
502
|
+
// 自研 AEC 的输入才线性,回声消得净;AGC 放大近端语音由「残差地板门控」
|
|
503
|
+
// 的归一化语义替代,不需要采集端增益。
|
|
504
|
+
noiseSuppression: false,
|
|
505
|
+
autoGainControl: false
|
|
367
506
|
}
|
|
368
507
|
});
|
|
508
|
+
if (stopRequested || mySeq !== startSeq) {
|
|
509
|
+
stream.getTracks().forEach((t3) => t3.stop());
|
|
510
|
+
stream = null;
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
const aecOn = stream.getAudioTracks()[0]?.getSettings().echoCancellation === true;
|
|
514
|
+
if (!aecOn) {
|
|
515
|
+
console.warn("[dsh-voice-mode] \u6D4F\u89C8\u5668\u539F\u751F echoCancellation \u672A\u751F\u6548\uFF08\u5916\u653E\u53EF\u80FD\u81EA\u6253\u65AD\uFF09\uFF0C\u5EFA\u8BAE\u7528\u8033\u673A\u6216\u300C\u624B\u52A8\u6253\u65AD\u300D");
|
|
516
|
+
}
|
|
517
|
+
config.onAecState?.(aecOn);
|
|
369
518
|
const AC = window.AudioContext ?? window.webkitAudioContext;
|
|
370
519
|
audioCtx = new AC({ sampleRate: SAMPLE_RATE });
|
|
371
520
|
try {
|
|
@@ -374,6 +523,29 @@ function createAsrEngine(config, sessionId) {
|
|
|
374
523
|
}
|
|
375
524
|
ctxRate = audioCtx.sampleRate;
|
|
376
525
|
const source = audioCtx.createMediaStreamSource(stream);
|
|
526
|
+
if (audioCtx.audioWorklet) {
|
|
527
|
+
try {
|
|
528
|
+
if (!workletBlobUrl) {
|
|
529
|
+
workletBlobUrl = URL.createObjectURL(new Blob(['"use strict";\n(() => {\n // src/resample.ts\n function resampleLinear(src, srcRate, dstRate) {\n if (srcRate === dstRate) return src;\n if (src.length === 0) return src;\n const ratio = srcRate / dstRate;\n const outLen = Math.max(1, Math.floor(src.length / ratio));\n const out = new Float32Array(outLen);\n for (let i = 0; i < outLen; i++) {\n const pos = i * ratio;\n const i0 = Math.floor(pos);\n const i1 = Math.min(i0 + 1, src.length - 1);\n const frac = pos - i0;\n out[i] = src[i0] + (src[i1] - src[i0]) * frac;\n }\n return out;\n }\n\n // src/audio-worklet.ts\n var TARGET_RATE = 16e3;\n var CHUNK = 1024;\n var RATIO = sampleRate / TARGET_RATE;\n var NEED = Math.ceil(CHUNK * RATIO);\n var VoiceCaptureProcessor = class extends AudioWorkletProcessor {\n acc = new Float32Array(0);\n accLen = 0;\n process(inputs) {\n const ch = inputs[0]?.[0];\n if (ch && ch.length > 0) this.push(ch);\n this.drain();\n return true;\n }\n push(ch) {\n if (this.accLen + ch.length > this.acc.length) {\n let cap = this.acc.length > 0 ? this.acc.length : NEED * 2;\n while (cap < this.accLen + ch.length) cap *= 2;\n const next = new Float32Array(cap);\n next.set(this.acc.subarray(0, this.accLen));\n this.acc = next;\n }\n this.acc.set(ch, this.accLen);\n this.accLen += ch.length;\n }\n drain() {\n while (this.accLen >= NEED) {\n const src = sampleRate === TARGET_RATE ? this.acc.subarray(0, CHUNK) : resampleLinear(this.acc.subarray(0, NEED), sampleRate, TARGET_RATE);\n const chunk = new Float32Array(CHUNK);\n chunk.set(src.length >= CHUNK ? src.subarray(0, CHUNK) : src);\n this.port.postMessage(chunk, [chunk.buffer]);\n this.acc.copyWithin(0, NEED, this.accLen);\n this.accLen -= NEED;\n }\n }\n };\n registerProcessor("voice-capture", VoiceCaptureProcessor);\n})();\n'], { type: "text/javascript" }));
|
|
530
|
+
}
|
|
531
|
+
await audioCtx.audioWorklet.addModule(workletBlobUrl);
|
|
532
|
+
workletNode = new AudioWorkletNode(audioCtx, "voice-capture", { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [1] });
|
|
533
|
+
workletNode.port.onmessage = (e) => {
|
|
534
|
+
handleAudio(e.data);
|
|
535
|
+
};
|
|
536
|
+
source.connect(workletNode);
|
|
537
|
+
workletNode.connect(audioCtx.destination);
|
|
538
|
+
ctxRate = SAMPLE_RATE;
|
|
539
|
+
active = true;
|
|
540
|
+
return;
|
|
541
|
+
} catch {
|
|
542
|
+
try {
|
|
543
|
+
workletNode?.disconnect();
|
|
544
|
+
} catch {
|
|
545
|
+
}
|
|
546
|
+
workletNode = null;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
377
549
|
processor = audioCtx.createScriptProcessor(BUFFER_SIZE, 1, 1);
|
|
378
550
|
processor.onaudioprocess = (e) => {
|
|
379
551
|
handleAudio(new Float32Array(e.inputBuffer.getChannelData(0)));
|
|
@@ -383,8 +555,6 @@ function createAsrEngine(config, sessionId) {
|
|
|
383
555
|
active = true;
|
|
384
556
|
};
|
|
385
557
|
const stopRecorder = async () => {
|
|
386
|
-
if (!active) return;
|
|
387
|
-
active = false;
|
|
388
558
|
inFlush = true;
|
|
389
559
|
segmentEpoch++;
|
|
390
560
|
forcePending = false;
|
|
@@ -393,13 +563,23 @@ function createAsrEngine(config, sessionId) {
|
|
|
393
563
|
speechActive = false;
|
|
394
564
|
silenceMs = 0;
|
|
395
565
|
segmentMs = 0;
|
|
566
|
+
speechMs = 0;
|
|
567
|
+
uploadedSamples = 0;
|
|
396
568
|
prePad = [];
|
|
397
|
-
|
|
569
|
+
detectChunks = [];
|
|
570
|
+
detectSent = 0;
|
|
571
|
+
detectGeneration++;
|
|
572
|
+
utteranceEndAt = null;
|
|
398
573
|
try {
|
|
399
574
|
processor?.disconnect();
|
|
400
575
|
} catch {
|
|
401
576
|
}
|
|
402
577
|
processor = null;
|
|
578
|
+
try {
|
|
579
|
+
workletNode?.disconnect();
|
|
580
|
+
} catch {
|
|
581
|
+
}
|
|
582
|
+
workletNode = null;
|
|
403
583
|
try {
|
|
404
584
|
stream?.getTracks().forEach((t3) => t3.stop());
|
|
405
585
|
} catch {
|
|
@@ -417,13 +597,28 @@ function createAsrEngine(config, sessionId) {
|
|
|
417
597
|
get state() {
|
|
418
598
|
return state;
|
|
419
599
|
},
|
|
600
|
+
get holding() {
|
|
601
|
+
return holdActive;
|
|
602
|
+
},
|
|
603
|
+
/** A2.5 回声门控:当前残差是否明显高于回声地板(marginDb 默认 6dB)——判用户人声而非回声。 */
|
|
604
|
+
aboveEchoFloor(marginDb = 6) {
|
|
605
|
+
if (echoFloorRms === 0) return false;
|
|
606
|
+
return echoPeak > echoFloorRms * Math.pow(10, marginDb / 20);
|
|
607
|
+
},
|
|
608
|
+
echoLevels() {
|
|
609
|
+
return { floorRms: echoFloorRms, residualRms: latestResidualRms, peakRms: echoPeak };
|
|
610
|
+
},
|
|
420
611
|
async start() {
|
|
421
612
|
if (active) return;
|
|
613
|
+
stopRequested = false;
|
|
614
|
+
startSeq++;
|
|
422
615
|
segmentEpoch++;
|
|
423
|
-
|
|
424
|
-
interruptCandidateMs = 0;
|
|
616
|
+
lastPollAt = 0;
|
|
425
617
|
holdActive = false;
|
|
426
|
-
|
|
618
|
+
detectChunks = [];
|
|
619
|
+
detectSent = 0;
|
|
620
|
+
detectGeneration++;
|
|
621
|
+
setState("listening");
|
|
427
622
|
try {
|
|
428
623
|
await startRecorder();
|
|
429
624
|
} catch (error) {
|
|
@@ -432,7 +627,10 @@ function createAsrEngine(config, sessionId) {
|
|
|
432
627
|
}
|
|
433
628
|
},
|
|
434
629
|
async stop() {
|
|
435
|
-
|
|
630
|
+
stopRequested = true;
|
|
631
|
+
const wasActive = active;
|
|
632
|
+
active = false;
|
|
633
|
+
if (!wasActive) {
|
|
436
634
|
setState("idle");
|
|
437
635
|
return;
|
|
438
636
|
}
|
|
@@ -443,7 +641,7 @@ function createAsrEngine(config, sessionId) {
|
|
|
443
641
|
const speechS = segment.reduce((n, c) => n + c.length, 0) / SAMPLE_RATE;
|
|
444
642
|
if (speechActive && speechS >= 0.25) {
|
|
445
643
|
forcePending = true;
|
|
446
|
-
|
|
644
|
+
lastPollAt = 0;
|
|
447
645
|
finalizeSegment();
|
|
448
646
|
}
|
|
449
647
|
},
|
|
@@ -452,15 +650,39 @@ function createAsrEngine(config, sessionId) {
|
|
|
452
650
|
holdActive = true;
|
|
453
651
|
forcePending = true;
|
|
454
652
|
segmentEpoch++;
|
|
653
|
+
utteranceEndAt = null;
|
|
455
654
|
segment = [];
|
|
456
655
|
segmentMs = 0;
|
|
656
|
+
speechMs = 0;
|
|
457
657
|
silenceMs = 0;
|
|
458
658
|
prePad = [];
|
|
659
|
+
uploadedSamples = 0;
|
|
660
|
+
detectChunks = [];
|
|
661
|
+
detectSent = 0;
|
|
662
|
+
detectGeneration++;
|
|
459
663
|
speechActive = true;
|
|
460
|
-
|
|
461
|
-
bargeInDampingUntil = Date.now() + 800;
|
|
664
|
+
lastPollAt = 0;
|
|
462
665
|
setState("speech");
|
|
463
666
|
},
|
|
667
|
+
discardSegment() {
|
|
668
|
+
segmentEpoch++;
|
|
669
|
+
segment = [];
|
|
670
|
+
segmentMs = 0;
|
|
671
|
+
speechMs = 0;
|
|
672
|
+
silenceMs = 0;
|
|
673
|
+
speechActive = false;
|
|
674
|
+
prePad = [];
|
|
675
|
+
uploadedSamples = 0;
|
|
676
|
+
detectChunks = [];
|
|
677
|
+
detectSent = 0;
|
|
678
|
+
detectGeneration++;
|
|
679
|
+
utteranceEndAt = null;
|
|
680
|
+
forcePending = false;
|
|
681
|
+
lastPollAt = 0;
|
|
682
|
+
return resetHostStream().then(() => {
|
|
683
|
+
if (active) setState("listening");
|
|
684
|
+
});
|
|
685
|
+
},
|
|
464
686
|
endHeld(cancel = false) {
|
|
465
687
|
if (!active || !holdActive) return;
|
|
466
688
|
holdActive = false;
|
|
@@ -471,12 +693,18 @@ function createAsrEngine(config, sessionId) {
|
|
|
471
693
|
silenceMs = 0;
|
|
472
694
|
prePad = [];
|
|
473
695
|
speechActive = false;
|
|
474
|
-
|
|
696
|
+
forcePending = false;
|
|
697
|
+
setState("listening");
|
|
475
698
|
return;
|
|
476
699
|
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
700
|
+
if (segment.length > 0) {
|
|
701
|
+
forcePending = true;
|
|
702
|
+
lastPollAt = 0;
|
|
703
|
+
finalizeSegment();
|
|
704
|
+
} else {
|
|
705
|
+
forcePending = false;
|
|
706
|
+
setState("listening");
|
|
707
|
+
}
|
|
480
708
|
},
|
|
481
709
|
onSegment(fn) {
|
|
482
710
|
transcriptListeners.add(fn);
|
|
@@ -496,12 +724,6 @@ function createAsrEngine(config, sessionId) {
|
|
|
496
724
|
partialListeners.delete(fn);
|
|
497
725
|
};
|
|
498
726
|
},
|
|
499
|
-
onSpeechStart(fn) {
|
|
500
|
-
speechStartListeners.add(fn);
|
|
501
|
-
return () => {
|
|
502
|
-
speechStartListeners.delete(fn);
|
|
503
|
-
};
|
|
504
|
-
},
|
|
505
727
|
onState(fn) {
|
|
506
728
|
stateListeners.add(fn);
|
|
507
729
|
fn(state);
|
|
@@ -514,10 +736,154 @@ function createAsrEngine(config, sessionId) {
|
|
|
514
736
|
return () => {
|
|
515
737
|
levelListeners.delete(fn);
|
|
516
738
|
};
|
|
739
|
+
},
|
|
740
|
+
onTelemetry(fn) {
|
|
741
|
+
telemetryListeners.add(fn);
|
|
742
|
+
return () => {
|
|
743
|
+
telemetryListeners.delete(fn);
|
|
744
|
+
};
|
|
517
745
|
}
|
|
518
746
|
};
|
|
519
747
|
}
|
|
520
748
|
|
|
749
|
+
// src/aec.ts
|
|
750
|
+
var DEFAULT_FILTER_LENGTH = 256;
|
|
751
|
+
var DEFAULT_DELAY = 64;
|
|
752
|
+
var DEFAULT_STEP = 0.1;
|
|
753
|
+
var DEFAULT_EPSILON = 1e-6;
|
|
754
|
+
var MIN_REF_NORM = 1e-6;
|
|
755
|
+
var NlmsAec = class {
|
|
756
|
+
w;
|
|
757
|
+
xBuf;
|
|
758
|
+
filterLength;
|
|
759
|
+
delay;
|
|
760
|
+
mu;
|
|
761
|
+
eps;
|
|
762
|
+
/** 参考历史环形游标。 */
|
|
763
|
+
cursor = 0;
|
|
764
|
+
/** 已缓冲参考样本数(预热期)。 */
|
|
765
|
+
filled = 0;
|
|
766
|
+
/** A2.5 双讲冻结:用户说话时暂停权重更新,防滤波器被用户语音带偏。 */
|
|
767
|
+
frozen = false;
|
|
768
|
+
constructor(options = {}) {
|
|
769
|
+
this.filterLength = options.filterLength ?? DEFAULT_FILTER_LENGTH;
|
|
770
|
+
this.delay = options.delay ?? DEFAULT_DELAY;
|
|
771
|
+
this.mu = options.step ?? DEFAULT_STEP;
|
|
772
|
+
this.eps = options.epsilon ?? DEFAULT_EPSILON;
|
|
773
|
+
this.w = new Float32Array(this.filterLength);
|
|
774
|
+
this.xBuf = new Float32Array(this.delay + this.filterLength);
|
|
775
|
+
}
|
|
776
|
+
/** A2.5 双讲冻结:true 时暂停权重更新(回声相减照常,仅停止自适应)。 */
|
|
777
|
+
setFrozen(frozen) {
|
|
778
|
+
this.frozen = frozen;
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* 送入下一块麦克风/参考;返回去回声后的麦克风样本(与输入等长)。
|
|
782
|
+
* 参考可比麦克风块短(如静音填充)——不足部分补零。
|
|
783
|
+
*/
|
|
784
|
+
process(mic, ref) {
|
|
785
|
+
const n = mic.length;
|
|
786
|
+
const out = new Float32Array(n);
|
|
787
|
+
if (n === 0) return out;
|
|
788
|
+
const xBuf = this.xBuf;
|
|
789
|
+
const bufLen = xBuf.length;
|
|
790
|
+
let cursor = this.cursor;
|
|
791
|
+
for (let i = 0; i < n; i++) {
|
|
792
|
+
xBuf[cursor] = i < ref.length ? ref[i] : 0;
|
|
793
|
+
cursor = (cursor + 1) % bufLen;
|
|
794
|
+
this.filled = Math.min(this.filled + 1, bufLen);
|
|
795
|
+
const d = mic[i];
|
|
796
|
+
if (this.filled >= this.delay + this.filterLength) {
|
|
797
|
+
let y = 0;
|
|
798
|
+
let norm = 0;
|
|
799
|
+
let idx = (cursor - this.delay + bufLen) % bufLen;
|
|
800
|
+
for (let t3 = 0; t3 < this.filterLength; t3++) {
|
|
801
|
+
const x = xBuf[idx];
|
|
802
|
+
y += this.w[t3] * x;
|
|
803
|
+
norm += x * x;
|
|
804
|
+
idx = (idx - 1 + bufLen) % bufLen;
|
|
805
|
+
}
|
|
806
|
+
const e = d - y;
|
|
807
|
+
out[i] = Number.isFinite(e) ? e : 0;
|
|
808
|
+
if (norm > MIN_REF_NORM && !this.frozen) {
|
|
809
|
+
const denom = norm + this.eps;
|
|
810
|
+
const gain = this.mu * e / denom;
|
|
811
|
+
idx = (cursor - this.delay + bufLen) % bufLen;
|
|
812
|
+
for (let t3 = 0; t3 < this.filterLength; t3++) {
|
|
813
|
+
this.w[t3] += gain * xBuf[idx];
|
|
814
|
+
idx = (idx - 1 + bufLen) % bufLen;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
} else {
|
|
818
|
+
out[i] = d;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
this.cursor = cursor;
|
|
822
|
+
return out;
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
function estimateBulkDelay(mic, ref, opts = {}) {
|
|
826
|
+
const sr = opts.sampleRate ?? 16e3;
|
|
827
|
+
const ds = Math.max(1, Math.floor(opts.downsample ?? 4));
|
|
828
|
+
const minLag = Math.max(0, opts.minLag ?? 0);
|
|
829
|
+
const maxLag = opts.maxLag ?? Math.floor(300 * sr / 1e3);
|
|
830
|
+
const n = Math.min(mic.length, ref.length);
|
|
831
|
+
if (n < ds * 64) return { lag: 0, peak: 0 };
|
|
832
|
+
const N = Math.floor(n / ds);
|
|
833
|
+
const maxLagD = Math.floor(maxLag / ds);
|
|
834
|
+
const minLagD = Math.floor(minLag / ds);
|
|
835
|
+
if (maxLagD >= N) return { lag: 0, peak: 0 };
|
|
836
|
+
const m = new Float32Array(N);
|
|
837
|
+
const r = new Float32Array(N);
|
|
838
|
+
let mSum = 0;
|
|
839
|
+
let rSum = 0;
|
|
840
|
+
for (let i = 0; i < N; i++) {
|
|
841
|
+
let mv = 0;
|
|
842
|
+
let rv = 0;
|
|
843
|
+
for (let k = 0; k < ds; k++) {
|
|
844
|
+
mv += mic[i * ds + k];
|
|
845
|
+
rv += ref[i * ds + k];
|
|
846
|
+
}
|
|
847
|
+
mv /= ds;
|
|
848
|
+
rv /= ds;
|
|
849
|
+
m[i] = mv;
|
|
850
|
+
r[i] = rv;
|
|
851
|
+
mSum += mv;
|
|
852
|
+
rSum += rv;
|
|
853
|
+
}
|
|
854
|
+
const mMu = mSum / N;
|
|
855
|
+
const rMu = rSum / N;
|
|
856
|
+
let mE = 0;
|
|
857
|
+
let rE = 0;
|
|
858
|
+
for (let i = 0; i < N; i++) {
|
|
859
|
+
m[i] -= mMu;
|
|
860
|
+
r[i] -= rMu;
|
|
861
|
+
mE += m[i] * m[i];
|
|
862
|
+
rE += r[i] * r[i];
|
|
863
|
+
}
|
|
864
|
+
if (mE < 1e-9 || rE < 1e-9) return { lag: 0, peak: 0 };
|
|
865
|
+
const prefM = new Float32Array(N + 1);
|
|
866
|
+
const prefR = new Float32Array(N + 1);
|
|
867
|
+
for (let i = 0; i < N; i++) {
|
|
868
|
+
prefM[i + 1] = prefM[i] + m[i] * m[i];
|
|
869
|
+
prefR[i + 1] = prefR[i] + r[i] * r[i];
|
|
870
|
+
}
|
|
871
|
+
let bestLag = 0;
|
|
872
|
+
let bestPeak = -Infinity;
|
|
873
|
+
for (let d = minLagD; d <= maxLagD; d++) {
|
|
874
|
+
let corr = 0;
|
|
875
|
+
for (let i = d; i < N; i++) corr += m[i] * r[i - d];
|
|
876
|
+
const em = prefM[N] - prefM[d];
|
|
877
|
+
const er = prefR[N - d];
|
|
878
|
+
const peak = em > 1e-12 && er > 1e-12 ? corr / Math.sqrt(em * er) : 0;
|
|
879
|
+
if (peak > bestPeak) {
|
|
880
|
+
bestPeak = peak;
|
|
881
|
+
bestLag = d;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
return { lag: bestLag * ds, peak: bestPeak };
|
|
885
|
+
}
|
|
886
|
+
|
|
521
887
|
// src/strings.ts
|
|
522
888
|
var zh = {
|
|
523
889
|
stateVoiceMode: "\u8BED\u97F3\u6A21\u5F0F",
|
|
@@ -530,6 +896,7 @@ var zh = {
|
|
|
530
896
|
hold: "\u6309\u4F4F",
|
|
531
897
|
recognizing: "\u8BC6\u522B\u4E2D\u2026",
|
|
532
898
|
holdToTalk: "\u6309\u4F4F\u8BF4\u8BDD",
|
|
899
|
+
releaseToSend: "\u677E\u5F00\u53D1\u9001",
|
|
533
900
|
voiceDetected: "\u8BED\u97F3\u4E2D",
|
|
534
901
|
entering: "\u8FDB\u5165\u4E2D\u2026",
|
|
535
902
|
voiceBtn: "\u8BED\u97F3",
|
|
@@ -540,15 +907,16 @@ var zh = {
|
|
|
540
907
|
titleEnter: "\u8FDB\u5165\u8BED\u97F3\u5BF9\u8BDD\u6A21\u5F0F\uFF08Ctrl+Shift+V\uFF09",
|
|
541
908
|
loadingModel: "\u6B63\u5728\u52A0\u8F7D\u6A21\u578B\u2026",
|
|
542
909
|
listening: "\u8046\u542C\u4E2D\u2026",
|
|
543
|
-
|
|
910
|
+
thinking: "\u601D\u8003\u4E2D\u2026",
|
|
544
911
|
barHold: "\u8BED\u97F3\u6A21\u5F0F \xB7 \u6309\u4F4F\u8BF4\u8BDD\uFF08\u77ED\u6309\u9000\u51FA\uFF09",
|
|
545
912
|
barListening: "\u8BED\u97F3\u6A21\u5F0F \xB7 \u8046\u542C\u4E2D\u2026",
|
|
546
913
|
reading: "\u6717\u8BFB\u4E2D\u2026",
|
|
547
914
|
recognitionFail: "\u8BC6\u522B\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5",
|
|
915
|
+
sessionExpired: "\u8BED\u97F3\u4F1A\u8BDD\u5DF2\u65AD\u5F00\uFF0C\u6B63\u5728\u91CD\u8FDE\u2026",
|
|
916
|
+
sessionExpiredFail: "\u8BED\u97F3\u4F1A\u8BDD\u91CD\u8FDE\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u5F00\u542F\u8BED\u97F3\u6A21\u5F0F",
|
|
548
917
|
modelDownloadFail: "\u8BED\u97F3\u6A21\u578B\u4E0B\u8F7D\u5931\u8D25\uFF08{file}\uFF09\uFF1A\u8BF7\u68C0\u67E5\u7F51\u7EDC\u540E\u91CD\u65B0\u8FDB\u5165\u8BED\u97F3\u6A21\u5F0F\u91CD\u8BD5",
|
|
549
918
|
startFail: "\u8BED\u97F3\u6A21\u5F0F\u542F\u52A8\u5931\u8D25\uFF1A{err}",
|
|
550
919
|
holdDots: "\u6309\u4F4F\u8BF4\u8BDD\u2026",
|
|
551
|
-
sayWake: "\u8BF4\u300C{wake}\u300D\u5F00\u59CB",
|
|
552
920
|
exit: "\u9000\u51FA",
|
|
553
921
|
skip: "\u8DF3\u8FC7",
|
|
554
922
|
configUnavailableNote: "\uFF08\u8BBE\u7F6E\u6587\u6863\u672A\u5C31\u7EEA\uFF0C\u9762\u677F\u5C31\u7EEA\u540E\u4F1A\u81EA\u52A8\u51FA\u73B0\uFF09\u3002",
|
|
@@ -565,22 +933,55 @@ var zh = {
|
|
|
565
933
|
// settings rows
|
|
566
934
|
descVoice: "Edge TTS \u97F3\u8272\uFF08\u4E0B\u62C9\u5E38\u7528\uFF0C\u5176\u4F59\u9009\u300C\u81EA\u5B9A\u4E49\u300D\u624B\u52A8\u586B ShortName\uFF09",
|
|
567
935
|
descRate: "\u6717\u8BFB\u8BED\u901F\u500D\u7387\uFF080.5 \u6162\u901F \uFF5E 2.0 \u5FEB\u901F\uFF0C1.0 \u6B63\u5E38\uFF09",
|
|
568
|
-
descInterrupt: "\u53D1\u58F0\u6253\u65AD\u7075\u654F\u5EA6\uFF080 \u9AD8\u95E8\u69DB / 1 \u4E2D / 2 \u4F4E\uFF09",
|
|
936
|
+
descInterrupt: "\u53D1\u58F0\u6253\u65AD\u7075\u654F\u5EA6\uFF080 \u9AD8\u95E8\u69DB / 1 \u4E2D / 2 \u4F4E\uFF1B\u53D1\u58F0\u786E\u8BA4\u7EA6 0.3/0.2/0.1 \u79D2\uFF09",
|
|
937
|
+
descBargeIn: "\u6253\u65AD\u65B9\u5F0F\uFF08auto \u81EA\u52A8\u6253\u65AD\uFF1A\u5F00\u53E3\u5373\u6253\u65AD\uFF0C\u8033\u673A/\u5B89\u9759\u73AF\u5883\u63A8\u8350\uFF1Bmanual \u624B\u52A8\u6253\u65AD\uFF1A\u5916\u653E\u63A8\u8350\u2014\u2014\u56DE\u58F0\u4E0D\u4F1A\u8BEF\u89E6\u53D1\u81EA\u6253\u65AD\uFF0C\u6309\u4F4F\u9EA6\u514B\u98CE/Ctrl \u663E\u5F0F\u6253\u65AD\uFF09",
|
|
938
|
+
bargeInAuto: "\u81EA\u52A8",
|
|
939
|
+
bargeInManual: "\u624B\u52A8",
|
|
940
|
+
descEchoGate: "\u56DE\u58F0\u95E8\u63A7\u9608\u503C\uFF08dB\uFF0C\u9ED8\u8BA4 6\uFF09\uFF1A\u81EA\u52A8\u6253\u65AD\u8981\u6C42\u6B8B\u5DEE\u9AD8\u4E8E\u56DE\u58F0\u5730\u677F\u6B64\u503C\uFF1B\u5916\u653E\u4ECD\u8BEF\u6253\u65AD\u8C03\u5927\uFF088~10\uFF09\uFF0C\u592A\u96BE\u6253\u65AD\u8C03\u5C0F\uFF083~4\uFF09",
|
|
941
|
+
descShortcut: "\u8FDB\u5165/\u9000\u51FA\u8BED\u97F3\u6A21\u5F0F\u7684\u5FEB\u6377\u952E\uFF08\u5F62\u5982 Ctrl+Shift+V\uFF1B\u7559\u7A7A\u7981\u7528\u5FEB\u6377\u952E\uFF0C\u53EA\u7528\u9EA6\u514B\u98CE\u6309\u94AE\uFF1B\u907F\u514D\u6D4F\u89C8\u5668\u4FDD\u7559\u7EC4\u5408\u5982 Ctrl+W/N/T\uFF09",
|
|
942
|
+
vadDetected: "VAD \u68C0\u6D4B\u5230\u8BED\u97F3",
|
|
943
|
+
aecOff: "\u539F\u751F\u56DE\u58F0\u6D88\u9664\u672A\u751F\u6548",
|
|
944
|
+
aecOffHint: "\u6D4F\u89C8\u5668\u539F\u751F\u56DE\u58F0\u6D88\u9664\u672A\u751F\u6548\uFF08\u5916\u653E\u53EF\u80FD\u81EA\u6253\u65AD\uFF09\uFF0C\u5EFA\u8BAE\u7528\u8033\u673A\u6216\u5207\u6362\u300C\u624B\u52A8\u6253\u65AD\u300D",
|
|
945
|
+
interruptConfirm: "\u6253\u65AD\u786E\u8BA4",
|
|
569
946
|
sev0: "0 \u9AD8\u95E8\u69DB",
|
|
570
947
|
sev1: "1 \u4E2D",
|
|
571
948
|
sev2: "2 \u4F4E",
|
|
572
|
-
descSilence: "\u8BF4\u5B8C\u6574\u4E00\u53E5\u7684\u9759\u97F3\u505C\u987F\u6BEB\u79D2\u6570\uFF08\u9ED8\u8BA4
|
|
949
|
+
descSilence: "\u8BF4\u5B8C\u6574\u4E00\u53E5\u7684\u9759\u97F3\u505C\u987F\u6BEB\u79D2\u6570\uFF08\u9ED8\u8BA4 700 \u6BEB\u79D2\uFF1B\u81F3\u5C11 250ms \u8BED\u97F3\u624D\u5224\u53E5\uFF0C\u9632\u77ED\u4FC3\u566A\u58F0\u8BEF\u89E6\u53D1\uFF09",
|
|
573
950
|
descIdle: "\u65E0\u6D3B\u52A8\u81EA\u52A8\u9000\u51FA\u8BED\u97F3\u6A21\u5F0F\u7684\u5206\u949F\u6570\uFF08\u9ED8\u8BA4 10\uFF09",
|
|
574
951
|
descModelHost: "ASR \u6A21\u578B\u4E0B\u8F7D\u6E90\uFF08\u5B98\u65B9\u6E90 / \u56FD\u5185\u955C\u50CF\uFF0C\u6216\u9009\u300C\u81EA\u5B9A\u4E49\u300D\u586B\u4EFB\u610F\u955C\u50CF\uFF09",
|
|
575
952
|
descAutoSend: "\u8BC6\u522B\u5B9A\u7A3F\u540E\u81EA\u52A8\u53D1\u9001\uFF08\u5173=\u53EA\u8FDB\u8349\u7A3F\uFF1B\u6309\u4F4F Ctrl / hold \u677E\u624B\u4ECD\u53D1\u9001\uFF09",
|
|
953
|
+
descAutoResume: "\u5207\u6362\u56DE\u4E0A\u6B21\u8BED\u97F3\u4F1A\u8BDD\u65F6\u81EA\u52A8\u6062\u590D\u8BED\u97F3\u6A21\u5F0F\uFF08\u9ED8\u8BA4\u5173\uFF0C\u9700\u9EA6\u514B\u98CE\u6743\u9650\u5DF2\u6388\u4E88\uFF1B\u7701\u53BB\u6BCF\u6B21\u5207\u6362\u4F1A\u8BDD\u540E\u91CD\u65B0\u70B9\u9EA6\u514B\u98CE\uFF09",
|
|
576
954
|
descSpokenFormat: "\u8BED\u97F3\u4F1A\u8BDD\u6CE8\u5165\u53E3\u8BED\u5316\u63D0\u793A\u8BCD\uFF08\u56DE\u590D\u53E3\u8BED\u5316\u3001\u4E0D\u7528 Markdown \u6392\u7248\u7B26\u53F7\uFF0C\u6717\u8BFB\u66F4\u987A\uFF1B\u9ED8\u8BA4\u5173\uFF0C\u6539\u52A8\u5373\u65F6\u751F\u6548\uFF09",
|
|
955
|
+
descSenseVoice: "\u5B9A\u7A3F\u7528 SenseVoice \u91CD\u8BD1\uFF08\u5E26\u6807\u70B9 + \u6570\u5B57\u5F52\u4E00\u5316\u3001\u8BC6\u522B\u66F4\u51C6\uFF1B\u9ED8\u8BA4\u5F00\u3002\u5173\u95ED\u53EF\u7701 228MB \u6A21\u578B\uFF0C\u53EA\u8D70\u6D41\u5F0F\u8BC6\u522B\uFF09",
|
|
577
956
|
descMode: "\u4EA4\u4E92\u6A21\u5F0F\uFF08toggle \u6301\u7EED\u8046\u542C+\u9759\u97F3\u65AD\u53E5 / hold \u6309\u4F4F\u8BF4\u8BDD\uFF09",
|
|
578
957
|
modeToggle: "\u6301\u7EED\u8046\u542C",
|
|
579
958
|
modeHold: "\u6309\u4F4F\u8BF4\u8BDD",
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
959
|
+
settingsCardDesc: "\u97F3\u8272 / \u8BED\u901F / \u6253\u65AD\u7075\u654F\u5EA6 / \u6253\u65AD\u65B9\u5F0F / \u9759\u97F3\u505C\u987F / \u7A7A\u95F2\u8D85\u65F6 / \u6A21\u578B\u955C\u50CF / \u81EA\u52A8\u53D1\u9001 / \u4EA4\u4E92\u6A21\u5F0F / \u53E3\u8BED\u5316\u63D0\u793A\u8BCD",
|
|
960
|
+
settingsEffectiveNote: "\u97F3\u8272 / \u8BED\u901F / \u53E3\u8BED\u5316\u63D0\u793A\u8BCD / \u91CD\u8BD1 \u5373\u65F6\u751F\u6548\uFF1B\u5176\u4F59\uFF08\u6253\u65AD\u7075\u654F\u5EA6 / \u6253\u65AD\u65B9\u5F0F / \u56DE\u58F0\u95E8\u63A7 / \u5FEB\u6377\u952E / \u9759\u97F3 / \u7A7A\u95F2 / \u955C\u50CF / \u81EA\u52A8\u53D1\u9001 / \u81EA\u52A8\u6062\u590D / \u4EA4\u4E92\u6A21\u5F0F\uFF09\u4E0B\u6B21\u8FDB\u5165\u8BED\u97F3\u6A21\u5F0F\u65F6\u751F\u6548\u3002",
|
|
961
|
+
configUnavailable: "\u914D\u7F6E\u6682\u4E0D\u53EF\u7528",
|
|
962
|
+
// telemetry(P1-5 开发模式延迟埋点状态条:各段耗时标签)
|
|
963
|
+
telUtteranceEnd: "\u8BF4\u5B8C",
|
|
964
|
+
telEndpoint: "\u7AEF\u70B9",
|
|
965
|
+
telSubmitted: "\u5B9A\u7A3F",
|
|
966
|
+
telFirstToken: "\u9996Token",
|
|
967
|
+
telFirstSentence: "\u9996\u53E5",
|
|
968
|
+
telFirstChunk: "\u9996chunk",
|
|
969
|
+
telFirstPlayed: "\u9996\u97F3",
|
|
970
|
+
// 模型管理(设置面板实时状态/重试)
|
|
971
|
+
modelsTitle: "\u8BED\u97F3\u6A21\u578B",
|
|
972
|
+
modelsDisabled: "\u5DF2\u5173\u95ED\uFF08\u8BBE\u7F6E\u4E2D\u5F00\u542F\uFF09",
|
|
973
|
+
modelStreamingAsr: "\u6D41\u5F0F\u8BC6\u522B",
|
|
974
|
+
modelVad: "\u7AEF\u70B9 VAD",
|
|
975
|
+
modelSense: "\u5B9A\u7A3F\u91CD\u8BD1",
|
|
976
|
+
modelsReady: "\u5C31\u7EEA",
|
|
977
|
+
modelsDownloading: "{file} {percent}%",
|
|
978
|
+
modelsFail: "\u4E0B\u8F7D\u5931\u8D25\uFF08{sec} \u79D2\u540E\u81EA\u52A8\u91CD\u8BD5\uFF09",
|
|
979
|
+
modelsMissing: "\u672A\u4E0B\u8F7D",
|
|
980
|
+
modelsRetry: "\u91CD\u8BD5\u4E0B\u8F7D",
|
|
981
|
+
modelsRetrying: "\u91CD\u8BD5\u4E2D\u2026",
|
|
982
|
+
modelsRetryHint: "\u955C\u50CF\u5207\u6362\u6216\u4E0B\u8F7D\u5931\u8D25\u540E\u70B9\u51FB\u7ACB\u5373\u91CD\u8BD5",
|
|
983
|
+
modelsHint: "\u4E0B\u8F7D/\u8FDB\u5EA6\u5B9E\u65F6\u8DDF\u8FDB\uFF1B\u5931\u8D25\u81EA\u52A8\u9000\u907F 60s \u91CD\u8BD5\u3002\u955C\u50CF\u6E90\u5207\u6362\u540E\u70B9\u300C\u91CD\u8BD5\u4E0B\u8F7D\u300D\u7ACB\u5373\u751F\u6548\uFF1B\u4E5F\u53EF\u7528 npm run prefetch \u9884\u4E0B\u8F7D\u3002",
|
|
984
|
+
telTotal: "\u5408\u8BA1"
|
|
584
985
|
};
|
|
585
986
|
var en = {
|
|
586
987
|
stateVoiceMode: "Voice Mode",
|
|
@@ -593,6 +994,7 @@ var en = {
|
|
|
593
994
|
hold: "Hold",
|
|
594
995
|
recognizing: "Recognizing\u2026",
|
|
595
996
|
holdToTalk: "Hold to talk",
|
|
997
|
+
releaseToSend: "Release to send",
|
|
596
998
|
voiceDetected: "Voice active",
|
|
597
999
|
entering: "Entering\u2026",
|
|
598
1000
|
voiceBtn: "Voice",
|
|
@@ -603,15 +1005,16 @@ var en = {
|
|
|
603
1005
|
titleEnter: "Enter voice mode (Ctrl+Shift+V)",
|
|
604
1006
|
loadingModel: "Loading model\u2026",
|
|
605
1007
|
listening: "Listening\u2026",
|
|
606
|
-
|
|
1008
|
+
thinking: "Thinking\u2026",
|
|
607
1009
|
barHold: "Voice mode \xB7 hold to talk (tap to exit)",
|
|
608
1010
|
barListening: "Voice mode \xB7 listening\u2026",
|
|
609
1011
|
reading: "Reading\u2026",
|
|
610
1012
|
recognitionFail: "Recognition failed, try again",
|
|
1013
|
+
sessionExpired: "Voice session expired, reconnecting\u2026",
|
|
1014
|
+
sessionExpiredFail: "Voice session reconnect failed; please re-enter voice mode",
|
|
611
1015
|
modelDownloadFail: "Model download failed ({file}): check network and re-enter voice mode",
|
|
612
1016
|
startFail: "Voice mode failed to start: {err}",
|
|
613
1017
|
holdDots: "Hold to talk\u2026",
|
|
614
|
-
sayWake: 'Say "{wake}" to start',
|
|
615
1018
|
exit: "Exit",
|
|
616
1019
|
skip: "Skip",
|
|
617
1020
|
configUnavailableNote: " (settings document not ready; the panel will appear when it is).",
|
|
@@ -626,22 +1029,53 @@ var en = {
|
|
|
626
1029
|
custom: "Custom",
|
|
627
1030
|
descVoice: "Edge TTS voice (presets, or a custom ShortName)",
|
|
628
1031
|
descRate: "Speech rate (0.5 slow \u2013 2.0 fast, 1.0 normal)",
|
|
629
|
-
descInterrupt: "Interrupt sensitivity (0 high barrier / 2 low)",
|
|
1032
|
+
descInterrupt: "Interrupt sensitivity (0 high barrier / 1 medium / 2 low; ~0.3/0.2/0.1 s speech confirmation)",
|
|
1033
|
+
descBargeIn: "Barge-in mode (auto: interrupt by speaking \u2014 headphones/quiet; manual: for loudspeaker, no echo-triggered self-interrupt \u2014 hold mic/Ctrl to interrupt)",
|
|
1034
|
+
bargeInAuto: "Auto",
|
|
1035
|
+
bargeInManual: "Manual",
|
|
1036
|
+
descEchoGate: "Echo gate threshold (dB, default 6): auto barge-in requires the residual to exceed the echo floor by this value; raise (8-10) if speaker echo still interrupts, lower (3-4) if hard to interrupt",
|
|
1037
|
+
descShortcut: "Shortcut to enter/exit voice mode (e.g. Ctrl+Shift+V; empty disables it, mic button only; avoid browser-reserved combos like Ctrl+W/N/T)",
|
|
1038
|
+
vadDetected: "VAD speech",
|
|
1039
|
+
aecOff: "Native AEC off",
|
|
1040
|
+
aecOffHint: "Native echo cancellation is not active (speaker echo may self-interrupt); use headphones or Manual barge-in",
|
|
1041
|
+
interruptConfirm: "interrupt confirm",
|
|
630
1042
|
sev0: "0 high",
|
|
631
1043
|
sev1: "1 medium",
|
|
632
1044
|
sev2: "2 low",
|
|
633
|
-
descSilence: "Silence pause before a sentence is committed (default
|
|
1045
|
+
descSilence: "Silence pause before a sentence is committed (default 700 ms; at least 250 ms of speech required, guards against noise triggers)",
|
|
634
1046
|
descIdle: "Auto-exit voice mode after idle minutes (default 10)",
|
|
635
1047
|
descModelHost: "ASR model download source (official source / mirror, or any custom URL)",
|
|
636
1048
|
descAutoSend: "Auto-send after finalized recognition (off = draft only; Ctrl / hold still sends)",
|
|
1049
|
+
descAutoResume: "Auto-resume voice mode when switching back to the last voice session (default off, requires granted mic permission)",
|
|
637
1050
|
descSpokenFormat: "Inject spoken-format prompt into voice replies (colloquial, no Markdown; default off, live)",
|
|
1051
|
+
descSenseVoice: "Re-transcribe the finalized utterance with SenseVoice (punctuation + ITN, more accurate; default on \u2014 turn off to skip the 228 MB model and keep streaming only)",
|
|
638
1052
|
descMode: "Interaction mode (toggle: continuous listen + auto-send / hold: press to talk)",
|
|
639
1053
|
modeToggle: "Continue listen",
|
|
640
1054
|
modeHold: "Hold to talk",
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
1055
|
+
settingsCardDesc: "Voice / rate / interrupt / barge-in / silence / idle / model host / auto-send / mode / spoken format",
|
|
1056
|
+
settingsEffectiveNote: "Voice / rate / spoken format / re-transcribe apply immediately; the rest (interrupt / barge-in / echo gate / shortcut / silence / idle / mirror / auto-send / auto-resume / mode) apply next time you enter voice mode.",
|
|
1057
|
+
configUnavailable: "Configuration unavailable",
|
|
1058
|
+
telUtteranceEnd: "end",
|
|
1059
|
+
telEndpoint: "endpoint",
|
|
1060
|
+
telSubmitted: "submit",
|
|
1061
|
+
telFirstToken: "1st token",
|
|
1062
|
+
telFirstSentence: "1st sentence",
|
|
1063
|
+
telFirstChunk: "1st chunk",
|
|
1064
|
+
telFirstPlayed: "1st audio",
|
|
1065
|
+
modelsTitle: "Voice models",
|
|
1066
|
+
modelsDisabled: "off (enable in settings)",
|
|
1067
|
+
modelStreamingAsr: "Streaming ASR",
|
|
1068
|
+
modelVad: "Endpoint VAD",
|
|
1069
|
+
modelSense: "Finalize",
|
|
1070
|
+
modelsReady: "Ready",
|
|
1071
|
+
modelsDownloading: "{file} {percent}%",
|
|
1072
|
+
modelsFail: "Download failed (auto-retry in {sec}s)",
|
|
1073
|
+
modelsMissing: "not downloaded",
|
|
1074
|
+
modelsRetry: "Retry",
|
|
1075
|
+
modelsRetrying: "Retrying\u2026",
|
|
1076
|
+
modelsRetryHint: "Click to retry now after switching mirror or a failure",
|
|
1077
|
+
modelsHint: "Live download state; failures auto-backoff 60s. After switching the mirror, click Retry to take effect immediately; npm run prefetch pre-downloads.",
|
|
1078
|
+
telTotal: "total"
|
|
645
1079
|
};
|
|
646
1080
|
var guess = () => /^zh\b/i.test(
|
|
647
1081
|
typeof document !== "undefined" && document.documentElement.lang || (typeof navigator !== "undefined" ? navigator.language : "") || ""
|
|
@@ -965,6 +1399,100 @@ function SegGroup({
|
|
|
965
1399
|
}) {
|
|
966
1400
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { role: "group", style: setSeg, children: options.map((o) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { style: setSegBtn(value === o.v), "aria-pressed": value === o.v, onClick: () => void score.set(field, o.v), children: o.label }, String(o.v))) });
|
|
967
1401
|
}
|
|
1402
|
+
var fmtMB = (b) => b >= 1048576 ? `${(b / 1048576).toFixed(0)}MB` : b > 0 ? `${Math.round(b / 1024)}KB` : "\u2013";
|
|
1403
|
+
function ModelStatusView() {
|
|
1404
|
+
const [st, setSt] = (0, import_react.useState)(null);
|
|
1405
|
+
const [retrying, setRetrying] = (0, import_react.useState)(null);
|
|
1406
|
+
(0, import_react.useEffect)(() => {
|
|
1407
|
+
let alive = true;
|
|
1408
|
+
const poll = async () => {
|
|
1409
|
+
try {
|
|
1410
|
+
const res = await fetch(`${location.origin}${BASE_PATH}/models/status`);
|
|
1411
|
+
if (res.ok && alive) setSt(await res.json());
|
|
1412
|
+
} catch {
|
|
1413
|
+
}
|
|
1414
|
+
};
|
|
1415
|
+
void poll();
|
|
1416
|
+
const timer = setInterval(() => void poll(), 3e3);
|
|
1417
|
+
return () => {
|
|
1418
|
+
alive = false;
|
|
1419
|
+
clearInterval(timer);
|
|
1420
|
+
};
|
|
1421
|
+
}, []);
|
|
1422
|
+
const retry = (kind) => {
|
|
1423
|
+
setRetrying(kind);
|
|
1424
|
+
void fetch(`${location.origin}${BASE_PATH}/models/retry`, {
|
|
1425
|
+
method: "POST",
|
|
1426
|
+
headers: { "content-type": "application/json" },
|
|
1427
|
+
body: JSON.stringify({ kind })
|
|
1428
|
+
}).catch(() => void 0).finally(() => {
|
|
1429
|
+
setTimeout(() => setRetrying(null), 2e3);
|
|
1430
|
+
});
|
|
1431
|
+
};
|
|
1432
|
+
const mkRow = (label, info, key, progressFor) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "flex", alignItems: "center", gap: 10, padding: "6px 0" }, children: [
|
|
1433
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { width: 92, flexShrink: 0, fontSize: 12, color: t2.label }, children: label }),
|
|
1434
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { flex: 1, minWidth: 0 }, children: info.disabledText ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { fontSize: 12, color: t2.term }, children: info.disabledText }) : info.ready ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { fontSize: 12, color: "var(--dsw-alias-state-success-primary)", fontWeight: 600 }, children: t("modelsReady") }) : progressFor && progressFor.file ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: { fontSize: 12, color: t2.term }, children: [
|
|
1435
|
+
t("modelsDownloading").replace("{file}", progressFor.file).replace("{percent}", String(progressFor.percent)),
|
|
1436
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { display: "block", height: 4, borderRadius: 99, background: t2.border, marginTop: 4, overflow: "hidden" }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { display: "block", height: "100%", width: `${progressFor.percent}%`, background: "var(--dsw-alias-brand-primary)", transition: "width .3s" } }) })
|
|
1437
|
+
] }) : info.failLatchMs !== void 0 && info.failLatchMs > 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { fontSize: 12, color: "var(--dsw-alias-state-error-primary)" }, children: t("modelsFail").replace("{sec}", String(Math.ceil(info.failLatchMs / 1e3))) }) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: { fontSize: 12, color: t2.term }, children: [
|
|
1438
|
+
fmtMB(info.size),
|
|
1439
|
+
t("modelsMissing")
|
|
1440
|
+
] }) }),
|
|
1441
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
1442
|
+
"button",
|
|
1443
|
+
{
|
|
1444
|
+
type: "button",
|
|
1445
|
+
disabled: retrying === key || info.ready || !!info.disabledText,
|
|
1446
|
+
onClick: () => retry(key),
|
|
1447
|
+
style: {
|
|
1448
|
+
font: "inherit",
|
|
1449
|
+
fontSize: 12,
|
|
1450
|
+
cursor: info.ready ? "default" : "pointer",
|
|
1451
|
+
color: info.ready ? t2.term : t2.label,
|
|
1452
|
+
background: "var(--dsw-alias-bg-layer-2)",
|
|
1453
|
+
border: `1px solid ${t2.border}`,
|
|
1454
|
+
borderRadius: 8,
|
|
1455
|
+
padding: "3px 10px",
|
|
1456
|
+
opacity: info.ready || info.disabledText ? 0.5 : 1,
|
|
1457
|
+
flexShrink: 0
|
|
1458
|
+
},
|
|
1459
|
+
title: t("modelsRetryHint"),
|
|
1460
|
+
children: retrying === key ? t("modelsRetrying") : t("modelsRetry")
|
|
1461
|
+
}
|
|
1462
|
+
)
|
|
1463
|
+
] });
|
|
1464
|
+
const anyDownloading = !!st?.progress;
|
|
1465
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { marginTop: 4 }, children: [
|
|
1466
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "flex", alignItems: "center", gap: 8, padding: "8px 0" }, children: [
|
|
1467
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { fontSize: 13, fontWeight: 600, color: t2.label }, children: t("modelsTitle") }),
|
|
1468
|
+
anyDownloading && st?.progress && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: { fontSize: 12, color: t2.term }, children: [
|
|
1469
|
+
st.progress.file,
|
|
1470
|
+
" ",
|
|
1471
|
+
st.progress.percent,
|
|
1472
|
+
"%"
|
|
1473
|
+
] })
|
|
1474
|
+
] }),
|
|
1475
|
+
mkRow(
|
|
1476
|
+
t("modelStreamingAsr"),
|
|
1477
|
+
{ ready: !!st?.asr.ready, size: st?.asr.files.reduce((a, f) => a + f.size, 0) ?? 0, failLatchMs: st?.asr.failLatchMs ?? 0 },
|
|
1478
|
+
"asr",
|
|
1479
|
+
anyDownloading ? st.progress : null
|
|
1480
|
+
),
|
|
1481
|
+
mkRow(t("modelVad"), { ready: !!st?.vad.ready, size: st?.vad.size ?? 0, failLatchMs: st?.vad.failLatchMs ?? 0 }, "vad", anyDownloading ? st.progress : null),
|
|
1482
|
+
mkRow(
|
|
1483
|
+
t("modelSense"),
|
|
1484
|
+
{
|
|
1485
|
+
ready: !!st?.sense.ready,
|
|
1486
|
+
size: st?.sense.size ?? 0,
|
|
1487
|
+
failLatchMs: st?.sense.enabled ? st?.sense.failLatchMs ?? 0 : 0,
|
|
1488
|
+
disabledText: st?.sense.enabled ? void 0 : t("modelsDisabled")
|
|
1489
|
+
},
|
|
1490
|
+
"sense",
|
|
1491
|
+
anyDownloading ? st.progress : null
|
|
1492
|
+
),
|
|
1493
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { fontSize: 12, color: t2.term, lineHeight: "18px", padding: "4px 0 8px" }, children: t("modelsHint") })
|
|
1494
|
+
] });
|
|
1495
|
+
}
|
|
968
1496
|
function VoiceSettingsCard({ scope }) {
|
|
969
1497
|
const [snap, setSnap] = (0, import_react.useState)(() => scope.getSnapshot());
|
|
970
1498
|
const [collapsed, setCollapsed] = (0, import_react.useState)(true);
|
|
@@ -1017,11 +1545,27 @@ function VoiceSettingsCard({ scope }) {
|
|
|
1017
1545
|
]
|
|
1018
1546
|
}
|
|
1019
1547
|
) }),
|
|
1020
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "
|
|
1548
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "bargeInMode", desc: t("descBargeIn"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
1549
|
+
SegGroup,
|
|
1550
|
+
{
|
|
1551
|
+
score: scope,
|
|
1552
|
+
field: "bargeInMode",
|
|
1553
|
+
value: value.bargeInMode,
|
|
1554
|
+
options: [
|
|
1555
|
+
{ v: "auto", label: t("bargeInAuto") },
|
|
1556
|
+
{ v: "manual", label: t("bargeInManual") }
|
|
1557
|
+
]
|
|
1558
|
+
}
|
|
1559
|
+
) }),
|
|
1560
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "echoGateDb", desc: t("descEchoGate"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NumberField, { score: scope, field: "echoGateDb", value: value.echoGateDb ?? 6, min: 3, max: 12, step: 1 }) }),
|
|
1561
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "shortcut", desc: t("descShortcut"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TextField, { score: scope, field: "shortcut", value: value.shortcut ?? "Ctrl+Shift+V", placeholder: "Ctrl+Shift+V" }) }),
|
|
1562
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "silenceMs", desc: t("descSilence"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NumberField, { score: scope, field: "silenceMs", value: value.silenceMs ?? 700, min: 500, max: 3e4, step: 100 }) }),
|
|
1021
1563
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "idleTimeoutMinutes", desc: t("descIdle"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NumberField, { score: scope, field: "idleTimeoutMinutes", value: value.idleTimeoutMinutes ?? 10, min: 1, max: 120, step: 1 }) }),
|
|
1022
1564
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "modelHost", desc: t("descModelHost"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SelectField, { score: scope, field: "modelHost", value: value.modelHost ?? "", options: HOST_OPTIONS, placeholder: "https://..." }) }),
|
|
1023
1565
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "autoSend", desc: t("descAutoSend"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { type: "checkbox", checked: Boolean(value.autoSend), onChange: (e) => void scope.set("autoSend", e.target.checked) }) }),
|
|
1566
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "autoResume", desc: t("descAutoResume"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { type: "checkbox", checked: Boolean(value.autoResume), onChange: (e) => void scope.set("autoResume", e.target.checked) }) }),
|
|
1024
1567
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "spokenFormat", desc: t("descSpokenFormat"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { type: "checkbox", checked: Boolean(value.spokenFormat), onChange: (e) => void scope.set("spokenFormat", e.target.checked) }) }),
|
|
1568
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "senseVoice", desc: t("descSenseVoice"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { type: "checkbox", checked: Boolean(value.senseVoice), onChange: (e) => void scope.set("senseVoice", e.target.checked) }) }),
|
|
1025
1569
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "mode", desc: t("descMode"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
1026
1570
|
SegGroup,
|
|
1027
1571
|
{
|
|
@@ -1034,17 +1578,93 @@ function VoiceSettingsCard({ scope }) {
|
|
|
1034
1578
|
]
|
|
1035
1579
|
}
|
|
1036
1580
|
) }),
|
|
1037
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
1581
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { fontSize: 12, color: t2.term, lineHeight: "18px", padding: "4px 0 8px" }, children: t("settingsEffectiveNote") }),
|
|
1582
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(ModelStatusView, {})
|
|
1038
1583
|
] }) })
|
|
1039
1584
|
] });
|
|
1040
1585
|
}
|
|
1041
1586
|
|
|
1042
1587
|
// src/client.tsx
|
|
1043
1588
|
var import_jsx_runtime2 = require("react/jsx-runtime");
|
|
1044
|
-
var
|
|
1589
|
+
var isSpeechTrueCount = 0;
|
|
1590
|
+
var interruptFirstAt = 0;
|
|
1591
|
+
var isSpeechFalseRun = 0;
|
|
1592
|
+
var lastReenterAt = 0;
|
|
1593
|
+
var INT_CONFIRM_FRAMES = { 0: 3, 1: 2, 2: 1 };
|
|
1045
1594
|
var inject = ["slots", "sessions", "settingsScope"];
|
|
1595
|
+
var TELEMETRY_VIEW = [
|
|
1596
|
+
{ stage: "utterance-end", key: "telUtteranceEnd" },
|
|
1597
|
+
{ stage: "endpoint-fired", key: "telEndpoint" },
|
|
1598
|
+
{ stage: "submitted", key: "telSubmitted" },
|
|
1599
|
+
{ stage: "first-llm-token", key: "telFirstToken" },
|
|
1600
|
+
{ stage: "first-sentence-text", key: "telFirstSentence" },
|
|
1601
|
+
{ stage: "first-tts-chunk", key: "telFirstChunk" },
|
|
1602
|
+
{ stage: "first-audio-played", key: "telFirstPlayed" }
|
|
1603
|
+
];
|
|
1604
|
+
var BUILD_TAG = "f1bc49c";
|
|
1605
|
+
var TELEMETRY_FLAG = "dsh-voice-mode.telemetry";
|
|
1606
|
+
var telemetryEnabled = typeof localStorage !== "undefined" && localStorage.getItem(TELEMETRY_FLAG) === "1";
|
|
1607
|
+
console.log("[dsh-voice] build=" + BUILD_TAG);
|
|
1608
|
+
var debugLog = (event, fields = {}) => {
|
|
1609
|
+
if (!telemetryEnabled) return;
|
|
1610
|
+
const out = {};
|
|
1611
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
1612
|
+
out[k] = typeof v === "number" && !Number.isInteger(v) ? Number(v.toFixed(4)) : v;
|
|
1613
|
+
}
|
|
1614
|
+
console.log("[dsh-voice]", event, JSON.stringify(out));
|
|
1615
|
+
};
|
|
1616
|
+
var SAMPLE_RATE_16K = 16e3;
|
|
1617
|
+
var ECHO_DELAY_MS = 0;
|
|
1618
|
+
var ECHO_TAIL_MS = 400;
|
|
1046
1619
|
var WAVE_BARS = 14;
|
|
1047
1620
|
var BASE_PATH2 = "/voice-mode";
|
|
1621
|
+
function getTabId() {
|
|
1622
|
+
try {
|
|
1623
|
+
const KEY = "dshvm-tabId";
|
|
1624
|
+
let id = sessionStorage.getItem(KEY);
|
|
1625
|
+
if (!id) {
|
|
1626
|
+
id = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
1627
|
+
sessionStorage.setItem(KEY, id);
|
|
1628
|
+
}
|
|
1629
|
+
return id;
|
|
1630
|
+
} catch {
|
|
1631
|
+
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
var TAB_ID = getTabId();
|
|
1635
|
+
function parseShortcut(s) {
|
|
1636
|
+
const parts = (s || "").split("+");
|
|
1637
|
+
const mods = { ctrl: false, shift: false, alt: false, meta: false };
|
|
1638
|
+
let key = "";
|
|
1639
|
+
for (const raw of parts) {
|
|
1640
|
+
const t3 = raw.trim().toLowerCase();
|
|
1641
|
+
if (t3 === "ctrl" || t3 === "control") mods.ctrl = true;
|
|
1642
|
+
else if (t3 === "shift") mods.shift = true;
|
|
1643
|
+
else if (t3 === "alt" || t3 === "option") mods.alt = true;
|
|
1644
|
+
else if (t3 === "meta" || t3 === "cmd" || t3 === "command") mods.meta = true;
|
|
1645
|
+
else if (t3.length === 1 && /^[a-z0-9]$/.test(t3)) {
|
|
1646
|
+
if (key) return null;
|
|
1647
|
+
key = t3;
|
|
1648
|
+
} else return null;
|
|
1649
|
+
}
|
|
1650
|
+
if (!key) return null;
|
|
1651
|
+
if (!mods.ctrl && !mods.shift && !mods.alt && !mods.meta) return null;
|
|
1652
|
+
return { ...mods, key };
|
|
1653
|
+
}
|
|
1654
|
+
function getLastVoiceSession() {
|
|
1655
|
+
try {
|
|
1656
|
+
return localStorage.getItem("dshvm-last-voice");
|
|
1657
|
+
} catch {
|
|
1658
|
+
return null;
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
function setLastVoiceSession(id) {
|
|
1662
|
+
try {
|
|
1663
|
+
if (id) localStorage.setItem("dshvm-last-voice", id);
|
|
1664
|
+
else localStorage.removeItem("dshvm-last-voice");
|
|
1665
|
+
} catch {
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1048
1668
|
function apply(ctx) {
|
|
1049
1669
|
const bus = createVoiceBus(void 0, ctx);
|
|
1050
1670
|
ctx.slots.inject(
|
|
@@ -1098,74 +1718,171 @@ function apply(ctx) {
|
|
|
1098
1718
|
);
|
|
1099
1719
|
}
|
|
1100
1720
|
}
|
|
1101
|
-
function createAudioEngine(setUi) {
|
|
1102
|
-
const
|
|
1103
|
-
const
|
|
1104
|
-
|
|
1105
|
-
|
|
1721
|
+
function createAudioEngine(setUi, onPlayed, onPlaybackRef, onAllPlayed) {
|
|
1722
|
+
const pending = [];
|
|
1723
|
+
const fallbackAudio = new Audio();
|
|
1724
|
+
let fallback = false;
|
|
1725
|
+
let ctx = null;
|
|
1726
|
+
let duckGain = null;
|
|
1727
|
+
let nextEndAt = 0;
|
|
1728
|
+
const activeSrcs = /* @__PURE__ */ new Set();
|
|
1729
|
+
let decoding = false;
|
|
1730
|
+
const captionQueue = [];
|
|
1731
|
+
const warm = () => {
|
|
1732
|
+
if (ctx) {
|
|
1733
|
+
void ctx.resume?.();
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
try {
|
|
1737
|
+
const AC = window.AudioContext ?? window.webkitAudioContext;
|
|
1738
|
+
ctx = new AC();
|
|
1739
|
+
duckGain = ctx.createGain();
|
|
1740
|
+
duckGain.gain.value = 1;
|
|
1741
|
+
duckGain.connect(ctx.destination);
|
|
1742
|
+
void ctx.resume?.();
|
|
1743
|
+
} catch {
|
|
1744
|
+
ctx = null;
|
|
1745
|
+
}
|
|
1746
|
+
};
|
|
1747
|
+
const playFallback = () => {
|
|
1748
|
+
const frame = pending.shift() ?? null;
|
|
1106
1749
|
if (!frame) {
|
|
1107
1750
|
setUi({ playing: false, playingCaption: null });
|
|
1108
1751
|
return;
|
|
1109
1752
|
}
|
|
1110
|
-
const
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
const url = URL.createObjectURL(new Blob([bytes], { type: "audio/mpeg" }));
|
|
1114
|
-
audio.src = url;
|
|
1115
|
-
audio.onended = () => {
|
|
1753
|
+
const url = URL.createObjectURL(new Blob([frame.audio], { type: "audio/mpeg" }));
|
|
1754
|
+
fallbackAudio.src = url;
|
|
1755
|
+
fallbackAudio.onended = () => {
|
|
1116
1756
|
URL.revokeObjectURL(url);
|
|
1117
|
-
|
|
1757
|
+
playFallback();
|
|
1118
1758
|
};
|
|
1119
|
-
|
|
1759
|
+
fallbackAudio.onerror = () => {
|
|
1120
1760
|
URL.revokeObjectURL(url);
|
|
1121
|
-
|
|
1761
|
+
playFallback();
|
|
1762
|
+
};
|
|
1763
|
+
fallbackAudio.onplaying = () => {
|
|
1764
|
+
try {
|
|
1765
|
+
onPlayed?.();
|
|
1766
|
+
} catch {
|
|
1767
|
+
}
|
|
1768
|
+
try {
|
|
1769
|
+
if (ctx && frame.audio.length) {
|
|
1770
|
+
void ctx.decodeAudioData(frame.audio.buffer.slice(0)).then((buf) => {
|
|
1771
|
+
onPlaybackRef?.(buf.getChannelData(0), buf.sampleRate, performance.now());
|
|
1772
|
+
}).catch(() => {
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1775
|
+
} catch {
|
|
1776
|
+
}
|
|
1122
1777
|
};
|
|
1123
1778
|
setUi({ playing: true, playingCaption: frame.text, ttsNotice: null });
|
|
1124
|
-
void
|
|
1779
|
+
void fallbackAudio.play().catch(() => playFallback());
|
|
1125
1780
|
};
|
|
1126
|
-
const
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1781
|
+
const drainPending = () => {
|
|
1782
|
+
if (decoding || !ctx || !duckGain || pending.length === 0) return;
|
|
1783
|
+
decoding = true;
|
|
1784
|
+
void (async () => {
|
|
1785
|
+
try {
|
|
1786
|
+
while (pending.length > 0) {
|
|
1787
|
+
const frame = pending[0];
|
|
1788
|
+
const buf = await ctx.decodeAudioData(frame.audio.buffer.slice(0));
|
|
1789
|
+
if (pending.length === 0 || pending[0] !== frame) return;
|
|
1790
|
+
pending.shift();
|
|
1791
|
+
const t0 = ctx.currentTime;
|
|
1792
|
+
const at = Math.max(t0 + 0.02, nextEndAt);
|
|
1793
|
+
const src = ctx.createBufferSource();
|
|
1794
|
+
src.buffer = buf;
|
|
1795
|
+
src.connect(duckGain);
|
|
1796
|
+
activeSrcs.add(src);
|
|
1797
|
+
src.onended = () => {
|
|
1798
|
+
activeSrcs.delete(src);
|
|
1799
|
+
captionQueue.shift();
|
|
1800
|
+
if (activeSrcs.size === 0 && pending.length === 0) {
|
|
1801
|
+
setUi({ playing: false, playingCaption: null });
|
|
1802
|
+
onAllPlayed?.();
|
|
1803
|
+
} else if (captionQueue.length > 0) {
|
|
1804
|
+
setUi({ playingCaption: captionQueue[0] });
|
|
1805
|
+
}
|
|
1806
|
+
};
|
|
1807
|
+
src.start(at);
|
|
1808
|
+
nextEndAt = at + buf.duration;
|
|
1809
|
+
try {
|
|
1810
|
+
const outLat = ctx.outputLatency ?? 0;
|
|
1811
|
+
const wallMs = performance.now() + (at + outLat - ctx.currentTime) * 1e3;
|
|
1812
|
+
onPlaybackRef?.(buf.getChannelData(0), buf.sampleRate, wallMs);
|
|
1813
|
+
} catch {
|
|
1814
|
+
}
|
|
1815
|
+
try {
|
|
1816
|
+
onPlayed?.();
|
|
1817
|
+
} catch {
|
|
1818
|
+
}
|
|
1819
|
+
captionQueue.push(frame.text);
|
|
1820
|
+
setUi({ playing: true, playingCaption: captionQueue[0], ttsNotice: null });
|
|
1821
|
+
}
|
|
1822
|
+
} catch {
|
|
1823
|
+
for (const src of activeSrcs) {
|
|
1824
|
+
try {
|
|
1825
|
+
src.stop();
|
|
1826
|
+
} catch {
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
activeSrcs.clear();
|
|
1830
|
+
captionQueue.length = 0;
|
|
1831
|
+
fallback = true;
|
|
1832
|
+
playFallback();
|
|
1833
|
+
} finally {
|
|
1834
|
+
decoding = false;
|
|
1835
|
+
}
|
|
1836
|
+
})();
|
|
1143
1837
|
};
|
|
1144
1838
|
return {
|
|
1145
1839
|
push(frame) {
|
|
1146
|
-
|
|
1147
|
-
|
|
1840
|
+
if (fallback || !ctx) {
|
|
1841
|
+
pending.push(frame);
|
|
1842
|
+
if (fallbackAudio.paused) playFallback();
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1845
|
+
pending.push(frame);
|
|
1846
|
+
drainPending();
|
|
1148
1847
|
},
|
|
1149
1848
|
skip() {
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1849
|
+
pending.length = 0;
|
|
1850
|
+
nextEndAt = 0;
|
|
1851
|
+
fallbackAudio.pause();
|
|
1852
|
+
fallbackAudio.onended = null;
|
|
1853
|
+
fallbackAudio.onerror = null;
|
|
1854
|
+
for (const src of activeSrcs) {
|
|
1855
|
+
try {
|
|
1856
|
+
src.stop();
|
|
1857
|
+
} catch {
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
activeSrcs.clear();
|
|
1861
|
+
captionQueue.length = 0;
|
|
1154
1862
|
setUi({ playing: false, playingCaption: null });
|
|
1155
1863
|
},
|
|
1156
|
-
|
|
1864
|
+
warm,
|
|
1865
|
+
unduck() {
|
|
1866
|
+
if (!ctx || !duckGain) return;
|
|
1867
|
+
const now = ctx.currentTime;
|
|
1868
|
+
duckGain.gain.cancelScheduledValues(now);
|
|
1869
|
+
duckGain.gain.setTargetAtTime(1, now, 0.035);
|
|
1870
|
+
}
|
|
1157
1871
|
};
|
|
1158
1872
|
}
|
|
1159
1873
|
function createVoiceBus(basePath = BASE_PATH2, ctx) {
|
|
1160
1874
|
let activeSessionId = null;
|
|
1161
1875
|
const DEFAULT_BOOT = {
|
|
1162
1876
|
basePath: BASE_PATH2,
|
|
1163
|
-
silenceMs:
|
|
1877
|
+
silenceMs: 700,
|
|
1164
1878
|
interruptLevel: 0,
|
|
1165
1879
|
idleTimeoutMinutes: 10,
|
|
1166
1880
|
autoSend: true,
|
|
1881
|
+
autoResume: false,
|
|
1167
1882
|
mode: "toggle",
|
|
1168
|
-
|
|
1883
|
+
bargeInMode: "auto",
|
|
1884
|
+
echoGateDb: 6,
|
|
1885
|
+
shortcut: "Ctrl+Shift+V"
|
|
1169
1886
|
};
|
|
1170
1887
|
const ui = {
|
|
1171
1888
|
state: "idle",
|
|
@@ -1178,16 +1895,141 @@ function createVoiceBus(basePath = BASE_PATH2, ctx) {
|
|
|
1178
1895
|
ttsNotice: null,
|
|
1179
1896
|
boot: DEFAULT_BOOT,
|
|
1180
1897
|
mode: "toggle",
|
|
1181
|
-
|
|
1898
|
+
telemetry: null,
|
|
1899
|
+
turn: "idle"
|
|
1182
1900
|
};
|
|
1183
1901
|
const listeners = /* @__PURE__ */ new Set();
|
|
1184
1902
|
const audioListeners = /* @__PURE__ */ new Set();
|
|
1185
|
-
const toolListeners = /* @__PURE__ */ new Set();
|
|
1186
1903
|
let source = null;
|
|
1187
|
-
|
|
1188
|
-
|
|
1904
|
+
let playingEndAt = 0;
|
|
1905
|
+
const telemetryStages = {};
|
|
1906
|
+
const stampTelemetry = (stage, at) => {
|
|
1907
|
+
if (!telemetryEnabled) return;
|
|
1908
|
+
if (stage === "utterance-end") {
|
|
1909
|
+
for (const k of Object.keys(telemetryStages)) delete telemetryStages[k];
|
|
1910
|
+
}
|
|
1911
|
+
if (telemetryStages[stage] === void 0) {
|
|
1912
|
+
telemetryStages[stage] = at ?? Date.now();
|
|
1913
|
+
ui.telemetry = { ...telemetryStages };
|
|
1914
|
+
notify();
|
|
1915
|
+
}
|
|
1916
|
+
};
|
|
1917
|
+
const resetTelemetry = () => {
|
|
1918
|
+
if (!telemetryEnabled) return;
|
|
1919
|
+
for (const k of Object.keys(telemetryStages)) delete telemetryStages[k];
|
|
1920
|
+
ui.telemetry = null;
|
|
1921
|
+
ui.interruptConfirmMs = void 0;
|
|
1189
1922
|
notify();
|
|
1190
|
-
}
|
|
1923
|
+
};
|
|
1924
|
+
const refChunks = [];
|
|
1925
|
+
let refTotal = 0;
|
|
1926
|
+
let refStartWall = 0;
|
|
1927
|
+
let refActive = false;
|
|
1928
|
+
const pushRef = (pcmSrc, srcRate, startWallMs) => {
|
|
1929
|
+
const pcm = resampleLinear(pcmSrc, srcRate, SAMPLE_RATE_16K);
|
|
1930
|
+
if (!refActive) {
|
|
1931
|
+
refActive = true;
|
|
1932
|
+
refStartWall = startWallMs;
|
|
1933
|
+
refChunks.length = 0;
|
|
1934
|
+
refTotal = 0;
|
|
1935
|
+
}
|
|
1936
|
+
const tailWall = refStartWall + refTotal / SAMPLE_RATE_16K * 1e3;
|
|
1937
|
+
const gapMs = startWallMs - tailWall;
|
|
1938
|
+
if (gapMs > 250) {
|
|
1939
|
+
refChunks.length = 0;
|
|
1940
|
+
refTotal = 0;
|
|
1941
|
+
refStartWall = startWallMs;
|
|
1942
|
+
} else if (gapMs > 1) {
|
|
1943
|
+
const padN = Math.floor(gapMs / 1e3 * SAMPLE_RATE_16K);
|
|
1944
|
+
refChunks.push(new Float32Array(padN));
|
|
1945
|
+
refTotal += padN;
|
|
1946
|
+
}
|
|
1947
|
+
refChunks.push(pcm);
|
|
1948
|
+
refTotal += pcm.length;
|
|
1949
|
+
const maxTotal = SAMPLE_RATE_16K * 60;
|
|
1950
|
+
while (refTotal - (refChunks[0]?.length ?? 0) > maxTotal) {
|
|
1951
|
+
refTotal -= refChunks.shift().length;
|
|
1952
|
+
}
|
|
1953
|
+
};
|
|
1954
|
+
const refWindowAt = (tWallMs, n) => {
|
|
1955
|
+
const out = new Float32Array(n);
|
|
1956
|
+
if (!refActive || refTotal === 0) return out;
|
|
1957
|
+
const idx = Math.floor((tWallMs - ECHO_DELAY_MS - refStartWall) / 1e3 * SAMPLE_RATE_16K);
|
|
1958
|
+
if (idx < 0 || idx >= refTotal) return out;
|
|
1959
|
+
let acc = 0;
|
|
1960
|
+
let outOff = 0;
|
|
1961
|
+
for (const c of refChunks) {
|
|
1962
|
+
if (outOff >= n) break;
|
|
1963
|
+
if (idx >= acc + c.length) {
|
|
1964
|
+
acc += c.length;
|
|
1965
|
+
continue;
|
|
1966
|
+
}
|
|
1967
|
+
const start = Math.max(0, idx - acc);
|
|
1968
|
+
const cnt = Math.min(c.length - start, n - outOff);
|
|
1969
|
+
out.set(c.subarray(start, start + cnt), outOff);
|
|
1970
|
+
outOff += cnt;
|
|
1971
|
+
acc += c.length;
|
|
1972
|
+
}
|
|
1973
|
+
return out;
|
|
1974
|
+
};
|
|
1975
|
+
const aec = new NlmsAec({ filterLength: 1024, delay: 0 });
|
|
1976
|
+
let refDelaySamples = 0;
|
|
1977
|
+
let estMic = [];
|
|
1978
|
+
let estRef = [];
|
|
1979
|
+
const EST_CAP = SAMPLE_RATE_16K;
|
|
1980
|
+
let lastEstimateAt = 0;
|
|
1981
|
+
let echoBypass = false;
|
|
1982
|
+
const echoSource = {
|
|
1983
|
+
process: (mic, ref) => {
|
|
1984
|
+
if (echoBypass) return mic;
|
|
1985
|
+
const now = performance.now();
|
|
1986
|
+
if (ui.playing) {
|
|
1987
|
+
for (let i = 0; i < mic.length; i++) estMic.push(mic[i]);
|
|
1988
|
+
for (let i = 0; i < ref.length; i++) estRef.push(ref[i]);
|
|
1989
|
+
if (estMic.length > EST_CAP) {
|
|
1990
|
+
const drop = estMic.length - EST_CAP;
|
|
1991
|
+
estMic.splice(0, drop);
|
|
1992
|
+
estRef.splice(0, drop);
|
|
1993
|
+
}
|
|
1994
|
+
if (now - lastEstimateAt > 2e3 && estMic.length > SAMPLE_RATE_16K * 0.5) {
|
|
1995
|
+
lastEstimateAt = now;
|
|
1996
|
+
const est = estimateBulkDelay(
|
|
1997
|
+
Float32Array.from(estMic),
|
|
1998
|
+
Float32Array.from(estRef),
|
|
1999
|
+
{ sampleRate: SAMPLE_RATE_16K, maxLag: Math.floor(0.25 * SAMPLE_RATE_16K) }
|
|
2000
|
+
);
|
|
2001
|
+
if (est.peak > 0.5) {
|
|
2002
|
+
refDelaySamples = refDelaySamples === 0 ? est.lag : Math.round(refDelaySamples * 0.8 + est.lag * 0.2);
|
|
2003
|
+
}
|
|
2004
|
+
estMic.length = 0;
|
|
2005
|
+
estRef.length = 0;
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
let refForAec = ref;
|
|
2009
|
+
if (refDelaySamples > 0 && refActive && refTotal > refDelaySamples) {
|
|
2010
|
+
const shiftMs = refDelaySamples / SAMPLE_RATE_16K * 1e3;
|
|
2011
|
+
refForAec = refWindowAt(now - shiftMs, ref.length);
|
|
2012
|
+
}
|
|
2013
|
+
return aec.process(mic, refForAec);
|
|
2014
|
+
},
|
|
2015
|
+
windowAt: refWindowAt,
|
|
2016
|
+
// A2.5 双讲冻结:用户说话时暂停 NLMS 自适应。
|
|
2017
|
+
setFrozen: (frozen) => aec.setFrozen(frozen)
|
|
2018
|
+
};
|
|
2019
|
+
const engine = createAudioEngine(
|
|
2020
|
+
(patch) => {
|
|
2021
|
+
Object.assign(ui, patch);
|
|
2022
|
+
notify();
|
|
2023
|
+
},
|
|
2024
|
+
() => stampTelemetry("first-audio-played"),
|
|
2025
|
+
(pcm, sampleRate, wallMs) => pushRef(pcm, sampleRate, wallMs),
|
|
2026
|
+
// Fix:自然播完(无 TTS 在播)即清参考池——AEC 不再拿旧回合参考适配新语音。
|
|
2027
|
+
() => {
|
|
2028
|
+
refActive = false;
|
|
2029
|
+
refChunks.length = 0;
|
|
2030
|
+
refTotal = 0;
|
|
2031
|
+
}
|
|
2032
|
+
);
|
|
1191
2033
|
const notify = () => {
|
|
1192
2034
|
for (const fn of listeners) {
|
|
1193
2035
|
try {
|
|
@@ -1198,13 +2040,23 @@ function createVoiceBus(basePath = BASE_PATH2, ctx) {
|
|
|
1198
2040
|
};
|
|
1199
2041
|
const connect = () => {
|
|
1200
2042
|
if (source) return;
|
|
1201
|
-
source = new EventSource(`${location.origin}${basePath}/stream`);
|
|
2043
|
+
source = new EventSource(`${location.origin}${basePath}/stream?tabId=${encodeURIComponent(TAB_ID)}`);
|
|
2044
|
+
source.addEventListener("open", () => {
|
|
2045
|
+
rejectSeqUpTo.clear();
|
|
2046
|
+
lastFinalSeq.clear();
|
|
2047
|
+
});
|
|
1202
2048
|
source.addEventListener("mode", (e) => {
|
|
1203
2049
|
try {
|
|
1204
|
-
const
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
2050
|
+
const data = JSON.parse(e.data);
|
|
2051
|
+
const active = data.active ?? null;
|
|
2052
|
+
const ownerTabId = data.ownerTabId ?? null;
|
|
2053
|
+
const preempted = active !== activeSessionId || ownerTabId !== null && activeSessionId !== null && ownerTabId !== TAB_ID;
|
|
2054
|
+
if (activeSessionId !== null && preempted) {
|
|
2055
|
+
const prev = activeSessionId;
|
|
2056
|
+
activeSessionId = null;
|
|
2057
|
+
if (ui.turn !== "idle") ui.turn = "idle";
|
|
2058
|
+
doSkipAudio(prev);
|
|
2059
|
+
resetTelemetry();
|
|
1208
2060
|
notify();
|
|
1209
2061
|
}
|
|
1210
2062
|
} catch {
|
|
@@ -1223,18 +2075,23 @@ function createVoiceBus(basePath = BASE_PATH2, ctx) {
|
|
|
1223
2075
|
} catch {
|
|
1224
2076
|
}
|
|
1225
2077
|
});
|
|
1226
|
-
source.addEventListener("
|
|
2078
|
+
source.addEventListener("turn", (e) => {
|
|
1227
2079
|
try {
|
|
1228
2080
|
const ev = JSON.parse(e.data);
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
} catch {
|
|
1233
|
-
}
|
|
2081
|
+
if (ev.sessionId === activeSessionId && ev.state) {
|
|
2082
|
+
ui.turn = ev.state;
|
|
2083
|
+
notify();
|
|
1234
2084
|
}
|
|
1235
2085
|
} catch {
|
|
1236
2086
|
}
|
|
1237
2087
|
});
|
|
2088
|
+
source.addEventListener("latency", (e) => {
|
|
2089
|
+
try {
|
|
2090
|
+
const ev = JSON.parse(e.data);
|
|
2091
|
+
if (ev.sessionId === activeSessionId && ev.stage) stampTelemetry(ev.stage);
|
|
2092
|
+
} catch {
|
|
2093
|
+
}
|
|
2094
|
+
});
|
|
1238
2095
|
source.addEventListener("asr-progress", (e) => {
|
|
1239
2096
|
try {
|
|
1240
2097
|
const p = JSON.parse(e.data);
|
|
@@ -1270,10 +2127,72 @@ function createVoiceBus(basePath = BASE_PATH2, ctx) {
|
|
|
1270
2127
|
});
|
|
1271
2128
|
};
|
|
1272
2129
|
connect();
|
|
2130
|
+
const rejectSeqUpTo = /* @__PURE__ */ new Map();
|
|
2131
|
+
const lastFinalSeq = /* @__PURE__ */ new Map();
|
|
2132
|
+
let curSentenceId = null;
|
|
2133
|
+
let curChunks = [];
|
|
2134
|
+
let curBytes = 0;
|
|
2135
|
+
let curChunkCount = 0;
|
|
1273
2136
|
audioListeners.add((frame) => {
|
|
1274
|
-
if (frame.sessionId
|
|
2137
|
+
if (frame.sessionId !== activeSessionId) return;
|
|
2138
|
+
const rejectLine = rejectSeqUpTo.get(frame.sessionId);
|
|
2139
|
+
if (rejectLine !== void 0 && frame.sentenceId <= rejectLine) return;
|
|
2140
|
+
stampTelemetry("first-tts-chunk");
|
|
2141
|
+
if (frame.sentenceId !== curSentenceId) {
|
|
2142
|
+
curSentenceId = frame.sentenceId;
|
|
2143
|
+
curChunks = [];
|
|
2144
|
+
curBytes = 0;
|
|
2145
|
+
curChunkCount = 0;
|
|
2146
|
+
}
|
|
2147
|
+
if (frame.final) {
|
|
2148
|
+
if (frame.chunkId !== curChunkCount) {
|
|
2149
|
+
curSentenceId = null;
|
|
2150
|
+
curChunks = [];
|
|
2151
|
+
curBytes = 0;
|
|
2152
|
+
curChunkCount = 0;
|
|
2153
|
+
return;
|
|
2154
|
+
}
|
|
2155
|
+
const buf = new Uint8Array(curBytes);
|
|
2156
|
+
let off = 0;
|
|
2157
|
+
for (const c of curChunks) {
|
|
2158
|
+
buf.set(c, off);
|
|
2159
|
+
off += c.length;
|
|
2160
|
+
}
|
|
2161
|
+
curSentenceId = null;
|
|
2162
|
+
curChunks = [];
|
|
2163
|
+
curBytes = 0;
|
|
2164
|
+
curChunkCount = 0;
|
|
2165
|
+
if (buf.length === 0 || buf[0] !== 255) return;
|
|
2166
|
+
engine.push({
|
|
2167
|
+
sessionId: frame.sessionId,
|
|
2168
|
+
seq: frame.sentenceId,
|
|
2169
|
+
text: frame.text ?? "",
|
|
2170
|
+
audio: buf
|
|
2171
|
+
});
|
|
2172
|
+
lastFinalSeq.set(frame.sessionId, frame.sentenceId);
|
|
2173
|
+
return;
|
|
2174
|
+
}
|
|
2175
|
+
const bin = atob(frame.audio);
|
|
2176
|
+
const bytes = new Uint8Array(bin.length);
|
|
2177
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
2178
|
+
curChunks.push(bytes);
|
|
2179
|
+
curBytes += bytes.length;
|
|
2180
|
+
curChunkCount += 1;
|
|
1275
2181
|
});
|
|
1276
|
-
|
|
2182
|
+
const doSkipAudio = (sidArg) => {
|
|
2183
|
+
const sid = sidArg ?? activeSessionId;
|
|
2184
|
+
if (sid) {
|
|
2185
|
+
rejectSeqUpTo.set(sid, Math.max(lastFinalSeq.get(sid) ?? -1, curSentenceId ?? -1));
|
|
2186
|
+
}
|
|
2187
|
+
curSentenceId = null;
|
|
2188
|
+
curChunks = [];
|
|
2189
|
+
curBytes = 0;
|
|
2190
|
+
refActive = false;
|
|
2191
|
+
refChunks.length = 0;
|
|
2192
|
+
refTotal = 0;
|
|
2193
|
+
engine.skip();
|
|
2194
|
+
playingEndAt = 0;
|
|
2195
|
+
};
|
|
1277
2196
|
return {
|
|
1278
2197
|
get activeSessionId() {
|
|
1279
2198
|
return activeSessionId;
|
|
@@ -1287,34 +2206,47 @@ function createVoiceBus(basePath = BASE_PATH2, ctx) {
|
|
|
1287
2206
|
};
|
|
1288
2207
|
},
|
|
1289
2208
|
setUi(patch) {
|
|
2209
|
+
if (patch.playing === false && ui.playing === true) playingEndAt = Date.now();
|
|
1290
2210
|
Object.assign(ui, patch);
|
|
1291
2211
|
notify();
|
|
1292
2212
|
},
|
|
2213
|
+
/** isPlaying 尾音截止墙钟:playing 或尾音宽限期内均视为「AI 正在朗读」。 */
|
|
2214
|
+
playingTailUntil() {
|
|
2215
|
+
return playingEndAt + ECHO_TAIL_MS + refDelaySamples / SAMPLE_RATE_16K * 1e3;
|
|
2216
|
+
},
|
|
1293
2217
|
async enter(sessionId) {
|
|
1294
2218
|
try {
|
|
1295
2219
|
const res = await fetch(`${location.origin}${basePath}/toggle`, {
|
|
1296
2220
|
method: "POST",
|
|
1297
2221
|
headers: { "content-type": "application/json" },
|
|
1298
|
-
body: JSON.stringify({ sessionId, on: true })
|
|
2222
|
+
body: JSON.stringify({ sessionId, on: true, tabId: TAB_ID })
|
|
1299
2223
|
});
|
|
1300
2224
|
const out = await res.json();
|
|
1301
|
-
activeSessionId = out.active
|
|
2225
|
+
activeSessionId = out.active === sessionId ? sessionId : null;
|
|
1302
2226
|
notify();
|
|
1303
2227
|
if (!res.ok) return { ok: false, error: out.error ?? t("enterFail") };
|
|
1304
|
-
|
|
2228
|
+
if (out.active === sessionId) setLastVoiceSession(sessionId);
|
|
2229
|
+
return {
|
|
2230
|
+
ok: out.active === sessionId,
|
|
2231
|
+
preempted: out.active !== null && out.active !== sessionId,
|
|
2232
|
+
error: out.active === sessionId ? void 0 : t("enterFail")
|
|
2233
|
+
};
|
|
1305
2234
|
} catch {
|
|
1306
2235
|
return { ok: false, error: t("enterFail") };
|
|
1307
2236
|
}
|
|
1308
2237
|
},
|
|
1309
2238
|
async exit(sessionId) {
|
|
2239
|
+
resetTelemetry();
|
|
2240
|
+
ui.turn = "idle";
|
|
2241
|
+
doSkipAudio();
|
|
1310
2242
|
try {
|
|
1311
2243
|
const res = await fetch(`${location.origin}${basePath}/toggle`, {
|
|
1312
2244
|
method: "POST",
|
|
1313
2245
|
headers: { "content-type": "application/json" },
|
|
1314
|
-
body: JSON.stringify({ sessionId, on: false })
|
|
2246
|
+
body: JSON.stringify({ sessionId, on: false, tabId: TAB_ID })
|
|
1315
2247
|
});
|
|
1316
|
-
|
|
1317
|
-
activeSessionId =
|
|
2248
|
+
await res.json();
|
|
2249
|
+
activeSessionId = null;
|
|
1318
2250
|
notify();
|
|
1319
2251
|
} catch {
|
|
1320
2252
|
}
|
|
@@ -1325,20 +2257,31 @@ function createVoiceBus(basePath = BASE_PATH2, ctx) {
|
|
|
1325
2257
|
audioListeners.delete(fn);
|
|
1326
2258
|
};
|
|
1327
2259
|
},
|
|
1328
|
-
onToolEvent(fn) {
|
|
1329
|
-
toolListeners.add(fn);
|
|
1330
|
-
return () => {
|
|
1331
|
-
toolListeners.delete(fn);
|
|
1332
|
-
};
|
|
1333
|
-
},
|
|
1334
2260
|
skipAudio() {
|
|
1335
|
-
|
|
2261
|
+
doSkipAudio();
|
|
2262
|
+
},
|
|
2263
|
+
echoForAsr() {
|
|
2264
|
+
return echoSource;
|
|
2265
|
+
},
|
|
2266
|
+
setEchoBypass(on) {
|
|
2267
|
+
echoBypass = on;
|
|
2268
|
+
},
|
|
2269
|
+
echoDelayMs() {
|
|
2270
|
+
return refDelaySamples / SAMPLE_RATE_16K * 1e3;
|
|
2271
|
+
},
|
|
2272
|
+
unduckAudio() {
|
|
2273
|
+
engine.unduck();
|
|
1336
2274
|
},
|
|
1337
2275
|
cancelTurn(sessionId) {
|
|
1338
2276
|
try {
|
|
1339
2277
|
ctx?.sessions?.binding?.(sessionId)?.session.cancel?.();
|
|
1340
2278
|
} catch {
|
|
1341
2279
|
}
|
|
2280
|
+
},
|
|
2281
|
+
stampTelemetry,
|
|
2282
|
+
resetTelemetry,
|
|
2283
|
+
warmAudio() {
|
|
2284
|
+
engine.warm();
|
|
1342
2285
|
}
|
|
1343
2286
|
};
|
|
1344
2287
|
}
|
|
@@ -1372,8 +2315,12 @@ function MicButton({
|
|
|
1372
2315
|
const submitTimerRef = (0, import_react2.useRef)(null);
|
|
1373
2316
|
const idleTimerRef = (0, import_react2.useRef)(null);
|
|
1374
2317
|
const runningRef = (0, import_react2.useRef)(false);
|
|
2318
|
+
const mountedRef = (0, import_react2.useRef)(true);
|
|
1375
2319
|
const holdCtrlRef = (0, import_react2.useRef)(false);
|
|
1376
|
-
const
|
|
2320
|
+
const manualHoldRef = (0, import_react2.useRef)(false);
|
|
2321
|
+
const breakRef = (0, import_react2.useRef)(null);
|
|
2322
|
+
const pausedForHiddenRef = (0, import_react2.useRef)(false);
|
|
2323
|
+
const bootNow = () => bus.ui.boot ?? { basePath: "/voice-mode", silenceMs: 700, interruptLevel: 0, idleTimeoutMinutes: 10, autoSend: true, autoResume: false, mode: "toggle", bargeInMode: "auto", echoGateDb: 6, shortcut: "Ctrl+Shift+V" };
|
|
1377
2324
|
useVoiceCss();
|
|
1378
2325
|
const [, bumpUi] = (0, import_react2.useState)(0);
|
|
1379
2326
|
(0, import_react2.useEffect)(
|
|
@@ -1398,10 +2345,13 @@ function MicButton({
|
|
|
1398
2345
|
interruptLevel: c.interruptLevel ?? cur.interruptLevel,
|
|
1399
2346
|
idleTimeoutMinutes: c.idleTimeoutMinutes ?? cur.idleTimeoutMinutes,
|
|
1400
2347
|
autoSend: c.autoSend ?? cur.autoSend,
|
|
2348
|
+
autoResume: c.autoResume === true,
|
|
1401
2349
|
mode: c.mode === "hold" ? "hold" : "toggle",
|
|
1402
|
-
|
|
2350
|
+
bargeInMode: c.bargeInMode === "manual" ? "manual" : "auto",
|
|
2351
|
+
echoGateDb: typeof c.echoGateDb === "number" ? Math.min(12, Math.max(3, c.echoGateDb)) : cur.echoGateDb,
|
|
2352
|
+
shortcut: typeof c.shortcut === "string" ? c.shortcut : cur.shortcut
|
|
1403
2353
|
};
|
|
1404
|
-
bus.setUi({ boot: next, mode: next.mode
|
|
2354
|
+
bus.setUi({ boot: next, mode: next.mode });
|
|
1405
2355
|
return next;
|
|
1406
2356
|
} catch {
|
|
1407
2357
|
return bootNow();
|
|
@@ -1428,14 +2378,24 @@ function MicButton({
|
|
|
1428
2378
|
if (bus.activeSessionId !== sid) {
|
|
1429
2379
|
setLocalMode("off");
|
|
1430
2380
|
clearIdle();
|
|
1431
|
-
|
|
2381
|
+
cancelPendingSubmit();
|
|
2382
|
+
clearBreakTimer();
|
|
2383
|
+
setHolding(false);
|
|
2384
|
+
isSpeechTrueCount = 0;
|
|
2385
|
+
breakRef.current = null;
|
|
2386
|
+
manualHoldRef.current = false;
|
|
2387
|
+
holdCtrlRef.current = false;
|
|
2388
|
+
const engine = engineRef.current;
|
|
1432
2389
|
engineRef.current = null;
|
|
2390
|
+
if (engine) void engine.stop();
|
|
2391
|
+
bus.resetTelemetry();
|
|
2392
|
+
bus.setUi({ state: "idle", partial: "", levels: [], error: null, model: null, ttsNotice: null, isSpeech: void 0 });
|
|
1433
2393
|
}
|
|
1434
2394
|
});
|
|
1435
2395
|
}, [bus]);
|
|
1436
2396
|
const cancelPendingSubmit = () => {
|
|
1437
2397
|
if (submitTimerRef.current) {
|
|
1438
|
-
|
|
2398
|
+
clearInterval(submitTimerRef.current);
|
|
1439
2399
|
submitTimerRef.current = null;
|
|
1440
2400
|
}
|
|
1441
2401
|
};
|
|
@@ -1444,38 +2404,174 @@ function MicButton({
|
|
|
1444
2404
|
setLocalMode("off");
|
|
1445
2405
|
clearIdle();
|
|
1446
2406
|
cancelPendingSubmit();
|
|
2407
|
+
isSpeechTrueCount = 0;
|
|
2408
|
+
breakRef.current = null;
|
|
2409
|
+
manualHoldRef.current = false;
|
|
2410
|
+
clearBreakTimer();
|
|
2411
|
+
setHolding(false);
|
|
1447
2412
|
const engine = engineRef.current;
|
|
1448
2413
|
engineRef.current = null;
|
|
1449
|
-
if (engine)
|
|
1450
|
-
bus.
|
|
2414
|
+
if (engine) await engine.stop();
|
|
2415
|
+
bus.resetTelemetry();
|
|
2416
|
+
bus.setUi({ state: "idle", partial: "", levels: [], error: null, model: null, ttsNotice: null, isSpeech: void 0 });
|
|
1451
2417
|
const sid = sidRef.current;
|
|
1452
|
-
if (sid)
|
|
2418
|
+
if (sid) await bus.exit(sid);
|
|
1453
2419
|
};
|
|
1454
2420
|
const enterMode = async () => {
|
|
1455
2421
|
const sid = sidRef.current;
|
|
1456
2422
|
if (!sid || localRef.current !== "off") return;
|
|
2423
|
+
isSpeechTrueCount = 0;
|
|
1457
2424
|
setLocalMode("pending");
|
|
1458
2425
|
try {
|
|
1459
2426
|
const entered = await bus.enter(sid);
|
|
2427
|
+
if (!mountedRef.current) {
|
|
2428
|
+
if (entered.ok) void bus.exit(sid);
|
|
2429
|
+
return;
|
|
2430
|
+
}
|
|
1460
2431
|
if (!entered.ok) {
|
|
1461
2432
|
setLocalMode("off");
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
2433
|
+
if (!entered.preempted) {
|
|
2434
|
+
bus.setUi({
|
|
2435
|
+
error: entered.error === "voice mode disabled" ? t("disabled") : entered.error ?? t("enterFail")
|
|
2436
|
+
});
|
|
2437
|
+
}
|
|
1465
2438
|
return;
|
|
1466
2439
|
}
|
|
1467
2440
|
const cfg = await fetchConfig();
|
|
1468
2441
|
const basePath = cfg.basePath;
|
|
1469
2442
|
const silenceMs = cfg.silenceMs;
|
|
1470
2443
|
const interruptLevel = cfg.interruptLevel;
|
|
1471
|
-
const
|
|
1472
|
-
|
|
2444
|
+
const confirmFrames = INT_CONFIRM_FRAMES[interruptLevel] ?? 2;
|
|
2445
|
+
const bargeInMode = cfg.bargeInMode;
|
|
2446
|
+
debugLog("enter", {
|
|
2447
|
+
build: BUILD_TAG,
|
|
2448
|
+
mode: cfg.mode,
|
|
2449
|
+
bargeInMode,
|
|
2450
|
+
echoGateDb: cfg.echoGateDb,
|
|
2451
|
+
interruptLevel,
|
|
2452
|
+
silenceMs,
|
|
2453
|
+
sessionId: sid
|
|
2454
|
+
});
|
|
2455
|
+
const hardBreak = async () => {
|
|
2456
|
+
bus.skipAudio();
|
|
2457
|
+
bus.unduckAudio();
|
|
2458
|
+
if (runningRef.current && sidRef.current) {
|
|
2459
|
+
bus.cancelTurn(sidRef.current);
|
|
2460
|
+
}
|
|
2461
|
+
const cancelP = fetch(`${location.origin}${BASE_PATH2}/cancel`, {
|
|
2462
|
+
method: "POST",
|
|
2463
|
+
headers: { "content-type": "application/json" },
|
|
2464
|
+
// hold 按压中保留 host ASR 段(松手定稿续传前半句,防吃句)。
|
|
2465
|
+
body: JSON.stringify({ sessionId: sidRef.current, keepAsr: engineRef.current?.holding === true }),
|
|
2466
|
+
signal: AbortSignal.timeout(3e3)
|
|
2467
|
+
}).catch(() => {
|
|
2468
|
+
});
|
|
2469
|
+
if (engineRef.current && !engineRef.current.holding) await engineRef.current.discardSegment();
|
|
2470
|
+
await cancelP;
|
|
2471
|
+
bus.setUi({ partial: "\u2026" });
|
|
2472
|
+
};
|
|
2473
|
+
breakRef.current = hardBreak;
|
|
2474
|
+
const engine = createAsrEngine(
|
|
2475
|
+
{
|
|
2476
|
+
silenceMs,
|
|
2477
|
+
basePath,
|
|
2478
|
+
mode: cfg.mode,
|
|
2479
|
+
echoGateDb: cfg.echoGateDb,
|
|
2480
|
+
echo: bus.echoForAsr(),
|
|
2481
|
+
// 回声尾音宽限:playing 或尾音窗口内均视为朗读中,防句播完瞬间的残响漏入 ASR。
|
|
2482
|
+
isPlaying: () => bus.ui.playing || Date.now() < bus.playingTailUntil(),
|
|
2483
|
+
// 打断根治阶段二:服务端 Silero VAD 帧级检测下行 → 驱动打断(替代 RMS 能量快
|
|
2484
|
+
// 路径)。连续 confirmFrames 次 true(墙钟节拍 100ms/拍,三档确认约 0.3/0.2/0.1s)
|
|
2485
|
+
// 判真实人声前沿;仅 AI 朗读中(bus.ui.playing)触发 hardBreak,
|
|
2486
|
+
// 防 TTS 回声被 VAD 误判为语音而自打断。
|
|
2487
|
+
onIsSpeech: (speech) => {
|
|
2488
|
+
if (bargeInMode === "manual") return;
|
|
2489
|
+
if (!bus.ui.playing) {
|
|
2490
|
+
isSpeechTrueCount = 0;
|
|
2491
|
+
interruptFirstAt = 0;
|
|
2492
|
+
bus.setUi({ isSpeech: speech, echoDelayMs: bus.echoDelayMs(), echoLevels: engineRef.current?.echoLevels() });
|
|
2493
|
+
return;
|
|
2494
|
+
}
|
|
2495
|
+
if (speech === true && isSpeechTrueCount === 0) {
|
|
2496
|
+
const lv = engineRef.current?.echoLevels();
|
|
2497
|
+
debugLog("vad-speech-start", {
|
|
2498
|
+
playing: bus.ui.playing,
|
|
2499
|
+
delayMs: Math.round(bus.echoDelayMs()),
|
|
2500
|
+
floor: lv?.floorRms,
|
|
2501
|
+
resid: lv?.residualRms,
|
|
2502
|
+
peak: lv?.peakRms
|
|
2503
|
+
});
|
|
2504
|
+
}
|
|
2505
|
+
if (speech === true) {
|
|
2506
|
+
isSpeechFalseRun = 0;
|
|
2507
|
+
isSpeechTrueCount++;
|
|
2508
|
+
if (isSpeechTrueCount === 1) interruptFirstAt = Date.now();
|
|
2509
|
+
if (isSpeechTrueCount >= confirmFrames) {
|
|
2510
|
+
if (engineRef.current && !engineRef.current.aboveEchoFloor(cfg.echoGateDb ?? 6)) {
|
|
2511
|
+
const lv2 = engineRef.current?.echoLevels();
|
|
2512
|
+
debugLog("echo-gate-reject", {
|
|
2513
|
+
gateDb: cfg.echoGateDb ?? 6,
|
|
2514
|
+
floor: lv2?.floorRms,
|
|
2515
|
+
resid: lv2?.residualRms,
|
|
2516
|
+
peak: lv2?.peakRms,
|
|
2517
|
+
confirmFrames
|
|
2518
|
+
});
|
|
2519
|
+
isSpeechTrueCount = 0;
|
|
2520
|
+
interruptFirstAt = 0;
|
|
2521
|
+
return;
|
|
2522
|
+
}
|
|
2523
|
+
const confirmMs = interruptFirstAt > 0 ? Date.now() - interruptFirstAt : 0;
|
|
2524
|
+
const lv = engineRef.current?.echoLevels();
|
|
2525
|
+
debugLog("interrupt-trigger", {
|
|
2526
|
+
confirmMs,
|
|
2527
|
+
floor: lv?.floorRms,
|
|
2528
|
+
resid: lv?.residualRms,
|
|
2529
|
+
peak: lv?.peakRms,
|
|
2530
|
+
delayMs: Math.round(bus.echoDelayMs())
|
|
2531
|
+
});
|
|
2532
|
+
interruptFirstAt = 0;
|
|
2533
|
+
isSpeechTrueCount = 0;
|
|
2534
|
+
resetIdle();
|
|
2535
|
+
bus.resetTelemetry();
|
|
2536
|
+
bus.setUi({ interruptConfirmMs: confirmMs });
|
|
2537
|
+
void hardBreak();
|
|
2538
|
+
}
|
|
2539
|
+
} else {
|
|
2540
|
+
isSpeechFalseRun++;
|
|
2541
|
+
if (isSpeechFalseRun >= 2) {
|
|
2542
|
+
isSpeechFalseRun = 0;
|
|
2543
|
+
isSpeechTrueCount = Math.max(0, isSpeechTrueCount - 1);
|
|
2544
|
+
if (isSpeechTrueCount === 0) interruptFirstAt = 0;
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
bus.setUi({ isSpeech: speech, echoDelayMs: bus.echoDelayMs(), echoLevels: engineRef.current?.echoLevels() });
|
|
2548
|
+
},
|
|
2549
|
+
onSessionExpired: async () => {
|
|
2550
|
+
if (localRef.current !== "on") return false;
|
|
2551
|
+
if (Date.now() - lastReenterAt < 2e3) return false;
|
|
2552
|
+
lastReenterAt = Date.now();
|
|
2553
|
+
bus.setUi({ error: t("sessionExpired") });
|
|
2554
|
+
const reentered = await bus.enter(sid);
|
|
2555
|
+
if (!reentered.ok) {
|
|
2556
|
+
bus.setUi({ error: t("sessionExpiredFail") });
|
|
2557
|
+
} else {
|
|
2558
|
+
bus.setUi({ error: null });
|
|
2559
|
+
}
|
|
2560
|
+
return reentered.ok;
|
|
2561
|
+
},
|
|
2562
|
+
// A1:原生 AEC 生效状态 → 状态条提示(外放且原生 AEC 失效时引导用耳机/手动打断)。
|
|
2563
|
+
onAecState: (on2) => {
|
|
2564
|
+
debugLog("aec-state", { nativeEchoCancellation: on2 });
|
|
2565
|
+
bus.setEchoBypass(on2);
|
|
2566
|
+
bus.setUi({ aecOff: !on2 });
|
|
2567
|
+
}
|
|
2568
|
+
},
|
|
2569
|
+
sid
|
|
2570
|
+
);
|
|
2571
|
+
bus.setUi({ mode: cfg.mode });
|
|
1473
2572
|
engineRef.current = engine;
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
void beepCtx.resume?.();
|
|
1477
|
-
} catch {
|
|
1478
|
-
}
|
|
2573
|
+
engine.onTelemetry((e) => bus.stampTelemetry(e.stage, e.at));
|
|
2574
|
+
bus.warmAudio();
|
|
1479
2575
|
engine.onState((s) => {
|
|
1480
2576
|
bus.setUi({ state: s });
|
|
1481
2577
|
if (s === "idle") resetIdle();
|
|
@@ -1491,6 +2587,7 @@ function MicButton({
|
|
|
1491
2587
|
engine.onPartial((text) => bus.setUi({ partial: text }));
|
|
1492
2588
|
engine.onSegment((text, meta) => {
|
|
1493
2589
|
resetIdle();
|
|
2590
|
+
bus.setUi({ partial: "" });
|
|
1494
2591
|
const actions = actionsRef.current;
|
|
1495
2592
|
const trimmed = text.trim();
|
|
1496
2593
|
if (!trimmed) return;
|
|
@@ -1507,6 +2604,7 @@ function MicButton({
|
|
|
1507
2604
|
} catch {
|
|
1508
2605
|
}
|
|
1509
2606
|
}
|
|
2607
|
+
if (bus.ui.playing && !meta?.force) return;
|
|
1510
2608
|
if (bootNow().autoSend === false && !meta?.force) return;
|
|
1511
2609
|
const doSubmit = () => {
|
|
1512
2610
|
try {
|
|
@@ -1522,29 +2620,35 @@ function MicButton({
|
|
|
1522
2620
|
};
|
|
1523
2621
|
cancelPendingSubmit();
|
|
1524
2622
|
doSubmit();
|
|
1525
|
-
|
|
2623
|
+
let retryCount = 0;
|
|
2624
|
+
submitTimerRef.current = setInterval(() => {
|
|
2625
|
+
retryCount++;
|
|
1526
2626
|
const phase = phaseRef.current;
|
|
1527
|
-
if (phase
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
try {
|
|
1534
|
-
await fetch(`${location.origin}${BASE_PATH2}/cancel`, {
|
|
1535
|
-
method: "POST",
|
|
1536
|
-
headers: { "content-type": "application/json" },
|
|
1537
|
-
body: JSON.stringify({ sessionId: sidRef.current })
|
|
1538
|
-
});
|
|
1539
|
-
} catch {
|
|
1540
|
-
}
|
|
1541
|
-
if (runningRef.current && sidRef.current) {
|
|
1542
|
-
bus.cancelTurn(sidRef.current);
|
|
1543
|
-
}
|
|
1544
|
-
bus.setUi({ partial: "\u2026" });
|
|
2627
|
+
if (retryCount > 3 || phase === "submitting" || phase === "adjudicating" || draftRef.current.trim() !== trimmed) {
|
|
2628
|
+
cancelPendingSubmit();
|
|
2629
|
+
return;
|
|
2630
|
+
}
|
|
2631
|
+
doSubmit();
|
|
2632
|
+
}, 500);
|
|
1545
2633
|
});
|
|
1546
2634
|
bus.setUi({ state: "idle", partial: "", levels: [], error: null, model: null, ttsNotice: null });
|
|
2635
|
+
if (!mountedRef.current) {
|
|
2636
|
+
engineRef.current = null;
|
|
2637
|
+
void bus.exit(sid);
|
|
2638
|
+
return;
|
|
2639
|
+
}
|
|
1547
2640
|
await engine.start();
|
|
2641
|
+
if (!mountedRef.current) {
|
|
2642
|
+
engineRef.current = null;
|
|
2643
|
+
await engine.stop();
|
|
2644
|
+
void bus.exit(sid);
|
|
2645
|
+
return;
|
|
2646
|
+
}
|
|
2647
|
+
if (bus.activeSessionId !== sid) {
|
|
2648
|
+
engineRef.current = null;
|
|
2649
|
+
await engine.stop();
|
|
2650
|
+
return;
|
|
2651
|
+
}
|
|
1548
2652
|
setLocalMode("on");
|
|
1549
2653
|
resetIdle();
|
|
1550
2654
|
} catch (e) {
|
|
@@ -1569,21 +2673,40 @@ function MicButton({
|
|
|
1569
2673
|
(0, import_react2.useEffect)(() => {
|
|
1570
2674
|
sidRef.current = sessionId;
|
|
1571
2675
|
}, [sessionId]);
|
|
2676
|
+
const autoResumeTriedForRef = (0, import_react2.useRef)(null);
|
|
2677
|
+
(0, import_react2.useEffect)(() => {
|
|
2678
|
+
const sid = sessionId;
|
|
2679
|
+
if (!sid || sid === autoResumeTriedForRef.current) return;
|
|
2680
|
+
autoResumeTriedForRef.current = sid;
|
|
2681
|
+
void (async () => {
|
|
2682
|
+
const cfg = await fetchConfig();
|
|
2683
|
+
if (!cfg.autoResume) return;
|
|
2684
|
+
if (getLastVoiceSession() !== sid) return;
|
|
2685
|
+
if (bus.activeSessionId !== null) return;
|
|
2686
|
+
if (localRef.current !== "off") return;
|
|
2687
|
+
await enterMode().catch(() => {
|
|
2688
|
+
setLocalMode("off");
|
|
2689
|
+
});
|
|
2690
|
+
})();
|
|
2691
|
+
}, [sessionId]);
|
|
1572
2692
|
const runningSel = useSession ? useSession((s) => s === void 0 ? void 0 : s.running) : void 0;
|
|
1573
2693
|
(0, import_react2.useEffect)(() => {
|
|
1574
2694
|
runningRef.current = runningSel === true;
|
|
1575
2695
|
}, [runningSel]);
|
|
1576
2696
|
(0, import_react2.useEffect)(() => {
|
|
2697
|
+
mountedRef.current = true;
|
|
1577
2698
|
return () => {
|
|
2699
|
+
mountedRef.current = false;
|
|
1578
2700
|
clearIdle();
|
|
1579
2701
|
cancelPendingSubmit();
|
|
2702
|
+
isSpeechTrueCount = 0;
|
|
1580
2703
|
const sid = sidRef.current;
|
|
1581
|
-
if (localRef.current === "on" && sid) {
|
|
2704
|
+
if ((localRef.current === "on" || localRef.current === "pending") && sid) {
|
|
1582
2705
|
void engineRef.current?.stop();
|
|
1583
2706
|
void fetch(`${location.origin}${BASE_PATH2}/toggle`, {
|
|
1584
2707
|
method: "POST",
|
|
1585
2708
|
headers: { "content-type": "application/json" },
|
|
1586
|
-
body: JSON.stringify({ sessionId: sid, on: false }),
|
|
2709
|
+
body: JSON.stringify({ sessionId: sid, on: false, tabId: TAB_ID }),
|
|
1587
2710
|
keepalive: true
|
|
1588
2711
|
}).catch(() => {
|
|
1589
2712
|
});
|
|
@@ -1592,6 +2715,8 @@ function MicButton({
|
|
|
1592
2715
|
}, []);
|
|
1593
2716
|
(0, import_react2.useEffect)(() => {
|
|
1594
2717
|
let ctrlTimer = null;
|
|
2718
|
+
let ctrlHoldStart = 0;
|
|
2719
|
+
let otherKeyDuringCtrl = false;
|
|
1595
2720
|
const cancelCtrl = () => {
|
|
1596
2721
|
if (ctrlTimer) {
|
|
1597
2722
|
clearTimeout(ctrlTimer);
|
|
@@ -1599,33 +2724,68 @@ function MicButton({
|
|
|
1599
2724
|
}
|
|
1600
2725
|
if (holdCtrlRef.current) {
|
|
1601
2726
|
holdCtrlRef.current = false;
|
|
2727
|
+
setHolding(false);
|
|
2728
|
+
engineRef.current?.endHeld(false);
|
|
2729
|
+
}
|
|
2730
|
+
if (manualHoldRef.current) {
|
|
2731
|
+
manualHoldRef.current = false;
|
|
2732
|
+
setHolding(false);
|
|
1602
2733
|
engineRef.current?.endHeld(false);
|
|
1603
2734
|
}
|
|
1604
2735
|
};
|
|
1605
2736
|
const onKeyDown = (e) => {
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
2737
|
+
const combo = parseShortcut(bootNow().shortcut);
|
|
2738
|
+
const codeKey = e.code.replace("Key", "").replace("Digit", "").toLowerCase();
|
|
2739
|
+
if (combo && !e.repeat && (e.key.toLowerCase() === combo.key || codeKey === combo.key) && e.ctrlKey === combo.ctrl && e.shiftKey === combo.shift && e.altKey === combo.alt && e.metaKey === combo.meta) {
|
|
2740
|
+
const el = e.target;
|
|
2741
|
+
const editable = el instanceof HTMLElement && (el.tagName === "TEXTAREA" || el.tagName === "INPUT" || el.isContentEditable);
|
|
2742
|
+
if (!editable && !e.isComposing) {
|
|
2743
|
+
e.preventDefault();
|
|
2744
|
+
cancelCtrl();
|
|
2745
|
+
toggleRef.current();
|
|
2746
|
+
}
|
|
1610
2747
|
return;
|
|
1611
2748
|
}
|
|
1612
2749
|
const eng = engineRef.current;
|
|
1613
|
-
if (e.key
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
2750
|
+
if (e.key === "Control" && !e.shiftKey && !e.altKey && !e.metaKey && !e.repeat && eng) {
|
|
2751
|
+
ctrlHoldStart = Date.now();
|
|
2752
|
+
otherKeyDuringCtrl = false;
|
|
2753
|
+
if (bootNow().mode === "hold") {
|
|
2754
|
+
ctrlTimer = setTimeout(() => {
|
|
2755
|
+
ctrlTimer = null;
|
|
2756
|
+
holdCtrlRef.current = true;
|
|
2757
|
+
setHolding(true);
|
|
2758
|
+
eng.beginHeld();
|
|
2759
|
+
}, 600);
|
|
2760
|
+
} else if (bootNow().bargeInMode === "manual" && bus.ui.playing) {
|
|
2761
|
+
manualHoldRef.current = true;
|
|
2762
|
+
setHolding(true);
|
|
1618
2763
|
eng.beginHeld();
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
2764
|
+
void breakRef.current?.();
|
|
2765
|
+
}
|
|
2766
|
+
return;
|
|
2767
|
+
}
|
|
2768
|
+
if (ctrlHoldStart > 0 && e.key !== "Control") {
|
|
2769
|
+
otherKeyDuringCtrl = true;
|
|
2770
|
+
if (ctrlTimer) {
|
|
2771
|
+
clearTimeout(ctrlTimer);
|
|
2772
|
+
ctrlTimer = null;
|
|
2773
|
+
}
|
|
1622
2774
|
}
|
|
1623
2775
|
};
|
|
1624
2776
|
const onKeyUp = (e) => {
|
|
1625
|
-
if (e.key
|
|
2777
|
+
if (e.key !== "Control") return;
|
|
2778
|
+
if (bootNow().mode !== "hold" && !manualHoldRef.current && !otherKeyDuringCtrl && ctrlHoldStart > 0 && Date.now() - ctrlHoldStart >= 250) {
|
|
2779
|
+
engineRef.current?.forceSend();
|
|
2780
|
+
}
|
|
2781
|
+
cancelCtrl();
|
|
2782
|
+
ctrlHoldStart = 0;
|
|
2783
|
+
otherKeyDuringCtrl = false;
|
|
1626
2784
|
};
|
|
1627
2785
|
const onBlur = () => {
|
|
1628
2786
|
cancelCtrl();
|
|
2787
|
+
ctrlHoldStart = 0;
|
|
2788
|
+
otherKeyDuringCtrl = false;
|
|
1629
2789
|
if (localRef.current === "on" && bootNow().mode === "hold") engineRef.current?.endHeld(true);
|
|
1630
2790
|
};
|
|
1631
2791
|
window.addEventListener("keydown", onKeyDown);
|
|
@@ -1654,12 +2814,25 @@ function MicButton({
|
|
|
1654
2814
|
if (localRef.current !== "on" || bootNow().mode !== "hold") return;
|
|
1655
2815
|
engineRef.current?.endHeld(true);
|
|
1656
2816
|
holdCtrlRef.current = false;
|
|
2817
|
+
setHolding(false);
|
|
1657
2818
|
bus.setUi({ partial: "" });
|
|
1658
2819
|
};
|
|
1659
2820
|
const onVisibility = () => {
|
|
1660
|
-
if (document.hidden
|
|
1661
|
-
engineRef.current
|
|
1662
|
-
|
|
2821
|
+
if (document.hidden) {
|
|
2822
|
+
if (localRef.current === "on" && engineRef.current) {
|
|
2823
|
+
if (bootNow().mode === "hold") {
|
|
2824
|
+
engineRef.current?.endHeld(true);
|
|
2825
|
+
holdCtrlRef.current = false;
|
|
2826
|
+
setHolding(false);
|
|
2827
|
+
}
|
|
2828
|
+
pausedForHiddenRef.current = true;
|
|
2829
|
+
void engineRef.current.stop();
|
|
2830
|
+
}
|
|
2831
|
+
} else if (pausedForHiddenRef.current && localRef.current === "on") {
|
|
2832
|
+
pausedForHiddenRef.current = false;
|
|
2833
|
+
void engineRef.current?.start().catch(() => {
|
|
2834
|
+
setLocalMode("off");
|
|
2835
|
+
});
|
|
1663
2836
|
}
|
|
1664
2837
|
};
|
|
1665
2838
|
window.addEventListener("keydown", onKeyDown);
|
|
@@ -1690,28 +2863,76 @@ function MicButton({
|
|
|
1690
2863
|
const livePhase = useInput ? useInput((s) => s?.phase ?? "") : "";
|
|
1691
2864
|
const phaseRef = (0, import_react2.useRef)("");
|
|
1692
2865
|
phaseRef.current = livePhase;
|
|
1693
|
-
const
|
|
2866
|
+
const [holding, setHolding] = (0, import_react2.useState)(false);
|
|
2867
|
+
const label = on ? busy ? t("recognizing") : holdMode ? holding ? t("releaseToSend") : t("holdToTalk") : t("voiceDetected") : local === "pending" ? t("entering") : t("voiceBtn");
|
|
1694
2868
|
const holdPtrRef = (0, import_react2.useRef)(null);
|
|
2869
|
+
const toggleHoldRef = (0, import_react2.useRef)(false);
|
|
2870
|
+
const suppressClickUntilRef = (0, import_react2.useRef)(0);
|
|
2871
|
+
const breakTimerRef = (0, import_react2.useRef)(null);
|
|
2872
|
+
const clearBreakTimer = () => {
|
|
2873
|
+
if (breakTimerRef.current !== null) {
|
|
2874
|
+
clearTimeout(breakTimerRef.current);
|
|
2875
|
+
breakTimerRef.current = null;
|
|
2876
|
+
}
|
|
2877
|
+
};
|
|
1695
2878
|
const onPointerDown = (e) => {
|
|
1696
|
-
if (bootNow().mode !== "hold") return;
|
|
1697
2879
|
holdPtrRef.current = { t: Date.now(), y: e.clientY, id: e.pointerId };
|
|
1698
2880
|
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
1699
|
-
if (
|
|
2881
|
+
if (bootNow().mode === "hold") {
|
|
2882
|
+
if (localRef.current === "on") {
|
|
2883
|
+
setHolding(true);
|
|
2884
|
+
const eng = engineRef.current;
|
|
2885
|
+
eng?.beginHeld();
|
|
2886
|
+
if (eng && bootNow().bargeInMode === "manual" && bus.ui.playing) {
|
|
2887
|
+
breakTimerRef.current = setTimeout(() => {
|
|
2888
|
+
breakTimerRef.current = null;
|
|
2889
|
+
if (holdPtrRef.current && bus.ui.playing) void breakRef.current?.();
|
|
2890
|
+
}, 250);
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
} else if (localRef.current === "on" && bus.ui.playing) {
|
|
2894
|
+
toggleHoldRef.current = true;
|
|
2895
|
+
setHolding(true);
|
|
2896
|
+
const eng = engineRef.current;
|
|
2897
|
+
eng?.beginHeld();
|
|
2898
|
+
breakTimerRef.current = setTimeout(() => {
|
|
2899
|
+
breakTimerRef.current = null;
|
|
2900
|
+
if (holdPtrRef.current && bus.ui.playing) void breakRef.current?.();
|
|
2901
|
+
}, 250);
|
|
2902
|
+
}
|
|
1700
2903
|
};
|
|
1701
2904
|
const onPointerMove = (e) => {
|
|
1702
2905
|
const p = holdPtrRef.current;
|
|
1703
2906
|
if (!p || p.id !== e.pointerId) return;
|
|
1704
2907
|
if (p.y - e.clientY >= 40) {
|
|
1705
2908
|
holdPtrRef.current = null;
|
|
2909
|
+
toggleHoldRef.current = false;
|
|
2910
|
+
clearBreakTimer();
|
|
2911
|
+
setHolding(false);
|
|
1706
2912
|
engineRef.current?.endHeld(true);
|
|
1707
2913
|
bus.setUi({ partial: "" });
|
|
2914
|
+
suppressClickUntilRef.current = Date.now() + 500;
|
|
1708
2915
|
}
|
|
1709
2916
|
};
|
|
1710
2917
|
const onPointerUp = (e) => {
|
|
1711
2918
|
const p = holdPtrRef.current;
|
|
1712
2919
|
holdPtrRef.current = null;
|
|
2920
|
+
clearBreakTimer();
|
|
2921
|
+
setHolding(false);
|
|
1713
2922
|
if (!p || p.id !== e.pointerId) return;
|
|
1714
2923
|
const ms = Date.now() - p.t;
|
|
2924
|
+
if (bootNow().mode !== "hold") {
|
|
2925
|
+
if (toggleHoldRef.current) {
|
|
2926
|
+
toggleHoldRef.current = false;
|
|
2927
|
+
if (ms >= 250) {
|
|
2928
|
+
suppressClickUntilRef.current = Date.now() + 500;
|
|
2929
|
+
engineRef.current?.endHeld(false);
|
|
2930
|
+
} else {
|
|
2931
|
+
engineRef.current?.endHeld(true);
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2934
|
+
return;
|
|
2935
|
+
}
|
|
1715
2936
|
if (ms < 250) {
|
|
1716
2937
|
if (localRef.current === "on") {
|
|
1717
2938
|
engineRef.current?.endHeld(true);
|
|
@@ -1725,12 +2946,16 @@ function MicButton({
|
|
|
1725
2946
|
};
|
|
1726
2947
|
const onPointerCancel = () => {
|
|
1727
2948
|
holdPtrRef.current = null;
|
|
2949
|
+
toggleHoldRef.current = false;
|
|
2950
|
+
clearBreakTimer();
|
|
2951
|
+
setHolding(false);
|
|
1728
2952
|
engineRef.current?.endHeld(true);
|
|
1729
2953
|
};
|
|
1730
2954
|
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
|
|
1731
2955
|
"button",
|
|
1732
2956
|
{
|
|
1733
2957
|
onClick: (e) => {
|
|
2958
|
+
if (Date.now() < suppressClickUntilRef.current) return;
|
|
1734
2959
|
if (holdMode) {
|
|
1735
2960
|
if (e.detail !== 0) return;
|
|
1736
2961
|
}
|
|
@@ -1740,29 +2965,34 @@ function MicButton({
|
|
|
1740
2965
|
onPointerMove,
|
|
1741
2966
|
onPointerUp,
|
|
1742
2967
|
onPointerCancel,
|
|
2968
|
+
onContextMenu: (e) => e.preventDefault(),
|
|
1743
2969
|
"data-dshvm": "mic",
|
|
1744
2970
|
"aria-label": on ? t("ariaActive") : t("ariaEnter"),
|
|
1745
2971
|
"aria-pressed": on,
|
|
1746
2972
|
title: on ? holdMode ? t("titleHold") : t("titleToggle") : t("titleEnter"),
|
|
1747
2973
|
style: {
|
|
1748
|
-
border: "
|
|
1749
|
-
background: on ? holdMode ? "rgba(88, 166, 255, 0.16)" : "rgba(63, 185, 80, 0.16)" : local === "pending" ? "rgba(88, 166, 255, 0.14)" : "
|
|
2974
|
+
border: holding ? "1px solid rgba(248, 81, 73, 0.6)" : on ? holdMode ? "1px solid rgba(88, 166, 255, 0.45)" : "1px solid rgba(63, 185, 80, 0.45)" : "1px solid rgba(139, 148, 158, 0.35)",
|
|
2975
|
+
background: holding ? "rgba(248, 81, 73, 0.2)" : on ? holdMode ? "rgba(88, 166, 255, 0.16)" : "rgba(63, 185, 80, 0.16)" : local === "pending" ? "rgba(88, 166, 255, 0.14)" : "rgba(139, 148, 158, 0.08)",
|
|
1750
2976
|
cursor: "pointer",
|
|
1751
|
-
padding: "
|
|
2977
|
+
padding: "5px 10px",
|
|
1752
2978
|
borderRadius: 8,
|
|
1753
2979
|
display: "flex",
|
|
1754
2980
|
alignItems: "center",
|
|
1755
2981
|
gap: 6,
|
|
1756
|
-
fontSize:
|
|
2982
|
+
fontSize: 12,
|
|
1757
2983
|
fontFamily: "system-ui, sans-serif",
|
|
1758
|
-
color: on ? holdMode ? "#58a6ff" : "#3fb950" : local === "pending" ? "#58a6ff" : "#8b949e",
|
|
1759
|
-
transition: "background 0.15s ease, color 0.2s ease",
|
|
2984
|
+
color: holding ? "#f85149" : on ? holdMode ? "#58a6ff" : "#3fb950" : local === "pending" ? "#58a6ff" : "#8b949e",
|
|
2985
|
+
transition: "background 0.15s ease, color 0.2s ease, border-color 0.15s ease",
|
|
1760
2986
|
touchAction: "none",
|
|
1761
2987
|
// 触摸设备上让 pointer 事件独占(滑出取消可用)
|
|
1762
|
-
userSelect: "none"
|
|
2988
|
+
userSelect: "none",
|
|
2989
|
+
WebkitUserSelect: "none",
|
|
2990
|
+
// iOS Safari 前缀,防长按选中文字
|
|
2991
|
+
WebkitTouchCallout: "none"
|
|
2992
|
+
// iOS 长按弹出「拷贝/选择」菜单
|
|
1763
2993
|
},
|
|
1764
2994
|
children: [
|
|
1765
|
-
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("svg", { viewBox: "0 0 24 24", width:
|
|
2995
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("svg", { viewBox: "0 0 24 24", width: 16, height: 16, "aria-hidden": "true", children: [
|
|
1766
2996
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
1767
2997
|
"path",
|
|
1768
2998
|
{
|
|
@@ -1790,15 +3020,38 @@ function VoiceStatusBar({ bus, sessionId }) {
|
|
|
1790
3020
|
}, [bus]);
|
|
1791
3021
|
const isActive = b.active === sessionId;
|
|
1792
3022
|
if (!isActive) return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_jsx_runtime2.Fragment, {});
|
|
1793
|
-
const stateText = b.ui.state === "loading-model" ? t("loadingModel") : b.ui.state === "transcribing" ? t("recognizing") : b.ui.state === "speech" ? b.ui.mode === "hold" ? t("holdDots") : t("listening") : b.ui.
|
|
3023
|
+
const stateText = b.ui.state === "loading-model" ? t("loadingModel") : b.ui.state === "transcribing" ? t("recognizing") : b.ui.state === "speech" ? b.ui.mode === "hold" ? t("holdDots") : t("listening") : b.ui.playing ? t("reading") : b.ui.turn === "agent-speaking" ? t("thinking") : b.ui.mode === "hold" ? t("barHold") : t("barListening");
|
|
1794
3024
|
const bars = Array.from({ length: WAVE_BARS }, (_, i) => b.ui.levels[i] ?? 0);
|
|
3025
|
+
const telParts = [];
|
|
3026
|
+
const fmt = (ms) => ms >= 1e3 ? `${(ms / 1e3).toFixed(2)}s` : `${Math.round(ms)}ms`;
|
|
3027
|
+
const tel = b.ui.telemetry;
|
|
3028
|
+
if (tel) {
|
|
3029
|
+
for (let i = 1; i < TELEMETRY_VIEW.length; i++) {
|
|
3030
|
+
const cur = tel[TELEMETRY_VIEW[i].stage];
|
|
3031
|
+
const prev = tel[TELEMETRY_VIEW[i - 1].stage];
|
|
3032
|
+
if (cur === void 0 || prev === void 0) continue;
|
|
3033
|
+
telParts.push(`${t(TELEMETRY_VIEW[i].key)} ${fmt(cur - prev)}`);
|
|
3034
|
+
}
|
|
3035
|
+
const begin = tel["utterance-end"];
|
|
3036
|
+
const end = tel["first-audio-played"];
|
|
3037
|
+
if (begin !== void 0 && end !== void 0) telParts.push(`${t("telTotal")} ${fmt(end - begin)}`);
|
|
3038
|
+
}
|
|
3039
|
+
if (b.ui.interruptConfirmMs !== void 0) {
|
|
3040
|
+
telParts.push(`${t("interruptConfirm")} ${fmt(b.ui.interruptConfirmMs)}`);
|
|
3041
|
+
}
|
|
3042
|
+
if (b.ui.echoLevels) {
|
|
3043
|
+
const el = b.ui.echoLevels;
|
|
3044
|
+
telParts.push(
|
|
3045
|
+
`AEC delay=${Math.round(b.ui.echoDelayMs ?? 0)}ms floor=${el.floorRms.toFixed(4)} resid=${el.residualRms.toFixed(4)}`
|
|
3046
|
+
);
|
|
3047
|
+
}
|
|
1795
3048
|
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
|
|
1796
3049
|
"div",
|
|
1797
3050
|
{
|
|
1798
3051
|
style: {
|
|
1799
3052
|
display: "flex",
|
|
1800
|
-
|
|
1801
|
-
gap:
|
|
3053
|
+
flexDirection: "column",
|
|
3054
|
+
gap: 2,
|
|
1802
3055
|
padding: "6px 12px",
|
|
1803
3056
|
borderRadius: 10,
|
|
1804
3057
|
fontSize: 12,
|
|
@@ -1809,34 +3062,84 @@ function VoiceStatusBar({ bus, sessionId }) {
|
|
|
1809
3062
|
animation: "dshvm-fadein 0.2s ease"
|
|
1810
3063
|
},
|
|
1811
3064
|
children: [
|
|
1812
|
-
/* @__PURE__ */ (0, import_jsx_runtime2.
|
|
1813
|
-
"span",
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
3065
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: { display: "flex", alignItems: "center", gap: 8 }, children: [
|
|
3066
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: { display: "inline-flex", alignItems: "flex-end", gap: 2, height: 14, flexShrink: 0 }, children: bars.map((v, i) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
3067
|
+
"span",
|
|
3068
|
+
{
|
|
3069
|
+
className: "dshvm-bar",
|
|
3070
|
+
style: {
|
|
3071
|
+
height: `${3 + v * 12}px`,
|
|
3072
|
+
background: "#3fb950",
|
|
3073
|
+
opacity: 0.4 + v * 0.6
|
|
3074
|
+
}
|
|
3075
|
+
},
|
|
3076
|
+
i
|
|
3077
|
+
)) }),
|
|
3078
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flexGrow: 1 }, children: b.ui.error ? b.ui.error : b.ui.state === "loading-model" || b.ui.model ? b.ui.model ? `${t("loadingModel")} ${b.ui.model.file} ${b.ui.model.percent}%` : stateText : b.ui.playing || b.ui.turn === "agent-speaking" ? stateText : b.ui.partial ? b.ui.partial : b.ui.ttsNotice ? b.ui.ttsNotice : stateText }),
|
|
3079
|
+
b.ui.isSpeech === true && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
3080
|
+
"span",
|
|
3081
|
+
{
|
|
3082
|
+
title: t("vadDetected"),
|
|
3083
|
+
style: {
|
|
3084
|
+
flexShrink: 0,
|
|
3085
|
+
padding: "0 6px",
|
|
3086
|
+
borderRadius: 8,
|
|
3087
|
+
fontSize: 10,
|
|
3088
|
+
lineHeight: "16px",
|
|
3089
|
+
color: "#ffa657",
|
|
3090
|
+
background: "rgba(255, 166, 87, 0.15)",
|
|
3091
|
+
border: "1px solid rgba(255, 166, 87, 0.35)"
|
|
3092
|
+
},
|
|
3093
|
+
children: t("vadDetected")
|
|
1820
3094
|
}
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
3095
|
+
),
|
|
3096
|
+
b.ui.aecOff === true && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
3097
|
+
"span",
|
|
3098
|
+
{
|
|
3099
|
+
title: t("aecOffHint"),
|
|
3100
|
+
style: {
|
|
3101
|
+
flexShrink: 0,
|
|
3102
|
+
padding: "0 6px",
|
|
3103
|
+
borderRadius: 8,
|
|
3104
|
+
fontSize: 10,
|
|
3105
|
+
lineHeight: "16px",
|
|
3106
|
+
color: "#ffa657",
|
|
3107
|
+
background: "rgba(255, 166, 87, 0.15)",
|
|
3108
|
+
border: "1px solid rgba(255, 166, 87, 0.35)"
|
|
3109
|
+
},
|
|
3110
|
+
children: t("aecOff")
|
|
3111
|
+
}
|
|
3112
|
+
),
|
|
3113
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
3114
|
+
"button",
|
|
3115
|
+
{
|
|
3116
|
+
onClick: () => {
|
|
3117
|
+
void bus.exit(sessionId);
|
|
3118
|
+
},
|
|
3119
|
+
style: {
|
|
3120
|
+
border: "none",
|
|
3121
|
+
background: "transparent",
|
|
3122
|
+
color: "#8b949e",
|
|
3123
|
+
cursor: "pointer",
|
|
3124
|
+
fontSize: 12,
|
|
3125
|
+
flexShrink: 0
|
|
3126
|
+
},
|
|
3127
|
+
children: t("exit")
|
|
3128
|
+
}
|
|
3129
|
+
)
|
|
3130
|
+
] }),
|
|
3131
|
+
telParts.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
3132
|
+
"div",
|
|
1827
3133
|
{
|
|
1828
|
-
onClick: () => {
|
|
1829
|
-
void bus.exit(sessionId);
|
|
1830
|
-
},
|
|
1831
3134
|
style: {
|
|
1832
|
-
|
|
1833
|
-
background: "transparent",
|
|
3135
|
+
fontSize: 11,
|
|
1834
3136
|
color: "#8b949e",
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
3137
|
+
fontVariantNumeric: "tabular-nums",
|
|
3138
|
+
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
|
|
3139
|
+
whiteSpace: "nowrap",
|
|
3140
|
+
overflowX: "auto"
|
|
1838
3141
|
},
|
|
1839
|
-
children:
|
|
3142
|
+
children: telParts.join(" \xB7 ")
|
|
1840
3143
|
}
|
|
1841
3144
|
)
|
|
1842
3145
|
]
|
|
@@ -1857,7 +3160,8 @@ function VoiceOverlay({ bus }) {
|
|
|
1857
3160
|
style: {
|
|
1858
3161
|
position: "fixed",
|
|
1859
3162
|
right: 16,
|
|
1860
|
-
bottom:
|
|
3163
|
+
bottom: 96,
|
|
3164
|
+
// 上移,避免盖住底部输入框/麦克风按钮
|
|
1861
3165
|
zIndex: 9999,
|
|
1862
3166
|
display: "flex",
|
|
1863
3167
|
alignItems: "center",
|
|
@@ -1866,7 +3170,8 @@ function VoiceOverlay({ bus }) {
|
|
|
1866
3170
|
borderRadius: 999,
|
|
1867
3171
|
fontSize: 12,
|
|
1868
3172
|
fontFamily: "system-ui, sans-serif",
|
|
1869
|
-
pointerEvents: "
|
|
3173
|
+
pointerEvents: "none",
|
|
3174
|
+
// 浮层不挡输入框/麦克风按钮的点击(仅内部「跳过」按钮可点)
|
|
1870
3175
|
background: "rgba(22, 24, 28, 0.85)",
|
|
1871
3176
|
backdropFilter: "blur(14px)",
|
|
1872
3177
|
WebkitBackdropFilter: "blur(14px)",
|
|
@@ -1904,7 +3209,9 @@ function VoiceOverlay({ bus }) {
|
|
|
1904
3209
|
padding: "3px 12px",
|
|
1905
3210
|
fontSize: 11,
|
|
1906
3211
|
cursor: "pointer",
|
|
1907
|
-
flexShrink: 0
|
|
3212
|
+
flexShrink: 0,
|
|
3213
|
+
pointerEvents: "auto"
|
|
3214
|
+
// 仅此按钮可点
|
|
1908
3215
|
},
|
|
1909
3216
|
children: t("skip")
|
|
1910
3217
|
}
|