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/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/wakeword.ts
46
- function normalizeWake(text) {
47
- return String(text ?? "").replace(/[\s\u3000]+/g, "").toLowerCase().replace(/[,。!?!?;;、,.]/g, "");
48
- }
49
- function matchWakeWord(partial, wakeWord) {
50
- const w = normalizeWake(wakeWord);
51
- if (!w) return false;
52
- const p = normalizeWake(partial);
53
- if (!p) return false;
54
- if (p.startsWith(w)) return true;
55
- return false;
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 = 900;
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 wakeWord = (config.wakeWord ?? "").trim().toLowerCase().replace(/[\s\u3000]+/g, "");
102
- const intLevel = INTERRUPT_LEVELS[config.interruptLevel] ?? INTERRUPT_LEVELS[0];
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
- const asrUrl = (final) => `${location.origin}${config.basePath.replace(/\/+$/, "")}/asr?sessionId=${encodeURIComponent(sessionId)}&final=${final ? 1 : 0}`;
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 concatSegment = () => {
130
- const n = segment.reduce((acc, c) => acc + c.length, 0);
131
- const out = new Float32Array(n);
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
- for (const c of segment) {
134
- out.set(c, off);
135
- off += c.length;
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 seconds = segment.reduce((n, c) => n + c.length, 0) / SAMPLE_RATE;
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 samples = concatSegment();
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
- if (state === "wake" && wakeWord) {
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
- const samples = concatSegment();
203
- const epoch = ++segmentEpoch;
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
- try {
214
- let res = await fetch(asrUrl(true), {
215
- method: "POST",
216
- headers: { "content-type": "application/octet-stream" },
217
- body: samples.buffer
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: samples.buffer
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 (epoch !== segmentEpoch) return;
238
- setState(active ? speechActive ? "speech" : "listening" : "idle");
239
- if (!res.ok) return;
240
- const out = await res.json();
241
- if (epoch !== segmentEpoch) return;
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
- } catch {
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
- const data = ctxRate !== SAMPLE_RATE ? resampleTo16k(raw, ctxRate) : raw;
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
- if (Date.now() < bargeInDampingUntil) {
263
- interruptCandidateMs = 0;
264
- } else if (rms > intLevel.rms) {
265
- interruptCandidateMs += durationMs;
266
- if (interruptCandidateMs >= intLevel.ms) {
267
- interruptCandidateMs = 0;
268
- bargeInDampingUntil = Date.now() + 800;
269
- for (const fn of speechStartListeners) {
270
- try {
271
- fn();
272
- } catch {
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
- interruptCandidateMs = 0;
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 (state === "wake") {
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) finalizeSegment();
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
- sincePartialMs += durationMs;
342
- if ((speechActive || holdActive || state === "wake") && sincePartialMs >= PARTIAL_INTERVAL_MS) {
343
- sincePartialMs = 0;
344
- void requestPartial();
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
- noiseSuppression: true,
366
- autoGainControl: true
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
- interruptCandidateMs = 0;
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
- sincePartialMs = 0;
424
- interruptCandidateMs = 0;
616
+ lastPollAt = 0;
425
617
  holdActive = false;
426
- setState(wakeWord ? "wake" : "listening");
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
- if (!active) {
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
- sincePartialMs = 0;
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
- sincePartialMs = 0;
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
- setState(wakeWord ? "wake" : "listening");
696
+ forcePending = false;
697
+ setState("listening");
475
698
  return;
476
699
  }
477
- forcePending = true;
478
- sincePartialMs = 0;
479
- finalizeSegment();
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
- wakeWord: "\u5524\u9192\u8BCD",
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 2000 = 2 \u79D2\uFF09",
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
- descWakeWord: "\u5524\u9192\u8BCD\uFF08\u9ED8\u8BA4\u5173\uFF1B\u5982\u300C\u4F60\u597D\u5C0FD\u300D\uFF0C\u8BF4\u51FA\u540E\u5F00\u59CB\u8BC6\u522B\uFF09",
581
- wakePlaceholder: "\u5982\uFF1A\u4F60\u597D\u5C0FD",
582
- settingsCardDesc: "\u97F3\u8272 / \u8BED\u901F / \u6253\u65AD\u7075\u654F\u5EA6 / \u9759\u97F3\u505C\u987F / \u7A7A\u95F2\u8D85\u65F6 / \u6A21\u578B\u955C\u50CF / \u81EA\u52A8\u53D1\u9001 / \u4EA4\u4E92\u6A21\u5F0F / \u5524\u9192\u8BCD / \u53E3\u8BED\u5316\u63D0\u793A\u8BCD",
583
- configUnavailable: "\u914D\u7F6E\u6682\u4E0D\u53EF\u7528"
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
- wakeWord: "Wake word",
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 2000 ms)",
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
- descWakeWord: "Wake word (default off; e.g. Hey D)",
642
- wakePlaceholder: "e.g. Hey D",
643
- settingsCardDesc: "Voice / rate / interrupt / silence / idle / model host / auto-send / mode / wake word / spoken format",
644
- configUnavailable: "Configuration unavailable"
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: "silenceMs", desc: t("descSilence"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NumberField, { score: scope, field: "silenceMs", value: value.silenceMs ?? 2e3, min: 500, max: 3e4, step: 100 }) }),
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)(Row, { name: "wakeWord", desc: t("descWakeWord"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TextField, { score: scope, field: "wakeWord", value: value.wakeWord ?? "", placeholder: t("wakePlaceholder") }) })
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 beepCtx = null;
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 queue = [];
1103
- const audio = new Audio();
1104
- const playNext = () => {
1105
- const frame = queue.shift();
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 bin = atob(frame.audio);
1111
- const bytes = new Uint8Array(bin.length);
1112
- for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
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
- playNext();
1757
+ playFallback();
1118
1758
  };
1119
- audio.onerror = () => {
1759
+ fallbackAudio.onerror = () => {
1120
1760
  URL.revokeObjectURL(url);
1121
- playNext();
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 audio.play().catch(() => playNext());
1779
+ void fallbackAudio.play().catch(() => playFallback());
1125
1780
  };
1126
- const toolBeep = () => {
1127
- try {
1128
- if (!beepCtx) {
1129
- beepCtx = new AudioContext();
1130
- void beepCtx.resume?.();
1131
- }
1132
- const osc = beepCtx.createOscillator();
1133
- const gain = beepCtx.createGain();
1134
- osc.frequency.value = 880;
1135
- gain.gain.setValueAtTime(0.08, beepCtx.currentTime);
1136
- gain.gain.exponentialRampToValueAtTime(1e-3, beepCtx.currentTime + 0.1);
1137
- osc.connect(gain);
1138
- gain.connect(beepCtx.destination);
1139
- osc.start();
1140
- osc.stop(beepCtx.currentTime + 0.1);
1141
- } catch {
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
- queue.push(frame);
1147
- if (audio.paused) playNext();
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
- queue.length = 0;
1151
- audio.pause();
1152
- audio.onended = null;
1153
- audio.onerror = null;
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
- toolBeep
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: 2e3,
1877
+ silenceMs: 700,
1164
1878
  interruptLevel: 0,
1165
1879
  idleTimeoutMinutes: 10,
1166
1880
  autoSend: true,
1881
+ autoResume: false,
1167
1882
  mode: "toggle",
1168
- wakeWord: ""
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
- wakeWord: ""
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
- const engine = createAudioEngine((patch) => {
1188
- Object.assign(ui, patch);
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 active = JSON.parse(e.data).active ?? null;
1205
- if (active !== activeSessionId) {
1206
- activeSessionId = active;
1207
- if (active !== null || ui.playing) engine.skip();
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("tool", (e) => {
2078
+ source.addEventListener("turn", (e) => {
1227
2079
  try {
1228
2080
  const ev = JSON.parse(e.data);
1229
- for (const fn of toolListeners) {
1230
- try {
1231
- fn(ev);
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 === activeSessionId) engine.push(frame);
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
- toolListeners.add(() => engine.toolBeep());
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 ?? null;
2225
+ activeSessionId = out.active === sessionId ? sessionId : null;
1302
2226
  notify();
1303
2227
  if (!res.ok) return { ok: false, error: out.error ?? t("enterFail") };
1304
- return { ok: out.active === sessionId, error: out.active === sessionId ? void 0 : t("enterFail") };
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
- const out = await res.json();
1317
- activeSessionId = out.active ?? null;
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
- engine.skip();
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 bootNow = () => bus.ui.boot ?? { basePath: "/voice-mode", silenceMs: 2e3, interruptLevel: 0, idleTimeoutMinutes: 10, autoSend: true, mode: "toggle", wakeWord: "" };
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
- wakeWord: c.wakeWord ?? cur.wakeWord
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, wakeWord: next.wakeWord });
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
- void engineRef.current?.stop();
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
- clearTimeout(submitTimerRef.current);
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) void engine.stop();
1450
- bus.setUi({ state: "idle", partial: "", levels: [], error: null, model: null, ttsNotice: null });
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) void bus.exit(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
- bus.setUi({
1463
- error: entered.error === "voice mode disabled" ? t("disabled") : entered.error ?? t("enterFail")
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 engine = createAsrEngine({ silenceMs, interruptLevel, basePath, wakeWord: cfg.wakeWord }, sid);
1472
- bus.setUi({ mode: cfg.mode, wakeWord: cfg.wakeWord });
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
- try {
1475
- if (!beepCtx) beepCtx = new AudioContext();
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
- submitTimerRef.current = setTimeout(() => {
2623
+ let retryCount = 0;
2624
+ submitTimerRef.current = setInterval(() => {
2625
+ retryCount++;
1526
2626
  const phase = phaseRef.current;
1527
- if (phase !== "submitting" && phase !== "adjudicating" && draftRef.current.trim()) doSubmit();
1528
- }, 800);
1529
- });
1530
- engine.onSpeechStart(async () => {
1531
- resetIdle();
1532
- bus.skipAudio();
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
- if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "v" && !e.repeat) {
1607
- e.preventDefault();
1608
- cancelCtrl();
1609
- toggleRef.current();
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 !== "Control" || e.shiftKey || e.altKey || e.metaKey || e.repeat || !eng) return;
1614
- if (bootNow().mode === "hold") {
1615
- ctrlTimer = setTimeout(() => {
1616
- ctrlTimer = null;
1617
- holdCtrlRef.current = true;
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
- }, 600);
1620
- } else {
1621
- eng.forceSend();
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 === "Control") cancelCtrl();
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 && bootNow().mode === "hold") {
1661
- engineRef.current?.endHeld(true);
1662
- holdCtrlRef.current = false;
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 label = on ? busy ? t("recognizing") : holdMode ? t("holdToTalk") : t("voiceDetected") : local === "pending" ? t("entering") : t("voiceBtn");
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 (localRef.current === "on") engineRef.current?.beginHeld();
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: "none",
1749
- background: on ? holdMode ? "rgba(88, 166, 255, 0.16)" : "rgba(63, 185, 80, 0.16)" : local === "pending" ? "rgba(88, 166, 255, 0.14)" : "transparent",
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: "4px 8px",
2977
+ padding: "5px 10px",
1752
2978
  borderRadius: 8,
1753
2979
  display: "flex",
1754
2980
  alignItems: "center",
1755
2981
  gap: 6,
1756
- fontSize: 11,
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: 14, height: 14, "aria-hidden": "true", children: [
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.state === "wake" ? t("sayWake").replace("{wake}", b.ui.wakeWord || t("wakeWord")) : b.ui.mode === "hold" ? t("barHold") : t("barListening");
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
- alignItems: "center",
1801
- gap: 8,
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.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)(
1813
- "span",
1814
- {
1815
- className: "dshvm-bar",
1816
- style: {
1817
- height: `${3 + v * 12}px`,
1818
- background: "#3fb950",
1819
- opacity: 0.4 + v * 0.6
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
- i
1823
- )) }),
1824
- /* @__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.partial ? b.ui.partial : b.ui.ttsNotice ? b.ui.ttsNotice : stateText }),
1825
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1826
- "button",
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
- border: "none",
1833
- background: "transparent",
3135
+ fontSize: 11,
1834
3136
  color: "#8b949e",
1835
- cursor: "pointer",
1836
- fontSize: 12,
1837
- flexShrink: 0
3137
+ fontVariantNumeric: "tabular-nums",
3138
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
3139
+ whiteSpace: "nowrap",
3140
+ overflowX: "auto"
1838
3141
  },
1839
- children: t("exit")
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: 16,
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: "auto",
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
  }