dsh-voice-mode 0.2.3 → 0.5.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,6 +42,23 @@ module.exports = __toCommonJS(client_exports);
42
42
  var React = __toESM(require("react"), 1);
43
43
  var import_react2 = require("react");
44
44
 
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;
60
+ }
61
+
45
62
  // src/wakeword.ts
46
63
  function normalizeWake(text) {
47
64
  return String(text ?? "").replace(/[\s\u3000]+/g, "").toLowerCase().replace(/[,。!?!?;;、,.]/g, "");
@@ -56,21 +73,20 @@ function matchWakeWord(partial, wakeWord) {
56
73
  }
57
74
 
58
75
  // src/asr.ts
76
+ var workletBlobUrl = null;
59
77
  var SAMPLE_RATE = 16e3;
60
78
  var SPEECH_RMS = 0.015;
61
79
  var LEVEL_CEILING = 0.25;
62
80
  var MAX_SEGMENT_MS = 3e4;
81
+ var MIN_SPEECH_MS = 250;
63
82
  var PRE_PAD_MS = 250;
64
- var PARTIAL_INTERVAL_MS = 900;
83
+ var PARTIAL_INTERVAL_MS = 100;
65
84
  var PARTIAL_MIN_S = 0.4;
66
85
  var PARTIAL_MAX_S = 30;
67
86
  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
87
  function createAsrEngine(config, sessionId) {
88
+ const wakeWord = (config.wakeWord ?? "").trim().toLowerCase().replace(/[\s\u3000]+/g, "");
89
+ const wakeEnabled = wakeWord !== "" && config.mode !== "hold";
74
90
  let state = "idle";
75
91
  const stateListeners = /* @__PURE__ */ new Set();
76
92
  const errorListeners = /* @__PURE__ */ new Set();
@@ -84,29 +100,45 @@ function createAsrEngine(config, sessionId) {
84
100
  };
85
101
  const transcriptListeners = /* @__PURE__ */ new Set();
86
102
  const partialListeners = /* @__PURE__ */ new Set();
87
- const speechStartListeners = /* @__PURE__ */ new Set();
88
103
  const levelListeners = /* @__PURE__ */ new Set();
104
+ const telemetryListeners = /* @__PURE__ */ new Set();
105
+ let utteranceEndAt = null;
106
+ const emitTelemetry = (stage) => {
107
+ const ev = { stage, at: Date.now() };
108
+ for (const fn of telemetryListeners) {
109
+ try {
110
+ fn(ev);
111
+ } catch {
112
+ }
113
+ }
114
+ };
89
115
  let audioCtx = null;
90
116
  let stream = null;
91
117
  let processor = null;
118
+ let workletNode = null;
92
119
  let active = false;
120
+ let stopRequested = false;
121
+ let startSeq = 0;
93
122
  let inFlush = false;
94
123
  let ctxRate = SAMPLE_RATE;
95
124
  let speechActive = false;
96
125
  let segment = [];
97
126
  let segmentMs = 0;
127
+ let speechMs = 0;
98
128
  let silenceMs = 0;
99
129
  let prePad = [];
100
130
  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;
131
+ const echo = config.echo;
132
+ let lastPollAt = 0;
106
133
  let partialInFlight = false;
107
134
  let segmentEpoch = 0;
108
135
  let forcePending = false;
109
- const asrUrl = (final) => `${location.origin}${config.basePath.replace(/\/+$/, "")}/asr?sessionId=${encodeURIComponent(sessionId)}&final=${final ? 1 : 0}`;
136
+ let uploadedSamples = 0;
137
+ let detectChunks = [];
138
+ let detectSent = 0;
139
+ let detectInFlight = false;
140
+ let detectGeneration = 0;
141
+ 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
142
  const setState = (s) => {
111
143
  state = s;
112
144
  for (const fn of stateListeners) {
@@ -126,25 +158,36 @@ function createAsrEngine(config, sessionId) {
126
158
  }
127
159
  }
128
160
  };
129
- const concatSegment = () => {
130
- const n = segment.reduce((acc, c) => acc + c.length, 0);
131
- const out = new Float32Array(n);
161
+ const sliceChunks = (chunks, from) => {
162
+ let total = 0;
163
+ for (const c of chunks) total += c.length;
164
+ const out = new Float32Array(Math.max(0, total - from));
165
+ if (out.length === 0) return out;
132
166
  let off = 0;
133
- for (const c of segment) {
134
- out.set(c, off);
135
- off += c.length;
167
+ let acc = 0;
168
+ for (const c of chunks) {
169
+ if (off >= out.length) break;
170
+ const sub = c.subarray(Math.max(0, from - acc));
171
+ const n = Math.min(sub.length, out.length - off);
172
+ out.set(sub.subarray(0, n), off);
173
+ off += n;
174
+ acc += c.length;
136
175
  }
137
176
  return out;
138
177
  };
178
+ const sliceSince = (from) => sliceChunks(segment, from);
139
179
  const requestPartial = async () => {
140
180
  if (partialInFlight || segment.length === 0) return;
141
- const seconds = segment.reduce((n, c) => n + c.length, 0) / SAMPLE_RATE;
181
+ const total = segment.reduce((n, c) => n + c.length, 0);
182
+ const seconds = total / SAMPLE_RATE;
142
183
  if (seconds < PARTIAL_MIN_S || seconds > PARTIAL_MAX_S) return;
143
- const samples = concatSegment();
184
+ const from = uploadedSamples;
185
+ if (total - from <= 0) return;
186
+ const samples = sliceSince(from);
144
187
  const epoch = segmentEpoch;
145
188
  partialInFlight = true;
146
189
  try {
147
- let res = await fetch(asrUrl(false), {
190
+ let res = await fetch(asrUrl(false, from, epoch), {
148
191
  method: "POST",
149
192
  headers: { "content-type": "application/octet-stream" },
150
193
  body: samples.buffer
@@ -154,7 +197,7 @@ function createAsrEngine(config, sessionId) {
154
197
  const retry = await new Promise((resolve) => {
155
198
  setTimeout(async () => {
156
199
  try {
157
- const r2 = await fetch(asrUrl(false), {
200
+ const r2 = await fetch(asrUrl(false, from, epoch), {
158
201
  method: "POST",
159
202
  headers: { "content-type": "application/octet-stream" },
160
203
  body: samples.buffer
@@ -167,42 +210,113 @@ function createAsrEngine(config, sessionId) {
167
210
  });
168
211
  res = retry;
169
212
  }
213
+ if (res.status === 403 && config.onSessionExpired) {
214
+ const recovered = await config.onSessionExpired();
215
+ if (recovered && epoch === segmentEpoch) {
216
+ uploadedSamples = 0;
217
+ try {
218
+ res = await fetch(asrUrl(false, 0, epoch), {
219
+ method: "POST",
220
+ headers: { "content-type": "application/octet-stream" },
221
+ body: sliceSince(0).buffer
222
+ });
223
+ } catch {
224
+ }
225
+ }
226
+ }
170
227
  if (epoch !== segmentEpoch) return;
171
228
  if (!res.ok) return;
172
229
  const out = await res.json();
173
230
  if (epoch !== segmentEpoch) return;
174
- if (state === "loading-model") setState("speech");
175
- if (state === "wake" && wakeWord) {
231
+ if (state === "wake" && wakeEnabled) {
232
+ uploadedSamples = Math.max(uploadedSamples, from + samples.length);
176
233
  if (matchWakeWord(out.text ?? "", wakeWord)) {
177
234
  segmentEpoch++;
178
235
  segment = [];
179
236
  segmentMs = 0;
237
+ speechMs = 0;
180
238
  silenceMs = 0;
181
239
  prePad = [];
182
- sincePartialMs = 0;
240
+ uploadedSamples = 0;
241
+ utteranceEndAt = null;
183
242
  await resetHostStream();
184
243
  if (active) setState("listening");
185
244
  }
186
245
  return;
187
246
  }
247
+ if (out.isSpeech !== void 0) config.onIsSpeech?.(out.isSpeech);
248
+ if (state === "loading-model") setState("speech");
249
+ uploadedSamples = Math.max(uploadedSamples, from + samples.length);
188
250
  emit(partialListeners, out.text ?? "");
251
+ if (out.endpoint && active && speechActive && !holdActive) finalizeSegment();
189
252
  } catch {
190
253
  } finally {
191
254
  partialInFlight = false;
192
255
  }
193
256
  };
257
+ const requestDetect = async () => {
258
+ if (detectInFlight) return;
259
+ let total = detectChunks.reduce((n, c) => n + c.length, 0);
260
+ const MAX_DETECT_PENDING = 30 * SAMPLE_RATE;
261
+ if (total - detectSent > MAX_DETECT_PENDING) {
262
+ detectSent = Math.max(0, total - MAX_DETECT_PENDING);
263
+ }
264
+ while (detectChunks.length > 0 && detectSent >= detectChunks[0].length) {
265
+ detectSent -= detectChunks[0].length;
266
+ detectChunks.shift();
267
+ }
268
+ total = detectChunks.reduce((n, c) => n + c.length, 0);
269
+ if (total - detectSent <= 0) return;
270
+ const samples = sliceChunks(detectChunks, detectSent);
271
+ const epoch = segmentEpoch;
272
+ const gen = detectGeneration;
273
+ detectInFlight = true;
274
+ try {
275
+ const res = await fetch(asrUrl(false, detectSent, epoch) + "&vadOnly=1", {
276
+ method: "POST",
277
+ headers: { "content-type": "application/octet-stream" },
278
+ body: samples.buffer,
279
+ signal: AbortSignal.timeout(5e3)
280
+ // 防服务端挂起长期锁死 detectInFlight(Important#2)
281
+ });
282
+ if (epoch !== segmentEpoch || gen !== detectGeneration) return;
283
+ if (!res.ok) return;
284
+ const out = await res.json();
285
+ if (epoch !== segmentEpoch || gen !== detectGeneration) return;
286
+ if (out.isSpeech !== void 0) config.onIsSpeech?.(out.isSpeech);
287
+ detectSent += samples.length;
288
+ while (detectChunks.length > 0 && detectSent >= detectChunks[0].length) {
289
+ detectSent -= detectChunks[0].length;
290
+ detectChunks.shift();
291
+ }
292
+ } catch {
293
+ } finally {
294
+ detectInFlight = false;
295
+ }
296
+ };
194
297
  const resetHostStream = async () => {
195
298
  try {
196
- await fetch(`${asrUrl(false)}&reset=1`, { method: "POST" });
299
+ await fetch(`${asrUrl(false)}&reset=1`, { method: "POST", signal: AbortSignal.timeout(5e3) });
197
300
  } catch {
198
301
  }
199
302
  };
200
- const finalizeSegment = () => {
303
+ const finalizeSegment = (force = false) => {
201
304
  if (segment.length === 0) return;
202
- const samples = concatSegment();
203
- const epoch = ++segmentEpoch;
305
+ if (config.isPlaying?.() && !forcePending && !force) return;
306
+ if (utteranceEndAt === null) {
307
+ utteranceEndAt = Date.now();
308
+ emitTelemetry("utterance-end");
309
+ }
310
+ emitTelemetry("endpoint-fired");
311
+ const from = uploadedSamples;
312
+ const samples = sliceSince(from);
313
+ const recoverySegment = segment;
314
+ const epochSnapshot = segmentEpoch;
315
+ segmentEpoch++;
204
316
  const meta = { force: forcePending };
205
317
  forcePending = false;
318
+ speechMs = 0;
319
+ uploadedSamples = 0;
206
320
  segment = [];
207
321
  speechActive = false;
208
322
  silenceMs = 0;
@@ -210,22 +324,41 @@ function createAsrEngine(config, sessionId) {
210
324
  prePad = [];
211
325
  setState("transcribing");
212
326
  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
- });
327
+ emitTelemetry("submitted");
328
+ const MAX_FINAL_ATTEMPTS = 3;
329
+ const restoreState = () => setState(active ? speechActive || holdActive ? "speech" : wakeEnabled ? "wake" : "listening" : "idle");
330
+ for (let attempt = 0; attempt < MAX_FINAL_ATTEMPTS; attempt++) {
331
+ if (attempt > 0) {
332
+ if (segmentEpoch !== epochSnapshot + 1) return;
333
+ await new Promise((r) => setTimeout(r, 500 * attempt));
334
+ }
335
+ const useFull = attempt > 0;
336
+ const off = useFull ? 0 : from;
337
+ const body = useFull ? sliceChunks(recoverySegment, 0).buffer : samples.buffer;
338
+ let res;
339
+ try {
340
+ res = await fetch(asrUrl(true, off, epochSnapshot), {
341
+ signal: AbortSignal.timeout(1e4),
342
+ method: "POST",
343
+ headers: { "content-type": "application/octet-stream" },
344
+ body
345
+ });
346
+ } catch {
347
+ restoreState();
348
+ if (attempt === MAX_FINAL_ATTEMPTS - 1) console.warn("[dsh-voice-mode] finalize fetch \u5F02\u5E38\uFF08\u91CD\u8BD5\u8017\u5C3D\uFF09");
349
+ continue;
350
+ }
219
351
  if (res.status === 202) {
220
352
  setState("loading-model");
221
353
  res = await new Promise((resolve) => {
222
354
  setTimeout(async () => {
223
355
  try {
224
356
  resolve(
225
- await fetch(asrUrl(true), {
357
+ await fetch(asrUrl(true, off, epochSnapshot), {
358
+ signal: AbortSignal.timeout(1e4),
226
359
  method: "POST",
227
360
  headers: { "content-type": "application/octet-stream" },
228
- body: samples.buffer
361
+ body
229
362
  })
230
363
  );
231
364
  } catch {
@@ -234,21 +367,53 @@ function createAsrEngine(config, sessionId) {
234
367
  }, 5e3);
235
368
  });
236
369
  }
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;
370
+ if (res.status === 202) {
371
+ if (attempt === MAX_FINAL_ATTEMPTS - 1) console.warn("[dsh-voice-mode] finalize \u6A21\u578B\u52A0\u8F7D\u8D85\u65F6\uFF08\u91CD\u8BD5\u8017\u5C3D\uFF09");
372
+ continue;
373
+ }
374
+ if (res.status === 403 && config.onSessionExpired) {
375
+ const recovered = await config.onSessionExpired();
376
+ if (recovered && segmentEpoch === epochSnapshot + 1) {
377
+ try {
378
+ res = await fetch(asrUrl(true, 0, epochSnapshot), {
379
+ signal: AbortSignal.timeout(1e4),
380
+ method: "POST",
381
+ headers: { "content-type": "application/octet-stream" },
382
+ body: sliceChunks(recoverySegment, 0).buffer
383
+ });
384
+ } catch {
385
+ }
386
+ }
387
+ }
388
+ restoreState();
389
+ if (!res.ok) {
390
+ if (attempt === MAX_FINAL_ATTEMPTS - 1) console.warn("[dsh-voice-mode] finalize 5xx\uFF08\u91CD\u8BD5\u8017\u5C3D\uFF09");
391
+ continue;
392
+ }
393
+ let out;
394
+ try {
395
+ out = await res.json();
396
+ } catch {
397
+ if (attempt === MAX_FINAL_ATTEMPTS - 1) console.warn("[dsh-voice-mode] finalize \u54CD\u5E94\u975E JSON\uFF08\u91CD\u8BD5\u8017\u5C3D\uFF09");
398
+ continue;
399
+ }
400
+ if (segmentEpoch !== epochSnapshot + 1) return;
242
401
  if (out.text) emit(transcriptListeners, out.text, meta);
243
- } catch {
244
- setState(active ? speechActive ? "speech" : "listening" : "idle");
245
- emitError("recognitionFail");
402
+ return;
246
403
  }
404
+ emitError("recognitionFail");
247
405
  })();
248
406
  };
407
+ let latestResidualRms = 0;
408
+ let echoFloorRms = 0;
409
+ let echoPeak = 0;
249
410
  const handleAudio = (raw) => {
250
411
  if (!active || inFlush) return;
251
- const data = ctxRate !== SAMPLE_RATE ? resampleTo16k(raw, ctxRate) : raw;
412
+ let data = ctxRate !== SAMPLE_RATE ? resampleLinear(raw, ctxRate, SAMPLE_RATE) : raw;
413
+ if (echo) {
414
+ const ref = echo.windowAt(performance.now(), data.length);
415
+ data = echo.process(data, ref);
416
+ }
252
417
  let sum = 0;
253
418
  for (let i = 0; i < data.length; i++) sum += data[i] * data[i];
254
419
  const rms = Math.sqrt(sum / data.length);
@@ -259,23 +424,25 @@ function createAsrEngine(config, sessionId) {
259
424
  } catch {
260
425
  }
261
426
  }
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
- }
427
+ const playingNow = config.isPlaying?.() ?? false;
428
+ const gateRatio = Math.pow(10, (config.echoGateDb ?? 6) / 20);
429
+ const peakDecay = Math.pow(0.9, durationMs / 64);
430
+ const floorAlpha = 1 - Math.pow(0.98, durationMs / 64);
431
+ if (playingNow) {
432
+ latestResidualRms = rms;
433
+ echoPeak = Math.max(echoPeak * peakDecay, rms);
434
+ }
435
+ const doubleTalk = playingNow && echoFloorRms > 0 && rms > echoFloorRms * gateRatio;
436
+ if (playingNow) {
437
+ if (echoFloorRms === 0) echoFloorRms = rms;
438
+ else if (!doubleTalk) echoFloorRms = echoFloorRms * (1 - floorAlpha) + rms * floorAlpha;
276
439
  } else {
277
- interruptCandidateMs = 0;
440
+ echoPeak = 0;
278
441
  }
442
+ if (echo) {
443
+ echo.setFrozen(doubleTalk);
444
+ }
445
+ if (playingNow && !holdActive && config.mode !== "hold") detectChunks.push(data);
279
446
  if (holdActive) {
280
447
  if (!speechActive) {
281
448
  speechActive = true;
@@ -285,6 +452,7 @@ function createAsrEngine(config, sessionId) {
285
452
  silenceMs = 0;
286
453
  segment.push(data);
287
454
  if (segmentMs > MAX_SEGMENT_MS) finalizeSegment();
455
+ } else if (config.mode === "hold") {
288
456
  } else if (state === "wake") {
289
457
  if (rms > SPEECH_RMS) {
290
458
  segmentMs += durationMs;
@@ -293,38 +461,55 @@ function createAsrEngine(config, sessionId) {
293
461
  segment = [];
294
462
  segmentMs = 0;
295
463
  silenceMs = 0;
296
- prePad = [];
464
+ uploadedSamples = 0;
297
465
  void resetHostStream();
298
466
  }
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
467
  }
312
468
  } else if (rms > SPEECH_RMS) {
469
+ if (config.isPlaying?.()) {
470
+ if (speechActive) finalizeSegment(true);
471
+ return;
472
+ }
313
473
  if (!speechActive) {
314
474
  speechActive = true;
475
+ detectChunks = [];
476
+ detectSent = 0;
477
+ detectGeneration++;
478
+ utteranceEndAt = null;
315
479
  setState("speech");
316
480
  for (const p of prePad) segment.push(p);
317
481
  prePad = [];
318
482
  }
483
+ speechMs += durationMs;
319
484
  segmentMs += durationMs;
320
485
  silenceMs = 0;
321
486
  segment.push(data);
322
487
  if (segmentMs > MAX_SEGMENT_MS) finalizeSegment();
323
488
  } else if (speechActive) {
489
+ if (utteranceEndAt === null) {
490
+ utteranceEndAt = Date.now();
491
+ emitTelemetry("utterance-end");
492
+ }
324
493
  segmentMs += durationMs;
325
494
  silenceMs += durationMs;
326
495
  segment.push(data);
327
- if (silenceMs > config.silenceMs) finalizeSegment();
496
+ if (silenceMs > config.silenceMs) {
497
+ if (speechMs >= MIN_SPEECH_MS) {
498
+ finalizeSegment();
499
+ } else {
500
+ segmentEpoch++;
501
+ segment = [];
502
+ speechActive = false;
503
+ speechMs = 0;
504
+ silenceMs = 0;
505
+ segmentMs = 0;
506
+ prePad = [];
507
+ utteranceEndAt = null;
508
+ uploadedSamples = 0;
509
+ void resetHostStream();
510
+ setState("listening");
511
+ }
512
+ }
328
513
  } else {
329
514
  prePad.push(data);
330
515
  let total = 0;
@@ -338,34 +523,41 @@ function createAsrEngine(config, sessionId) {
338
523
  }
339
524
  if (cut > 0) prePad = prePad.slice(cut);
340
525
  }
341
- sincePartialMs += durationMs;
342
- if ((speechActive || holdActive || state === "wake") && sincePartialMs >= PARTIAL_INTERVAL_MS) {
343
- sincePartialMs = 0;
344
- void requestPartial();
526
+ const nowMs = Date.now();
527
+ if (nowMs - lastPollAt >= PARTIAL_INTERVAL_MS) {
528
+ if (playingNow && !speechActive && !holdActive) {
529
+ lastPollAt = nowMs;
530
+ void requestDetect();
531
+ } else if (speechActive || holdActive || state === "wake") {
532
+ lastPollAt = nowMs;
533
+ void requestPartial();
534
+ }
345
535
  }
346
536
  };
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
537
  const startRecorder = async () => {
538
+ const mySeq = startSeq;
361
539
  stream = await navigator.mediaDevices.getUserMedia({
362
540
  audio: {
363
541
  channelCount: 1,
364
542
  echoCancellation: true,
365
- noiseSuppression: true,
366
- autoGainControl: true
543
+ // L0(研究核实):AGC/NS 是时变非线性,放在 AEC 前破坏线性回声路径假设——
544
+ // WebRTC GainController2 与 Speex 均把增益放 AEC 之后。禁用后原生 AEC 与
545
+ // 自研 AEC 的输入才线性,回声消得净;AGC 放大近端语音由「残差地板门控」
546
+ // 的归一化语义替代,不需要采集端增益。
547
+ noiseSuppression: false,
548
+ autoGainControl: false
367
549
  }
368
550
  });
551
+ if (stopRequested || mySeq !== startSeq) {
552
+ stream.getTracks().forEach((t3) => t3.stop());
553
+ stream = null;
554
+ return;
555
+ }
556
+ const aecOn = stream.getAudioTracks()[0]?.getSettings().echoCancellation === true;
557
+ if (!aecOn) {
558
+ 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");
559
+ }
560
+ config.onAecState?.(aecOn);
369
561
  const AC = window.AudioContext ?? window.webkitAudioContext;
370
562
  audioCtx = new AC({ sampleRate: SAMPLE_RATE });
371
563
  try {
@@ -374,6 +566,29 @@ function createAsrEngine(config, sessionId) {
374
566
  }
375
567
  ctxRate = audioCtx.sampleRate;
376
568
  const source = audioCtx.createMediaStreamSource(stream);
569
+ if (audioCtx.audioWorklet) {
570
+ try {
571
+ if (!workletBlobUrl) {
572
+ 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" }));
573
+ }
574
+ await audioCtx.audioWorklet.addModule(workletBlobUrl);
575
+ workletNode = new AudioWorkletNode(audioCtx, "voice-capture", { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [1] });
576
+ workletNode.port.onmessage = (e) => {
577
+ handleAudio(e.data);
578
+ };
579
+ source.connect(workletNode);
580
+ workletNode.connect(audioCtx.destination);
581
+ ctxRate = SAMPLE_RATE;
582
+ active = true;
583
+ return;
584
+ } catch {
585
+ try {
586
+ workletNode?.disconnect();
587
+ } catch {
588
+ }
589
+ workletNode = null;
590
+ }
591
+ }
377
592
  processor = audioCtx.createScriptProcessor(BUFFER_SIZE, 1, 1);
378
593
  processor.onaudioprocess = (e) => {
379
594
  handleAudio(new Float32Array(e.inputBuffer.getChannelData(0)));
@@ -383,8 +598,6 @@ function createAsrEngine(config, sessionId) {
383
598
  active = true;
384
599
  };
385
600
  const stopRecorder = async () => {
386
- if (!active) return;
387
- active = false;
388
601
  inFlush = true;
389
602
  segmentEpoch++;
390
603
  forcePending = false;
@@ -393,13 +606,23 @@ function createAsrEngine(config, sessionId) {
393
606
  speechActive = false;
394
607
  silenceMs = 0;
395
608
  segmentMs = 0;
609
+ speechMs = 0;
610
+ uploadedSamples = 0;
396
611
  prePad = [];
397
- interruptCandidateMs = 0;
612
+ detectChunks = [];
613
+ detectSent = 0;
614
+ detectGeneration++;
615
+ utteranceEndAt = null;
398
616
  try {
399
617
  processor?.disconnect();
400
618
  } catch {
401
619
  }
402
620
  processor = null;
621
+ try {
622
+ workletNode?.disconnect();
623
+ } catch {
624
+ }
625
+ workletNode = null;
403
626
  try {
404
627
  stream?.getTracks().forEach((t3) => t3.stop());
405
628
  } catch {
@@ -417,13 +640,28 @@ function createAsrEngine(config, sessionId) {
417
640
  get state() {
418
641
  return state;
419
642
  },
643
+ get holding() {
644
+ return holdActive;
645
+ },
646
+ /** A2.5 回声门控:当前残差是否明显高于回声地板(marginDb 默认 6dB)——判用户人声而非回声。 */
647
+ aboveEchoFloor(marginDb = 6) {
648
+ if (echoFloorRms === 0) return false;
649
+ return echoPeak > echoFloorRms * Math.pow(10, marginDb / 20);
650
+ },
651
+ echoLevels() {
652
+ return { floorRms: echoFloorRms, residualRms: latestResidualRms, peakRms: echoPeak };
653
+ },
420
654
  async start() {
421
655
  if (active) return;
656
+ stopRequested = false;
657
+ startSeq++;
422
658
  segmentEpoch++;
423
- sincePartialMs = 0;
424
- interruptCandidateMs = 0;
659
+ lastPollAt = 0;
425
660
  holdActive = false;
426
- setState(wakeWord ? "wake" : "listening");
661
+ detectChunks = [];
662
+ detectSent = 0;
663
+ detectGeneration++;
664
+ setState(wakeEnabled ? "wake" : "listening");
427
665
  try {
428
666
  await startRecorder();
429
667
  } catch (error) {
@@ -432,7 +670,10 @@ function createAsrEngine(config, sessionId) {
432
670
  }
433
671
  },
434
672
  async stop() {
435
- if (!active) {
673
+ stopRequested = true;
674
+ const wasActive = active;
675
+ active = false;
676
+ if (!wasActive) {
436
677
  setState("idle");
437
678
  return;
438
679
  }
@@ -443,7 +684,7 @@ function createAsrEngine(config, sessionId) {
443
684
  const speechS = segment.reduce((n, c) => n + c.length, 0) / SAMPLE_RATE;
444
685
  if (speechActive && speechS >= 0.25) {
445
686
  forcePending = true;
446
- sincePartialMs = 0;
687
+ lastPollAt = 0;
447
688
  finalizeSegment();
448
689
  }
449
690
  },
@@ -452,15 +693,39 @@ function createAsrEngine(config, sessionId) {
452
693
  holdActive = true;
453
694
  forcePending = true;
454
695
  segmentEpoch++;
696
+ utteranceEndAt = null;
455
697
  segment = [];
456
698
  segmentMs = 0;
699
+ speechMs = 0;
457
700
  silenceMs = 0;
458
701
  prePad = [];
702
+ uploadedSamples = 0;
703
+ detectChunks = [];
704
+ detectSent = 0;
705
+ detectGeneration++;
459
706
  speechActive = true;
460
- sincePartialMs = 0;
461
- bargeInDampingUntil = Date.now() + 800;
707
+ lastPollAt = 0;
462
708
  setState("speech");
463
709
  },
710
+ discardSegment() {
711
+ segmentEpoch++;
712
+ segment = [];
713
+ segmentMs = 0;
714
+ speechMs = 0;
715
+ silenceMs = 0;
716
+ speechActive = false;
717
+ prePad = [];
718
+ uploadedSamples = 0;
719
+ detectChunks = [];
720
+ detectSent = 0;
721
+ detectGeneration++;
722
+ utteranceEndAt = null;
723
+ forcePending = false;
724
+ lastPollAt = 0;
725
+ return resetHostStream().then(() => {
726
+ if (active) setState(wakeEnabled ? "wake" : "listening");
727
+ });
728
+ },
464
729
  endHeld(cancel = false) {
465
730
  if (!active || !holdActive) return;
466
731
  holdActive = false;
@@ -471,12 +736,18 @@ function createAsrEngine(config, sessionId) {
471
736
  silenceMs = 0;
472
737
  prePad = [];
473
738
  speechActive = false;
474
- setState(wakeWord ? "wake" : "listening");
739
+ forcePending = false;
740
+ setState(wakeEnabled ? "wake" : "listening");
475
741
  return;
476
742
  }
477
- forcePending = true;
478
- sincePartialMs = 0;
479
- finalizeSegment();
743
+ if (segment.length > 0) {
744
+ forcePending = true;
745
+ lastPollAt = 0;
746
+ finalizeSegment();
747
+ } else {
748
+ forcePending = false;
749
+ setState(wakeEnabled ? "wake" : "listening");
750
+ }
480
751
  },
481
752
  onSegment(fn) {
482
753
  transcriptListeners.add(fn);
@@ -496,12 +767,6 @@ function createAsrEngine(config, sessionId) {
496
767
  partialListeners.delete(fn);
497
768
  };
498
769
  },
499
- onSpeechStart(fn) {
500
- speechStartListeners.add(fn);
501
- return () => {
502
- speechStartListeners.delete(fn);
503
- };
504
- },
505
770
  onState(fn) {
506
771
  stateListeners.add(fn);
507
772
  fn(state);
@@ -514,10 +779,154 @@ function createAsrEngine(config, sessionId) {
514
779
  return () => {
515
780
  levelListeners.delete(fn);
516
781
  };
782
+ },
783
+ onTelemetry(fn) {
784
+ telemetryListeners.add(fn);
785
+ return () => {
786
+ telemetryListeners.delete(fn);
787
+ };
517
788
  }
518
789
  };
519
790
  }
520
791
 
792
+ // src/aec.ts
793
+ var DEFAULT_FILTER_LENGTH = 256;
794
+ var DEFAULT_DELAY = 64;
795
+ var DEFAULT_STEP = 0.1;
796
+ var DEFAULT_EPSILON = 1e-6;
797
+ var MIN_REF_NORM = 1e-6;
798
+ var NlmsAec = class {
799
+ w;
800
+ xBuf;
801
+ filterLength;
802
+ delay;
803
+ mu;
804
+ eps;
805
+ /** 参考历史环形游标。 */
806
+ cursor = 0;
807
+ /** 已缓冲参考样本数(预热期)。 */
808
+ filled = 0;
809
+ /** A2.5 双讲冻结:用户说话时暂停权重更新,防滤波器被用户语音带偏。 */
810
+ frozen = false;
811
+ constructor(options = {}) {
812
+ this.filterLength = options.filterLength ?? DEFAULT_FILTER_LENGTH;
813
+ this.delay = options.delay ?? DEFAULT_DELAY;
814
+ this.mu = options.step ?? DEFAULT_STEP;
815
+ this.eps = options.epsilon ?? DEFAULT_EPSILON;
816
+ this.w = new Float32Array(this.filterLength);
817
+ this.xBuf = new Float32Array(this.delay + this.filterLength);
818
+ }
819
+ /** A2.5 双讲冻结:true 时暂停权重更新(回声相减照常,仅停止自适应)。 */
820
+ setFrozen(frozen) {
821
+ this.frozen = frozen;
822
+ }
823
+ /**
824
+ * 送入下一块麦克风/参考;返回去回声后的麦克风样本(与输入等长)。
825
+ * 参考可比麦克风块短(如静音填充)——不足部分补零。
826
+ */
827
+ process(mic, ref) {
828
+ const n = mic.length;
829
+ const out = new Float32Array(n);
830
+ if (n === 0) return out;
831
+ const xBuf = this.xBuf;
832
+ const bufLen = xBuf.length;
833
+ let cursor = this.cursor;
834
+ for (let i = 0; i < n; i++) {
835
+ xBuf[cursor] = i < ref.length ? ref[i] : 0;
836
+ cursor = (cursor + 1) % bufLen;
837
+ this.filled = Math.min(this.filled + 1, bufLen);
838
+ const d = mic[i];
839
+ if (this.filled >= this.delay + this.filterLength) {
840
+ let y = 0;
841
+ let norm = 0;
842
+ let idx = (cursor - this.delay + bufLen) % bufLen;
843
+ for (let t3 = 0; t3 < this.filterLength; t3++) {
844
+ const x = xBuf[idx];
845
+ y += this.w[t3] * x;
846
+ norm += x * x;
847
+ idx = (idx - 1 + bufLen) % bufLen;
848
+ }
849
+ const e = d - y;
850
+ out[i] = Number.isFinite(e) ? e : 0;
851
+ if (norm > MIN_REF_NORM && !this.frozen) {
852
+ const denom = norm + this.eps;
853
+ const gain = this.mu * e / denom;
854
+ idx = (cursor - this.delay + bufLen) % bufLen;
855
+ for (let t3 = 0; t3 < this.filterLength; t3++) {
856
+ this.w[t3] += gain * xBuf[idx];
857
+ idx = (idx - 1 + bufLen) % bufLen;
858
+ }
859
+ }
860
+ } else {
861
+ out[i] = d;
862
+ }
863
+ }
864
+ this.cursor = cursor;
865
+ return out;
866
+ }
867
+ };
868
+ function estimateBulkDelay(mic, ref, opts = {}) {
869
+ const sr = opts.sampleRate ?? 16e3;
870
+ const ds = Math.max(1, Math.floor(opts.downsample ?? 4));
871
+ const minLag = Math.max(0, opts.minLag ?? 0);
872
+ const maxLag = opts.maxLag ?? Math.floor(300 * sr / 1e3);
873
+ const n = Math.min(mic.length, ref.length);
874
+ if (n < ds * 64) return { lag: 0, peak: 0 };
875
+ const N = Math.floor(n / ds);
876
+ const maxLagD = Math.floor(maxLag / ds);
877
+ const minLagD = Math.floor(minLag / ds);
878
+ if (maxLagD >= N) return { lag: 0, peak: 0 };
879
+ const m = new Float32Array(N);
880
+ const r = new Float32Array(N);
881
+ let mSum = 0;
882
+ let rSum = 0;
883
+ for (let i = 0; i < N; i++) {
884
+ let mv = 0;
885
+ let rv = 0;
886
+ for (let k = 0; k < ds; k++) {
887
+ mv += mic[i * ds + k];
888
+ rv += ref[i * ds + k];
889
+ }
890
+ mv /= ds;
891
+ rv /= ds;
892
+ m[i] = mv;
893
+ r[i] = rv;
894
+ mSum += mv;
895
+ rSum += rv;
896
+ }
897
+ const mMu = mSum / N;
898
+ const rMu = rSum / N;
899
+ let mE = 0;
900
+ let rE = 0;
901
+ for (let i = 0; i < N; i++) {
902
+ m[i] -= mMu;
903
+ r[i] -= rMu;
904
+ mE += m[i] * m[i];
905
+ rE += r[i] * r[i];
906
+ }
907
+ if (mE < 1e-9 || rE < 1e-9) return { lag: 0, peak: 0 };
908
+ const prefM = new Float32Array(N + 1);
909
+ const prefR = new Float32Array(N + 1);
910
+ for (let i = 0; i < N; i++) {
911
+ prefM[i + 1] = prefM[i] + m[i] * m[i];
912
+ prefR[i + 1] = prefR[i] + r[i] * r[i];
913
+ }
914
+ let bestLag = 0;
915
+ let bestPeak = -Infinity;
916
+ for (let d = minLagD; d <= maxLagD; d++) {
917
+ let corr = 0;
918
+ for (let i = d; i < N; i++) corr += m[i] * r[i - d];
919
+ const em = prefM[N] - prefM[d];
920
+ const er = prefR[N - d];
921
+ const peak = em > 1e-12 && er > 1e-12 ? corr / Math.sqrt(em * er) : 0;
922
+ if (peak > bestPeak) {
923
+ bestPeak = peak;
924
+ bestLag = d;
925
+ }
926
+ }
927
+ return { lag: bestLag * ds, peak: bestPeak };
928
+ }
929
+
521
930
  // src/strings.ts
522
931
  var zh = {
523
932
  stateVoiceMode: "\u8BED\u97F3\u6A21\u5F0F",
@@ -530,6 +939,7 @@ var zh = {
530
939
  hold: "\u6309\u4F4F",
531
940
  recognizing: "\u8BC6\u522B\u4E2D\u2026",
532
941
  holdToTalk: "\u6309\u4F4F\u8BF4\u8BDD",
942
+ releaseToSend: "\u677E\u5F00\u53D1\u9001",
533
943
  voiceDetected: "\u8BED\u97F3\u4E2D",
534
944
  entering: "\u8FDB\u5165\u4E2D\u2026",
535
945
  voiceBtn: "\u8BED\u97F3",
@@ -540,15 +950,18 @@ var zh = {
540
950
  titleEnter: "\u8FDB\u5165\u8BED\u97F3\u5BF9\u8BDD\u6A21\u5F0F\uFF08Ctrl+Shift+V\uFF09",
541
951
  loadingModel: "\u6B63\u5728\u52A0\u8F7D\u6A21\u578B\u2026",
542
952
  listening: "\u8046\u542C\u4E2D\u2026",
543
- wakeWord: "\u5524\u9192\u8BCD",
953
+ thinking: "\u601D\u8003\u4E2D\u2026",
544
954
  barHold: "\u8BED\u97F3\u6A21\u5F0F \xB7 \u6309\u4F4F\u8BF4\u8BDD\uFF08\u77ED\u6309\u9000\u51FA\uFF09",
545
955
  barListening: "\u8BED\u97F3\u6A21\u5F0F \xB7 \u8046\u542C\u4E2D\u2026",
956
+ wakeWord: "\u5524\u9192\u8BCD",
957
+ sayWake: "\u8BF4\u300C{wake}\u300D\u5F00\u59CB",
546
958
  reading: "\u6717\u8BFB\u4E2D\u2026",
547
959
  recognitionFail: "\u8BC6\u522B\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5",
960
+ sessionExpired: "\u8BED\u97F3\u4F1A\u8BDD\u5DF2\u65AD\u5F00\uFF0C\u6B63\u5728\u91CD\u8FDE\u2026",
961
+ sessionExpiredFail: "\u8BED\u97F3\u4F1A\u8BDD\u91CD\u8FDE\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u5F00\u542F\u8BED\u97F3\u6A21\u5F0F",
548
962
  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
963
  startFail: "\u8BED\u97F3\u6A21\u5F0F\u542F\u52A8\u5931\u8D25\uFF1A{err}",
550
964
  holdDots: "\u6309\u4F4F\u8BF4\u8BDD\u2026",
551
- sayWake: "\u8BF4\u300C{wake}\u300D\u5F00\u59CB",
552
965
  exit: "\u9000\u51FA",
553
966
  skip: "\u8DF3\u8FC7",
554
967
  configUnavailableNote: "\uFF08\u8BBE\u7F6E\u6587\u6863\u672A\u5C31\u7EEA\uFF0C\u9762\u677F\u5C31\u7EEA\u540E\u4F1A\u81EA\u52A8\u51FA\u73B0\uFF09\u3002",
@@ -558,29 +971,95 @@ var zh = {
558
971
  previewPlayFail: "\u8BD5\u542C\u5931\u8D25\uFF1A\u65E0\u6CD5\u64AD\u653E\u8BE5\u97F3\u8272",
559
972
  previewAutoplay: "\u6D4F\u89C8\u5668\u62E6\u622A\u4E86\u81EA\u52A8\u64AD\u653E\uFF0C\u8BF7\u518D\u70B9\u4E00\u6B21\u8BD5\u542C",
560
973
  previewCheck: "\u8BD5\u542C\u5931\u8D25\uFF1A\u8BF7\u68C0\u67E5\u7F51\u7EDC\u6216\u97F3\u8272\u540D\uFF08ShortName\uFF09\u662F\u5426\u6B63\u786E",
974
+ previewRateLimited: "\u8BD5\u542C\u592A\u9891\u7E41\u4E86\uFF0C\u7A0D\u5019\u51E0\u79D2\u518D\u70B9\uFF08\u6BCF\u5206\u949F\u9650 20 \u6B21\uFF09",
975
+ previewTimeout: "\u5408\u6210\u8D85\u65F6\uFF1A\u6A21\u578B\u4ECD\u5728\u52A0\u8F7D\uFF0C\u8BF7\u7A0D\u5019\u51E0\u79D2\u518D\u8BD5",
976
+ previewSynthesisFail: "\u5408\u6210\u5931\u8D25",
561
977
  previewBtnTitle: "\u8BD5\u542C\u5F53\u524D\u97F3\u8272\uFF08\u5F53\u524D\u8BED\u901F\uFF09",
562
978
  synthesizing: "\u5408\u6210\u4E2D\u2026",
563
979
  preview: "\u8BD5\u542C",
564
980
  custom: "\u81EA\u5B9A\u4E49",
565
981
  // settings rows
566
- descVoice: "Edge TTS \u97F3\u8272\uFF08\u4E0B\u62C9\u5E38\u7528\uFF0C\u5176\u4F59\u9009\u300C\u81EA\u5B9A\u4E49\u300D\u624B\u52A8\u586B ShortName\uFF09",
982
+ voicePrev: "\u4E0A\u4E00\u97F3\u8272",
983
+ voiceNext: "\u4E0B\u4E00\u97F3\u8272",
984
+ modeBtnToggle: "\u6301\u7EED\u8046\u542C",
985
+ modeBtnHold: "\u6309\u4F4F\u8BF4",
986
+ modeBtnTitle: "\u70B9\u51FB\u5207\u6362\u4EA4\u4E92\u6A21\u5F0F\uFF08\u4FDD\u5B58\u5230\u8BBE\u7F6E\uFF09",
987
+ descVoice: "Edge \u4E91\u7AEF\u97F3\u8272\uFF08\u8FDB\u5165\u65F6\u81EA\u52A8\u52A0\u8F7D\u5FAE\u8F6F\u5168\u90E8\u97F3\u8272\uFF0C\u4E0B\u62C9\u6216 \u25C0\u25B6 \u9009\uFF1B\u7F51\u7EDC\u5F02\u5E38\u56DE\u9000\u5E38\u7528 14 \u4E2A\uFF0C\u4E5F\u53EF\u300C\u81EA\u5B9A\u4E49\u300D\u586B ShortName\uFF09",
988
+ descVoiceLocal: "\u672C\u5730\u97F3\u8272\uFF08vits \u4E94\u4E2A\u8BF4\u8BDD\u4EBA\u5168\u90E8\u5217\u51FA\uFF0C\u4E0B\u62C9\u9009\u6216 \u25C0\u25B6 \u5207\u6362\uFF1B\u65E0\u9700\u81EA\u5B9A\u4E49\uFF09",
989
+ ttsEngine: "\u6717\u8BFB\u5F15\u64CE",
990
+ descTtsEngine: "\u672C\u5730 VITS\uFF08\u7EAF\u4E2D\u6587\uFF09/ \u672C\u5730 Kokoro\uFF08\u4E2D\u82F1\u5747\u53EF\uFF09/ Edge \u4E91\u7AEF\uFF08\u97F3\u8D28\u6700\u81EA\u7136\uFF0C\u6587\u672C\u4E0A\u5FAE\u8F6F\uFF09",
991
+ engineVits: "\u672C\u5730 VITS",
992
+ engineKokoro: "\u672C\u5730\u4E2D\u82F1",
993
+ engineEdge: "Edge \u4E91\u7AEF",
994
+ descVoiceKokoro: "Kokoro \u4E2D\u82F1\u97F3\u8272\uFF08103 \u4E2A\u5168\u90E8\u5217\u51FA\uFF0C\u4E0B\u62C9\u9009\u6216 \u25C0\u25B6 \u5207\u6362\uFF1B48-51 \u4E2D\u6587\u540D\uFF0C\u5176\u4F59\u6309\u7F16\u53F7+\u5B9E\u6D4B\u6027\u522B\u6807\u6CE8\uFF0C\u4E2D\u82F1\u6DF7\u8BFB\u5747\u53EF\uFF09",
567
995
  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",
996
+ 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",
997
+ 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",
998
+ bargeInAuto: "\u81EA\u52A8",
999
+ bargeInManual: "\u624B\u52A8",
1000
+ 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",
1001
+ 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",
1002
+ vadDetected: "VAD \u68C0\u6D4B\u5230\u8BED\u97F3",
1003
+ aecOff: "\u539F\u751F\u56DE\u58F0\u6D88\u9664\u672A\u751F\u6548",
1004
+ 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",
1005
+ interruptConfirm: "\u6253\u65AD\u786E\u8BA4",
569
1006
  sev0: "0 \u9AD8\u95E8\u69DB",
570
1007
  sev1: "1 \u4E2D",
571
1008
  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",
1009
+ 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
1010
  descIdle: "\u65E0\u6D3B\u52A8\u81EA\u52A8\u9000\u51FA\u8BED\u97F3\u6A21\u5F0F\u7684\u5206\u949F\u6570\uFF08\u9ED8\u8BA4 10\uFF09",
574
1011
  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
1012
  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",
576
- 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",
1013
+ 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",
1014
+ descSpokenFormat: "\u8BED\u97F3\u4F1A\u8BDD\u6CE8\u5165\u53E3\u8BED\u5316\u63D0\u793A\u8BCD\uFF08\u56DE\u590D\u53E3\u8BED\u5316\u3001\u77ED\u53E5\u3001\u4E0D\u7528 Markdown \u6392\u7248\u7B26\u53F7\uFF0C\u6717\u8BFB\u66F4\u987A\u66F4\u5FEB\uFF1B\u9ED8\u8BA4\u5F00\uFF0C\u6539\u52A8\u5373\u65F6\u751F\u6548\uFF09",
1015
+ 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",
1016
+ descToolBeep: '\u5DE5\u5177\u8C03\u7528\u63D0\u793A\u97F3\uFF08\u9ED8\u8BA4\u5173\uFF09\uFF1AAI \u601D\u8003/\u8C03\u7528\u5DE5\u5177\u65F6"\u6EF4"\u4E00\u58F0\uFF1B\u5ACC\u5435\u5C31\u4FDD\u6301\u5173\u95ED',
577
1017
  descMode: "\u4EA4\u4E92\u6A21\u5F0F\uFF08toggle \u6301\u7EED\u8046\u542C+\u9759\u97F3\u65AD\u53E5 / hold \u6309\u4F4F\u8BF4\u8BDD\uFF09",
578
1018
  modeToggle: "\u6301\u7EED\u8046\u542C",
579
1019
  modeHold: "\u6309\u4F4F\u8BF4\u8BDD",
580
1020
  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
1021
  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"
1022
+ settingsCardDesc: "\u6717\u8BFB\u5F15\u64CE / \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 / \u5524\u9192\u8BCD / \u53E3\u8BED\u5316\u63D0\u793A\u8BCD",
1023
+ settingsEffectiveNote: "\u6717\u8BFB\u5F15\u64CE / \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 / \u5524\u9192\u8BCD / \u5DE5\u5177\u63D0\u793A\u97F3\uFF09\u4E0B\u6B21\u8FDB\u5165\u8BED\u97F3\u6A21\u5F0F\u65F6\u751F\u6548\u3002",
1024
+ configUnavailable: "\u914D\u7F6E\u6682\u4E0D\u53EF\u7528",
1025
+ // telemetry(P1-5 开发模式延迟埋点状态条:各段耗时标签)
1026
+ telUtteranceEnd: "\u8BF4\u5B8C",
1027
+ telEndpoint: "\u7AEF\u70B9",
1028
+ telSubmitted: "\u5B9A\u7A3F",
1029
+ telFirstToken: "\u9996Token",
1030
+ telFirstSentence: "\u9996\u53E5",
1031
+ telFirstChunk: "\u9996chunk",
1032
+ telFirstPlayed: "\u9996\u97F3",
1033
+ // 模型管理(设置面板实时状态/重试)
1034
+ modelsTitle: "\u8BED\u97F3\u6A21\u578B",
1035
+ modelsDisabled: "\u5DF2\u5173\u95ED\uFF08\u8BBE\u7F6E\u4E2D\u5F00\u542F\uFF09",
1036
+ modelStreamingAsr: "\u6D41\u5F0F\u8BC6\u522B",
1037
+ modelVad: "\u7AEF\u70B9 VAD",
1038
+ modelSense: "\u5B9A\u7A3F\u91CD\u8BD1",
1039
+ modelsReady: "\u5C31\u7EEA",
1040
+ modelsDownloading: "{file} {percent}%",
1041
+ modelsFail: "\u4E0B\u8F7D\u5931\u8D25\uFF08{sec} \u79D2\u540E\u81EA\u52A8\u91CD\u8BD5\uFF09",
1042
+ modelsMissing: "\u672A\u4E0B\u8F7D",
1043
+ modelsRetry: "\u91CD\u8BD5\u4E0B\u8F7D",
1044
+ modelsRetrying: "\u91CD\u8BD5\u4E2D\u2026",
1045
+ modelsRetryHint: "\u955C\u50CF\u5207\u6362\u6216\u4E0B\u8F7D\u5931\u8D25\u540E\u70B9\u51FB\u7ACB\u5373\u91CD\u8BD5",
1046
+ 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",
1047
+ // 朗读引擎状态(设置面板内联展示)
1048
+ engineLoading: "\u52A0\u8F7D\u4E2D\u2026",
1049
+ engineReady: "\u5C31\u7EEA",
1050
+ engineError: "\u52A0\u8F7D\u5931\u8D25",
1051
+ engineIdle: "\u53EF\u8BD5\u542C",
1052
+ ttsModelsMissing: "\u672C\u5730\u6A21\u578B\u672A\u5C31\u7EEA",
1053
+ ttsRedownload: "\u91CD\u65B0\u4E0B\u8F7D",
1054
+ ttsRedownloadHint: "\u6E05\u7406\u8BE5\u5F15\u64CE\u6A21\u578B\u7F13\u5B58\u5E76\u91CD\u65B0\u4E0B\u8F7D",
1055
+ ttsCleaning: "\u6E05\u7406\u4E2D\u2026",
1056
+ // 设置分组标题
1057
+ secRead: "\u6717\u8BFB\u4E0E\u97F3\u8272",
1058
+ secInterrupt: "\u6253\u65AD\u4E0E\u9759\u97F3",
1059
+ secInteraction: "\u4EA4\u4E92\u4E0E\u8F93\u5165",
1060
+ secRecognition: "\u8BC6\u522B\u4E0E\u53E3\u8BED",
1061
+ secModel: "\u6A21\u578B\u4E0E\u955C\u50CF",
1062
+ telTotal: "\u5408\u8BA1"
584
1063
  };
585
1064
  var en = {
586
1065
  stateVoiceMode: "Voice Mode",
@@ -593,6 +1072,7 @@ var en = {
593
1072
  hold: "Hold",
594
1073
  recognizing: "Recognizing\u2026",
595
1074
  holdToTalk: "Hold to talk",
1075
+ releaseToSend: "Release to send",
596
1076
  voiceDetected: "Voice active",
597
1077
  entering: "Entering\u2026",
598
1078
  voiceBtn: "Voice",
@@ -603,15 +1083,18 @@ var en = {
603
1083
  titleEnter: "Enter voice mode (Ctrl+Shift+V)",
604
1084
  loadingModel: "Loading model\u2026",
605
1085
  listening: "Listening\u2026",
606
- wakeWord: "Wake word",
1086
+ thinking: "Thinking\u2026",
607
1087
  barHold: "Voice mode \xB7 hold to talk (tap to exit)",
608
1088
  barListening: "Voice mode \xB7 listening\u2026",
1089
+ wakeWord: "Wake word",
1090
+ sayWake: 'Say "{wake}" to start',
609
1091
  reading: "Reading\u2026",
610
1092
  recognitionFail: "Recognition failed, try again",
1093
+ sessionExpired: "Voice session expired, reconnecting\u2026",
1094
+ sessionExpiredFail: "Voice session reconnect failed; please re-enter voice mode",
611
1095
  modelDownloadFail: "Model download failed ({file}): check network and re-enter voice mode",
612
1096
  startFail: "Voice mode failed to start: {err}",
613
1097
  holdDots: "Hold to talk\u2026",
614
- sayWake: 'Say "{wake}" to start',
615
1098
  exit: "Exit",
616
1099
  skip: "Skip",
617
1100
  configUnavailableNote: " (settings document not ready; the panel will appear when it is).",
@@ -620,28 +1103,90 @@ var en = {
620
1103
  previewPlayFail: "Preview failed: cannot play this voice",
621
1104
  previewAutoplay: "Autoplay blocked \u2014 click preview again",
622
1105
  previewCheck: "Preview failed: check network or ShortName",
1106
+ previewRateLimited: "Preview too frequent \u2014 wait a few seconds (20/min limit)",
1107
+ previewTimeout: "Synthesis timed out: model still loading, retry in a few seconds",
1108
+ previewSynthesisFail: "Synthesis failed",
623
1109
  previewBtnTitle: "Preview voice (current rate)",
624
1110
  synthesizing: "Synthesizing\u2026",
625
1111
  preview: "Preview",
626
1112
  custom: "Custom",
627
- descVoice: "Edge TTS voice (presets, or a custom ShortName)",
1113
+ voicePrev: "Previous voice",
1114
+ voiceNext: "Next voice",
1115
+ modeBtnToggle: "Continuous",
1116
+ modeBtnHold: "Hold to talk",
1117
+ modeBtnTitle: "Click to switch interaction mode (saved to settings)",
1118
+ descVoice: "Edge cloud voices (auto-loads all Microsoft voices on entry; \u25C0\u25B6 or dropdown; falls back to 14 common on network failure; custom ShortName allowed)",
1119
+ descVoiceLocal: "Local voice (vits, all 5 speakers listed; dropdown or \u25C0\u25B6; no custom needed)",
1120
+ ttsEngine: "Read-aloud engine",
1121
+ descTtsEngine: "Local VITS (Chinese only) / Local Kokoro (Chinese + English) / Edge cloud (most natural, text sent to Microsoft)",
1122
+ engineVits: "Local VITS",
1123
+ engineKokoro: "Local zh-en",
1124
+ engineEdge: "Edge cloud",
1125
+ descVoiceKokoro: "Kokoro zh-en voices (103; \u25C0\u25B6 to cycle; 48-51 named Chinese, others numbered with measured gender; mixed zh-en supported)",
628
1126
  descRate: "Speech rate (0.5 slow \u2013 2.0 fast, 1.0 normal)",
629
- descInterrupt: "Interrupt sensitivity (0 high barrier / 2 low)",
1127
+ descInterrupt: "Interrupt sensitivity (0 high barrier / 1 medium / 2 low; ~0.3/0.2/0.1 s speech confirmation)",
1128
+ 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)",
1129
+ bargeInAuto: "Auto",
1130
+ bargeInManual: "Manual",
1131
+ 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",
1132
+ 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)",
1133
+ vadDetected: "VAD speech",
1134
+ aecOff: "Native AEC off",
1135
+ aecOffHint: "Native echo cancellation is not active (speaker echo may self-interrupt); use headphones or Manual barge-in",
1136
+ interruptConfirm: "interrupt confirm",
630
1137
  sev0: "0 high",
631
1138
  sev1: "1 medium",
632
1139
  sev2: "2 low",
633
- descSilence: "Silence pause before a sentence is committed (default 2000 ms)",
1140
+ descSilence: "Silence pause before a sentence is committed (default 700 ms; at least 250 ms of speech required, guards against noise triggers)",
634
1141
  descIdle: "Auto-exit voice mode after idle minutes (default 10)",
635
1142
  descModelHost: "ASR model download source (official source / mirror, or any custom URL)",
636
1143
  descAutoSend: "Auto-send after finalized recognition (off = draft only; Ctrl / hold still sends)",
637
- descSpokenFormat: "Inject spoken-format prompt into voice replies (colloquial, no Markdown; default off, live)",
1144
+ descAutoResume: "Auto-resume voice mode when switching back to the last voice session (default off, requires granted mic permission)",
1145
+ descSpokenFormat: "Inject spoken-format prompt into voice replies (colloquial, short sentences, no Markdown; default on, live)",
1146
+ 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)",
1147
+ descToolBeep: "Tool-call beep (default off): beep when the agent is thinking/calling tools; keep off if it annoys you",
638
1148
  descMode: "Interaction mode (toggle: continuous listen + auto-send / hold: press to talk)",
639
1149
  modeToggle: "Continue listen",
640
1150
  modeHold: "Hold to talk",
641
1151
  descWakeWord: "Wake word (default off; e.g. Hey D)",
642
1152
  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"
1153
+ settingsCardDesc: "Engine / voice / rate / interrupt / barge-in / silence / idle / model host / auto-send / mode / wake word / spoken format",
1154
+ settingsEffectiveNote: "Engine / voice / rate / spoken format / re-transcribe apply immediately; the rest (interrupt / barge-in / echo gate / shortcut / silence / idle / mirror / auto-send / auto-resume / mode / wake word / tool beep) apply next time you enter voice mode.",
1155
+ configUnavailable: "Configuration unavailable",
1156
+ telUtteranceEnd: "end",
1157
+ telEndpoint: "endpoint",
1158
+ telSubmitted: "submit",
1159
+ telFirstToken: "1st token",
1160
+ telFirstSentence: "1st sentence",
1161
+ telFirstChunk: "1st chunk",
1162
+ telFirstPlayed: "1st audio",
1163
+ modelsTitle: "Voice models",
1164
+ modelsDisabled: "off (enable in settings)",
1165
+ modelStreamingAsr: "Streaming ASR",
1166
+ modelVad: "Endpoint VAD",
1167
+ modelSense: "Finalize",
1168
+ modelsReady: "Ready",
1169
+ modelsDownloading: "{file} {percent}%",
1170
+ modelsFail: "Download failed (auto-retry in {sec}s)",
1171
+ modelsMissing: "not downloaded",
1172
+ modelsRetry: "Retry",
1173
+ modelsRetrying: "Retrying\u2026",
1174
+ modelsRetryHint: "Click to retry now after switching mirror or a failure",
1175
+ modelsHint: "Live download state; failures auto-backoff 60s. After switching the mirror, click Retry to take effect immediately; npm run prefetch pre-downloads.",
1176
+ engineLoading: "loading\u2026",
1177
+ engineReady: "ready",
1178
+ engineError: "failed",
1179
+ engineIdle: "ready to preview",
1180
+ ttsModelsMissing: "local models missing",
1181
+ ttsRedownload: "Re-download",
1182
+ ttsRedownloadHint: "Clear this engine model cache and re-download",
1183
+ ttsCleaning: "Clearing\u2026",
1184
+ secRead: "Reading & voice",
1185
+ secInterrupt: "Interrupt & silence",
1186
+ secInteraction: "Interaction",
1187
+ secRecognition: "Recognition & speech",
1188
+ secModel: "Model & mirror",
1189
+ telTotal: "total"
645
1190
  };
646
1191
  var guess = () => /^zh\b/i.test(
647
1192
  typeof document !== "undefined" && document.documentElement.lang || (typeof navigator !== "undefined" ? navigator.language : "") || ""
@@ -666,6 +1211,25 @@ var cardStyle = {
666
1211
  borderRadius: 12,
667
1212
  overflow: "hidden"
668
1213
  };
1214
+ var FIELD_LABELS = {
1215
+ ttsEngine: "\u6717\u8BFB\u5F15\u64CE",
1216
+ voice: "\u97F3\u8272",
1217
+ rate: "\u8BED\u901F",
1218
+ interruptLevel: "\u6253\u65AD\u7075\u654F\u5EA6",
1219
+ bargeInMode: "\u6253\u65AD\u65B9\u5F0F",
1220
+ echoGateDb: "\u56DE\u58F0\u95E8\u63A7",
1221
+ mode: "\u4EA4\u4E92\u6A21\u5F0F",
1222
+ shortcut: "\u5FEB\u6377\u952E",
1223
+ wakeWord: "\u5524\u9192\u8BCD",
1224
+ toolBeep: "\u5DE5\u5177\u63D0\u793A\u97F3",
1225
+ autoSend: "\u81EA\u52A8\u53D1\u9001",
1226
+ autoResume: "\u81EA\u52A8\u6062\u590D",
1227
+ senseVoice: "\u5B9A\u7A3F\u91CD\u8BD1",
1228
+ spokenFormat: "\u53E3\u8BED\u5316\u63D0\u793A\u8BCD",
1229
+ silenceMs: "\u9759\u97F3\u505C\u987F",
1230
+ idleTimeoutMinutes: "\u7A7A\u95F2\u8D85\u65F6",
1231
+ modelHost: "\u6A21\u578B\u955C\u50CF"
1232
+ };
669
1233
  var setHeader = {
670
1234
  appearance: "none",
671
1235
  width: "100%",
@@ -742,6 +1306,149 @@ var VOICE_OPTIONS = [
742
1306
  { v: "en-US-AriaNeural", label: "Aria \xB7 \u5973 \xB7 English" },
743
1307
  { v: "en-US-GuyNeural", label: "Guy \xB7 \u7537 \xB7 English" }
744
1308
  ];
1309
+ var VOICE_OPTIONS_LOCAL = [
1310
+ { v: "suyingxue", label: "\u7D20\u6620\u96EA \xB7 \u5973" },
1311
+ { v: "gunian", label: "\u987E\u5FF5 \xB7 \u7537" },
1312
+ { v: "fushiyu", label: "\u5085\u65AF\u9047 \xB7 \u5973" },
1313
+ { v: "bingjiao", label: "\u51B0\u5A07 \xB7 \u7537" },
1314
+ { v: "bazong", label: "\u9738\u603B \xB7 \u7537" }
1315
+ ];
1316
+ var KOKORO_F0 = [
1317
+ 224,
1318
+ 189,
1319
+ 154,
1320
+ 261,
1321
+ 226,
1322
+ 222,
1323
+ 220,
1324
+ 229,
1325
+ 198,
1326
+ 186,
1327
+ 212,
1328
+ 293,
1329
+ 233,
1330
+ 161,
1331
+ 247,
1332
+ 207,
1333
+ 218,
1334
+ 216,
1335
+ 220,
1336
+ 238,
1337
+ 242,
1338
+ 229,
1339
+ 198,
1340
+ 286,
1341
+ 211,
1342
+ 190,
1343
+ 264,
1344
+ 261,
1345
+ 226,
1346
+ 147,
1347
+ 216,
1348
+ 240,
1349
+ 233,
1350
+ 188,
1351
+ 222,
1352
+ 247,
1353
+ 253,
1354
+ 270,
1355
+ 276,
1356
+ 276,
1357
+ 279,
1358
+ 320,
1359
+ 247,
1360
+ 296,
1361
+ 276,
1362
+ 235,
1363
+ 139,
1364
+ 240,
1365
+ 282,
1366
+ 282,
1367
+ 238,
1368
+ 226,
1369
+ 273,
1370
+ 216,
1371
+ 286,
1372
+ 270,
1373
+ 198,
1374
+ 179,
1375
+ 117,
1376
+ 130,
1377
+ 114,
1378
+ 128,
1379
+ 108,
1380
+ 106,
1381
+ 122,
1382
+ 136,
1383
+ 190,
1384
+ 112,
1385
+ 108,
1386
+ 128,
1387
+ 131,
1388
+ 111,
1389
+ 110,
1390
+ 132,
1391
+ 138,
1392
+ 189,
1393
+ 137,
1394
+ 148,
1395
+ 151,
1396
+ 127,
1397
+ 135,
1398
+ 111,
1399
+ 138,
1400
+ 114,
1401
+ 125,
1402
+ 158,
1403
+ 128,
1404
+ 156,
1405
+ 132,
1406
+ 162,
1407
+ 131,
1408
+ 136,
1409
+ 142,
1410
+ 124,
1411
+ 129,
1412
+ 136,
1413
+ 126,
1414
+ 135,
1415
+ 161,
1416
+ 150,
1417
+ 124,
1418
+ 104,
1419
+ 124
1420
+ ];
1421
+ var KOKORO_NAMED = {
1422
+ 48: { v: "zf_xiaobei", label: "\u5C0F\u5317 \xB7 \u4E2D\u6587\u5973" },
1423
+ 49: { v: "zf_xiaoni", label: "\u5C0F\u59AE \xB7 \u4E2D\u6587\u5973" },
1424
+ 50: { v: "zf_xiaoxiao", label: "\u5C0F\u5C0F \xB7 \u4E2D\u6587\u5973" },
1425
+ 51: { v: "zf_xiaoyi", label: "\u5C0F\u827A \xB7 \u4E2D\u6587\u5973" }
1426
+ };
1427
+ var KOKORO_LABEL_OVERRIDES = {
1428
+ 62: "62 \xB7 \u6DF1\u6C89 \xB7 \u5E38\u7528\u7537\u58F0",
1429
+ 68: "68 \xB7 \u6D51\u539A \xB7 \u5E38\u7528\u7537\u58F0",
1430
+ 75: "75 \xB7 \u6E05\u4EAE \xB7 \u5E38\u7528\u7537\u58F0",
1431
+ 76: "76 \xB7 \u78C1\u6027 \xB7 \u5E38\u7528\u7537\u58F0"
1432
+ };
1433
+ var KOKORO_PINNED = [62, 68, 75, 76];
1434
+ function kokoroOption(sid) {
1435
+ const custom = KOKORO_LABEL_OVERRIDES[sid];
1436
+ if (custom) return { v: String(sid), label: custom };
1437
+ const named = KOKORO_NAMED[sid];
1438
+ if (named) return { v: named.v, label: named.label };
1439
+ const hz = KOKORO_F0[sid] ?? null;
1440
+ if (hz === null) return { v: String(sid), label: `${sid} \xB7 \u97F3\u8272` };
1441
+ return { v: String(sid), label: `${sid} \xB7 ${hz < 180 ? "\u7537\u58F0" : "\u5973\u58F0"} \xB7 ${hz}Hz` };
1442
+ }
1443
+ var VOICE_OPTIONS_KOKORO = [
1444
+ ...KOKORO_PINNED.map((sid) => kokoroOption(sid)),
1445
+ ...KOKORO_F0.map((_, sid) => kokoroOption(sid)).filter((o) => !KOKORO_PINNED.includes(Number(o.v)))
1446
+ ];
1447
+ var ENGINE_DEFAULT_VOICE = {
1448
+ vits: "fushiyu",
1449
+ kokoro: "zf_xiaoni",
1450
+ edge: "zh-CN-XiaoxiaoNeural"
1451
+ };
745
1452
  var HOST_OPTIONS = [
746
1453
  { v: "https://huggingface.co", label: "\u5B98\u65B9\u6E90 huggingface.co" },
747
1454
  { v: "https://hf-mirror.com", label: "\u56FD\u5185\u955C\u50CF hf-mirror.com" }
@@ -869,6 +1576,100 @@ function SelectField({
869
1576
  footer?.(inOptions ? cur : custom)
870
1577
  ] });
871
1578
  }
1579
+ var stepBtn = {
1580
+ boxSizing: "border-box",
1581
+ width: 36,
1582
+ flex: "0 0 auto",
1583
+ cursor: "pointer",
1584
+ border: `1px solid ${t2.border}`,
1585
+ borderRadius: 8,
1586
+ background: "var(--dsw-alias-bg-layer-2)",
1587
+ color: t2.label,
1588
+ fontSize: 16,
1589
+ lineHeight: "28px",
1590
+ textAlign: "center",
1591
+ padding: 0,
1592
+ fontFamily: "inherit"
1593
+ };
1594
+ function VoiceSelect({
1595
+ score,
1596
+ field,
1597
+ value,
1598
+ options,
1599
+ placeholder,
1600
+ footer,
1601
+ showCustom = true
1602
+ }) {
1603
+ const cur = String(value ?? "");
1604
+ const inOptions = options.some((o) => o.v === cur);
1605
+ const idx = options.findIndex((o) => o.v === cur);
1606
+ const [custom, setCustom] = (0, import_react.useState)(inOptions ? "" : cur);
1607
+ (0, import_react.useEffect)(() => {
1608
+ if (!options.some((o) => o.v === cur)) setCustom(cur);
1609
+ }, [cur, options]);
1610
+ const move = (delta) => {
1611
+ if (options.length === 0) return;
1612
+ if (inOptions) {
1613
+ const n = options.length;
1614
+ const next = options[((idx + delta) % n + n) % n];
1615
+ void score.set(field, next.v);
1616
+ } else {
1617
+ void score.set(field, options[0].v);
1618
+ }
1619
+ };
1620
+ const selectStyle = {
1621
+ ...inputStyle,
1622
+ appearance: "none",
1623
+ cursor: "pointer",
1624
+ backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")`,
1625
+ backgroundPosition: "right 12px center",
1626
+ backgroundRepeat: "no-repeat",
1627
+ backgroundSize: "12px 12px",
1628
+ paddingRight: 32
1629
+ };
1630
+ const label = inOptions ? options[idx].label : custom || placeholder || "";
1631
+ const selectValue = inOptions ? cur : showCustom ? "__custom__" : options[0]?.v ?? "";
1632
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: { display: "flex", flexDirection: "column", gap: 6, width: 320, maxWidth: "100%", alignItems: "stretch" }, children: [
1633
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: { display: "flex", gap: 6, alignItems: "center" }, children: [
1634
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", "aria-label": t("voicePrev"), onClick: () => move(-1), style: stepBtn, children: "\u2039" }),
1635
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
1636
+ "select",
1637
+ {
1638
+ style: selectStyle,
1639
+ value: selectValue,
1640
+ "aria-label": label,
1641
+ onChange: (e) => {
1642
+ const v = e.target.value;
1643
+ if (v === "__custom__") setCustom(inOptions ? "" : custom);
1644
+ else void score.set(field, v);
1645
+ },
1646
+ children: [
1647
+ options.map((o) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: o.v, children: o.label }, o.v)),
1648
+ showCustom && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("option", { value: "__custom__", children: [
1649
+ t("custom"),
1650
+ "\u2026"
1651
+ ] })
1652
+ ]
1653
+ }
1654
+ ),
1655
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", "aria-label": t("voiceNext"), onClick: () => move(1), style: stepBtn, children: "\u203A" })
1656
+ ] }),
1657
+ showCustom && !inOptions && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1658
+ "input",
1659
+ {
1660
+ style: inputStyle,
1661
+ value: custom,
1662
+ placeholder,
1663
+ onChange: (e) => setCustom(e.target.value),
1664
+ onBlur: () => void score.set(field, custom),
1665
+ onKeyDown: (e) => {
1666
+ if (e.key === "Enter") void score.set(field, custom);
1667
+ }
1668
+ }
1669
+ ),
1670
+ footer?.(inOptions ? cur : showCustom ? custom : cur)
1671
+ ] });
1672
+ }
872
1673
  function VoicePreviewButton({ voice, rate }) {
873
1674
  const [busy, setBusy] = (0, import_react.useState)(false);
874
1675
  const [note, setNote] = (0, import_react.useState)(null);
@@ -895,13 +1696,26 @@ function VoicePreviewButton({ voice, rate }) {
895
1696
  method: "POST",
896
1697
  headers: { "content-type": "application/json" },
897
1698
  body: JSON.stringify({ voice: v, rate }),
898
- signal: AbortSignal.timeout(15e3)
1699
+ signal: AbortSignal.timeout(9e4)
899
1700
  });
900
1701
  if (res.status === 403) {
901
1702
  setNote(t("previewDisabled"));
902
1703
  return;
903
1704
  }
904
- if (!res.ok) throw new Error(`preview http ${res.status}`);
1705
+ if (res.status === 429) {
1706
+ setNote(t("previewRateLimited"));
1707
+ return;
1708
+ }
1709
+ if (!res.ok) {
1710
+ let detail = "";
1711
+ try {
1712
+ const parsed = await res.json();
1713
+ if (parsed && typeof parsed.error === "string") detail = parsed.error;
1714
+ } catch {
1715
+ }
1716
+ setNote(detail ? `${t("previewSynthesisFail")}\uFF1A${detail}` : t("previewCheck"));
1717
+ return;
1718
+ }
905
1719
  const blob = await res.blob();
906
1720
  const url = URL.createObjectURL(blob);
907
1721
  audio.src = url;
@@ -918,8 +1732,8 @@ function VoicePreviewButton({ voice, rate }) {
918
1732
  e instanceof DOMException && e.name === "NotAllowedError" ? t("previewAutoplay") : t("previewPlayFail")
919
1733
  );
920
1734
  }
921
- } catch {
922
- setNote(t("previewCheck"));
1735
+ } catch (e) {
1736
+ setNote(e instanceof DOMException && e.name === "TimeoutError" ? t("previewTimeout") : t("previewCheck"));
923
1737
  } finally {
924
1738
  setBusy(false);
925
1739
  }
@@ -949,41 +1763,264 @@ function VoicePreviewButton({ voice, rate }) {
949
1763
  ] });
950
1764
  }
951
1765
  function Row({ name, desc, children }) {
1766
+ const label = FIELD_LABELS[name] ?? name;
952
1767
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: setRow, children: [
953
1768
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: setLabelBox, children: [
954
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: setLabel, children: name }),
1769
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: setLabel, children: label }),
955
1770
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: setHint, children: desc })
956
1771
  ] }),
957
1772
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { flexShrink: 0, maxWidth: 300 }, children })
958
1773
  ] });
959
1774
  }
1775
+ function Section({ title, children }) {
1776
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { marginTop: 2 }, children: [
1777
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "flex", alignItems: "center", gap: 8, padding: "10px 0 2px" }, children: [
1778
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { fontSize: 11, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: "var(--dsw-alias-label-secondary)" }, children: title }),
1779
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { flex: 1, height: 1, background: t2.border } })
1780
+ ] }),
1781
+ children
1782
+ ] });
1783
+ }
960
1784
  function SegGroup({
961
1785
  score,
962
1786
  field,
963
1787
  value,
964
- options
1788
+ options,
1789
+ onSelect
965
1790
  }) {
966
- 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))) });
1791
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { role: "group", style: setSeg, children: options.map((o) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1792
+ "button",
1793
+ {
1794
+ style: setSegBtn(value === o.v),
1795
+ "aria-pressed": value === o.v,
1796
+ onClick: () => {
1797
+ void score.set(field, o.v);
1798
+ onSelect?.(o.v);
1799
+ },
1800
+ children: o.label
1801
+ },
1802
+ String(o.v)
1803
+ )) });
967
1804
  }
968
- function VoiceSettingsCard({ scope }) {
969
- const [snap, setSnap] = (0, import_react.useState)(() => scope.getSnapshot());
970
- const [collapsed, setCollapsed] = (0, import_react.useState)(true);
971
- (0, import_react.useEffect)(
972
- () => scope.subscribe(() => {
973
- setSnap({ ...scope.getSnapshot() });
974
- }),
975
- [scope]
976
- );
977
- const value = snap?.value ?? {};
978
- const unavailable = snap?.status === "unavailable" || snap?.status === "error";
979
- if (unavailable) {
980
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { "data-dshvm-settings": "card", style: { color: t2.term, fontSize: 12, padding: "14px 16px", ...cardStyle }, children: [
981
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { color: "var(--dsw-alias-state-error-primary)" }, children: t("configUnavailable") }),
982
- t("configUnavailableNote")
983
- ] });
1805
+ var fmtMB = (b) => b >= 1048576 ? `${(b / 1048576).toFixed(0)}MB` : b > 0 ? `${Math.round(b / 1024)}KB` : "\u2013";
1806
+ function EngineStatusInline() {
1807
+ const [st, setSt] = (0, import_react.useState)(null);
1808
+ const [cleaning, setCleaning] = (0, import_react.useState)(false);
1809
+ (0, import_react.useEffect)(() => {
1810
+ let alive = true;
1811
+ const poll = async () => {
1812
+ try {
1813
+ const res = await fetch(location.origin + BASE_PATH + "/models/status");
1814
+ if (res.ok && alive) setSt(await res.json());
1815
+ } catch {
1816
+ }
1817
+ };
1818
+ void poll();
1819
+ const timer = setInterval(() => void poll(), 3e3);
1820
+ return () => {
1821
+ alive = false;
1822
+ clearInterval(timer);
1823
+ };
1824
+ }, []);
1825
+ const clean = (engine) => {
1826
+ setCleaning(true);
1827
+ void fetch(location.origin + BASE_PATH + "/models/clean", {
1828
+ method: "POST",
1829
+ headers: { "content-type": "application/json" },
1830
+ body: JSON.stringify({ engine })
1831
+ }).catch(() => void 0).finally(() => setTimeout(() => setCleaning(false), 1500));
1832
+ };
1833
+ const tts = st?.tts;
1834
+ if (!tts) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_jsx_runtime.Fragment, {});
1835
+ const engineName = tts.engine === "vits" ? t("engineVits") : tts.engine === "kokoro" ? t("engineKokoro") : t("engineEdge");
1836
+ const isLocal = !!tts.local;
1837
+ let statusText;
1838
+ let statusColor;
1839
+ if (tts.loading) {
1840
+ statusText = t("engineLoading");
1841
+ statusColor = t2.term;
1842
+ } else if (tts.ready) {
1843
+ statusText = t("engineReady");
1844
+ statusColor = "var(--dsw-alias-state-success-primary)";
1845
+ } else if (tts.error) {
1846
+ statusText = t("engineError");
1847
+ statusColor = "var(--dsw-alias-state-error-primary)";
1848
+ } else if (isLocal && !tts.local.ready) {
1849
+ statusText = t("ttsModelsMissing");
1850
+ statusColor = "var(--dsw-alias-state-error-primary)";
1851
+ } else {
1852
+ statusText = t("engineIdle");
1853
+ statusColor = t2.term;
984
1854
  }
985
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { "data-dshvm-settings": "card", style: cardStyle, children: [
986
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("style", { children: focusVisibleCss }),
1855
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "flex", alignItems: "center", flexWrap: "wrap", gap: 8, padding: "2px 0 10px" }, children: [
1856
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { fontSize: 12, color: t2.term }, children: engineName }),
1857
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { fontSize: 12, fontWeight: tts.ready || tts.error ? 600 : 400, color: statusColor }, children: statusText }),
1858
+ tts.loading && st?.progress?.file && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: { fontSize: 12, color: t2.term }, children: [
1859
+ st.progress.file,
1860
+ " ",
1861
+ st.progress.percent,
1862
+ "%"
1863
+ ] }),
1864
+ isLocal && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1865
+ "button",
1866
+ {
1867
+ type: "button",
1868
+ onClick: () => clean(tts.engine),
1869
+ disabled: cleaning || tts.loading,
1870
+ style: {
1871
+ font: "inherit",
1872
+ fontSize: 12,
1873
+ cursor: tts.loading ? "default" : "pointer",
1874
+ color: t2.label,
1875
+ background: "var(--dsw-alias-bg-layer-2)",
1876
+ border: `1px solid ${t2.border}`,
1877
+ borderRadius: 8,
1878
+ padding: "3px 10px",
1879
+ flexShrink: 0
1880
+ },
1881
+ title: t("ttsRedownloadHint"),
1882
+ children: cleaning ? t("ttsCleaning") : t("ttsRedownload")
1883
+ }
1884
+ ),
1885
+ tts.error && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { fontSize: 11, color: "var(--dsw-alias-state-error-primary)", flexBasis: "100%" }, children: tts.error })
1886
+ ] });
1887
+ }
1888
+ function ModelStatusView() {
1889
+ const [st, setSt] = (0, import_react.useState)(null);
1890
+ const [retrying, setRetrying] = (0, import_react.useState)(null);
1891
+ (0, import_react.useEffect)(() => {
1892
+ let alive = true;
1893
+ const poll = async () => {
1894
+ try {
1895
+ const res = await fetch(`${location.origin}${BASE_PATH}/models/status`);
1896
+ if (res.ok && alive) setSt(await res.json());
1897
+ } catch {
1898
+ }
1899
+ };
1900
+ void poll();
1901
+ const timer = setInterval(() => void poll(), 3e3);
1902
+ return () => {
1903
+ alive = false;
1904
+ clearInterval(timer);
1905
+ };
1906
+ }, []);
1907
+ const retry = (kind) => {
1908
+ setRetrying(kind);
1909
+ void fetch(`${location.origin}${BASE_PATH}/models/retry`, {
1910
+ method: "POST",
1911
+ headers: { "content-type": "application/json" },
1912
+ body: JSON.stringify({ kind })
1913
+ }).catch(() => void 0).finally(() => {
1914
+ setTimeout(() => setRetrying(null), 2e3);
1915
+ });
1916
+ };
1917
+ const mkRow = (label, info, key, progressFor) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "flex", alignItems: "center", gap: 10, padding: "6px 0" }, children: [
1918
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { width: 92, flexShrink: 0, fontSize: 12, color: t2.label }, children: label }),
1919
+ /* @__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: [
1920
+ t("modelsDownloading").replace("{file}", progressFor.file).replace("{percent}", String(progressFor.percent)),
1921
+ /* @__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" } }) })
1922
+ ] }) : 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: [
1923
+ fmtMB(info.size),
1924
+ t("modelsMissing")
1925
+ ] }) }),
1926
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1927
+ "button",
1928
+ {
1929
+ type: "button",
1930
+ disabled: retrying === key || info.ready || !!info.disabledText,
1931
+ onClick: () => retry(key),
1932
+ style: {
1933
+ font: "inherit",
1934
+ fontSize: 12,
1935
+ cursor: info.ready ? "default" : "pointer",
1936
+ color: info.ready ? t2.term : t2.label,
1937
+ background: "var(--dsw-alias-bg-layer-2)",
1938
+ border: `1px solid ${t2.border}`,
1939
+ borderRadius: 8,
1940
+ padding: "3px 10px",
1941
+ opacity: info.ready || info.disabledText ? 0.5 : 1,
1942
+ flexShrink: 0
1943
+ },
1944
+ title: t("modelsRetryHint"),
1945
+ children: retrying === key ? t("modelsRetrying") : t("modelsRetry")
1946
+ }
1947
+ )
1948
+ ] });
1949
+ const anyDownloading = !!st?.progress;
1950
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { marginTop: 4 }, children: [
1951
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "flex", alignItems: "center", gap: 8, padding: "8px 0" }, children: [
1952
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { fontSize: 13, fontWeight: 600, color: t2.label }, children: t("modelsTitle") }),
1953
+ anyDownloading && st?.progress && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: { fontSize: 12, color: t2.term }, children: [
1954
+ st.progress.file,
1955
+ " ",
1956
+ st.progress.percent,
1957
+ "%"
1958
+ ] })
1959
+ ] }),
1960
+ mkRow(
1961
+ t("modelStreamingAsr"),
1962
+ { ready: !!st?.asr.ready, size: st?.asr.files.reduce((a, f) => a + f.size, 0) ?? 0, failLatchMs: st?.asr.failLatchMs ?? 0 },
1963
+ "asr",
1964
+ anyDownloading ? st.progress : null
1965
+ ),
1966
+ mkRow(t("modelVad"), { ready: !!st?.vad.ready, size: st?.vad.size ?? 0, failLatchMs: st?.vad.failLatchMs ?? 0 }, "vad", anyDownloading ? st.progress : null),
1967
+ mkRow(
1968
+ t("modelSense"),
1969
+ {
1970
+ ready: !!st?.sense.ready,
1971
+ size: st?.sense.size ?? 0,
1972
+ failLatchMs: st?.sense.enabled ? st?.sense.failLatchMs ?? 0 : 0,
1973
+ disabledText: st?.sense.enabled ? void 0 : t("modelsDisabled")
1974
+ },
1975
+ "sense",
1976
+ anyDownloading ? st.progress : null
1977
+ ),
1978
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { fontSize: 12, color: t2.term, lineHeight: "18px", padding: "4px 0 8px" }, children: t("modelsHint") })
1979
+ ] });
1980
+ }
1981
+ function VoiceSettingsCard({ scope }) {
1982
+ const [snap, setSnap] = (0, import_react.useState)(() => scope.getSnapshot());
1983
+ const [collapsed, setCollapsed] = (0, import_react.useState)(true);
1984
+ (0, import_react.useEffect)(
1985
+ () => scope.subscribe(() => {
1986
+ setSnap({ ...scope.getSnapshot() });
1987
+ }),
1988
+ [scope]
1989
+ );
1990
+ const value = snap?.value ?? {};
1991
+ const unavailable = snap?.status === "unavailable" || snap?.status === "error";
1992
+ const engine = value.ttsEngine === "edge" ? "edge" : value.ttsEngine === "kokoro" ? "kokoro" : "vits";
1993
+ const [edgeVoices, setEdgeVoices] = (0, import_react.useState)(null);
1994
+ (0, import_react.useEffect)(() => {
1995
+ if (engine !== "edge") return;
1996
+ let alive = true;
1997
+ void fetch(location.origin + BASE_PATH + "/voices").then((res) => res.ok ? res.json() : null).then((data) => {
1998
+ if (!alive || !data?.voices) return;
1999
+ const localeName = (lk) => {
2000
+ const m = /^([a-z]{2,3})(?:-[A-Z]{2})?/.exec(lk);
2001
+ return m ? m[1].toUpperCase() : lk;
2002
+ };
2003
+ const genderName = (g) => g === "Female" ? "\u5973" : g === "Male" ? "\u7537" : g === "Neutral" ? "\u4E2D\u6027" : g;
2004
+ setEdgeVoices(
2005
+ data.voices.map((v) => ({
2006
+ v: v.ShortName,
2007
+ label: v.FriendlyName.replace(/ Microsoft.*/, "") + " \xB7 " + genderName(v.Gender) + " \xB7 " + localeName(v.Locale) + " (" + v.ShortName + ")"
2008
+ })).sort((a, b) => a.label.localeCompare(b.label))
2009
+ );
2010
+ }).catch(() => void 0);
2011
+ return () => {
2012
+ alive = false;
2013
+ };
2014
+ }, [engine]);
2015
+ const voiceOptions = engine === "edge" ? edgeVoices ?? VOICE_OPTIONS : engine === "kokoro" ? VOICE_OPTIONS_KOKORO : VOICE_OPTIONS_LOCAL;
2016
+ if (unavailable) {
2017
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { "data-dshvm-settings": "card", style: { color: t2.term, fontSize: 12, padding: "14px 16px", ...cardStyle }, children: [
2018
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { color: "var(--dsw-alias-state-error-primary)" }, children: t("configUnavailable") }),
2019
+ t("configUnavailableNote")
2020
+ ] });
2021
+ }
2022
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { "data-dshvm-settings": "card", style: cardStyle, children: [
2023
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("style", { children: focusVisibleCss }),
987
2024
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("button", { type: "button", "aria-expanded": !collapsed, onClick: () => setCollapsed((c) => !c), style: { ...setHeader, background: collapsed ? "transparent" : t2.bgOpen }, children: [
988
2025
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: setHeadText, children: [
989
2026
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: setName, children: t("stateVoiceMode") }),
@@ -992,59 +2029,205 @@ function VoiceSettingsCard({ scope }) {
992
2029
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { ...setChevron, transform: collapsed ? "rotate(0deg)" : "rotate(180deg)" }, "aria-hidden": "true", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", { viewBox: "0 0 16 16", width: 14, height: 14, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { fill: "currentColor", d: "M4 6l4 4 4-4z" }) }) })
993
2030
  ] }),
994
2031
  !collapsed && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: setBody, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { marginTop: 4 }, children: [
995
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "voice", desc: t("descVoice"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
996
- SelectField,
997
- {
998
- score: scope,
999
- field: "voice",
1000
- value: value.voice ?? "",
1001
- options: VOICE_OPTIONS,
1002
- placeholder: "zh-CN-XiaoxiaoNeural",
1003
- footer: (v) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(VoicePreviewButton, { voice: v, rate: Number(value.rate ?? 1) })
1004
- }
1005
- ) }),
1006
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "rate", desc: t("descRate"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NumberField, { score: scope, field: "rate", value: value.rate ?? 1, min: 0.5, max: 2, step: 0.1 }) }),
1007
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "interruptLevel", desc: t("descInterrupt"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1008
- SegGroup,
1009
- {
1010
- score: scope,
1011
- field: "interruptLevel",
1012
- value: value.interruptLevel,
1013
- options: [
1014
- { v: 0, label: t("sev0") },
1015
- { v: 1, label: t("sev1") },
1016
- { v: 2, label: t("sev2") }
1017
- ]
1018
- }
1019
- ) }),
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 }) }),
1021
- /* @__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
- /* @__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
- /* @__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) }) }),
1024
- /* @__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) }) }),
1025
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "mode", desc: t("descMode"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1026
- SegGroup,
1027
- {
1028
- score: scope,
1029
- field: "mode",
1030
- value: value.mode,
1031
- options: [
1032
- { v: "toggle", label: t("modeToggle") },
1033
- { v: "hold", label: t("modeHold") }
1034
- ]
1035
- }
1036
- ) }),
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") }) })
2032
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Section, { title: t("secRead"), children: [
2033
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "ttsEngine", desc: t("descTtsEngine"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
2034
+ SegGroup,
2035
+ {
2036
+ score: scope,
2037
+ field: "ttsEngine",
2038
+ value: engine,
2039
+ options: [
2040
+ { v: "vits", label: t("engineVits") },
2041
+ { v: "kokoro", label: t("engineKokoro") },
2042
+ { v: "edge", label: t("engineEdge") }
2043
+ ],
2044
+ onSelect: (v) => {
2045
+ if (v !== engine) {
2046
+ void scope.set("voice", ENGINE_DEFAULT_VOICE[String(v)] ?? "zh-CN-XiaoxiaoNeural");
2047
+ }
2048
+ }
2049
+ }
2050
+ ) }),
2051
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(EngineStatusInline, {}),
2052
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
2053
+ Row,
2054
+ {
2055
+ name: "voice",
2056
+ desc: engine === "edge" ? t("descVoice") : engine === "kokoro" ? t("descVoiceKokoro") : t("descVoiceLocal"),
2057
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
2058
+ VoiceSelect,
2059
+ {
2060
+ score: scope,
2061
+ field: "voice",
2062
+ value: value.voice ?? "",
2063
+ options: voiceOptions,
2064
+ placeholder: ENGINE_DEFAULT_VOICE[engine] ?? "zh-CN-XiaoxiaoNeural",
2065
+ showCustom: engine === "edge",
2066
+ footer: (v) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(VoicePreviewButton, { voice: v, rate: Number(value.rate ?? 1) })
2067
+ }
2068
+ )
2069
+ }
2070
+ ),
2071
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "rate", desc: t("descRate"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NumberField, { score: scope, field: "rate", value: value.rate ?? 1, min: 0.5, max: 2, step: 0.1 }) })
2072
+ ] }),
2073
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Section, { title: t("secInterrupt"), children: [
2074
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "interruptLevel", desc: t("descInterrupt"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
2075
+ SegGroup,
2076
+ {
2077
+ score: scope,
2078
+ field: "interruptLevel",
2079
+ value: value.interruptLevel,
2080
+ options: [
2081
+ { v: 0, label: t("sev0") },
2082
+ { v: 1, label: t("sev1") },
2083
+ { v: 2, label: t("sev2") }
2084
+ ]
2085
+ }
2086
+ ) }),
2087
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "bargeInMode", desc: t("descBargeIn"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
2088
+ SegGroup,
2089
+ {
2090
+ score: scope,
2091
+ field: "bargeInMode",
2092
+ value: value.bargeInMode,
2093
+ options: [
2094
+ { v: "auto", label: t("bargeInAuto") },
2095
+ { v: "manual", label: t("bargeInManual") }
2096
+ ]
2097
+ }
2098
+ ) }),
2099
+ /* @__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 }) })
2100
+ ] }),
2101
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Section, { title: t("secInteraction"), children: [
2102
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "mode", desc: t("descMode"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
2103
+ SegGroup,
2104
+ {
2105
+ score: scope,
2106
+ field: "mode",
2107
+ value: value.mode,
2108
+ options: [
2109
+ { v: "toggle", label: t("modeToggle") },
2110
+ { v: "hold", label: t("modeHold") }
2111
+ ]
2112
+ }
2113
+ ) }),
2114
+ /* @__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" }) }),
2115
+ /* @__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") }) }),
2116
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "toolBeep", desc: t("descToolBeep"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { type: "checkbox", checked: Boolean(value.toolBeep), onChange: (e) => void scope.set("toolBeep", e.target.checked) }) }),
2117
+ /* @__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) }) }),
2118
+ /* @__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) }) })
2119
+ ] }),
2120
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Section, { title: t("secRecognition"), children: [
2121
+ /* @__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) }) }),
2122
+ /* @__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) }) }),
2123
+ /* @__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 }) }),
2124
+ /* @__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 }) })
2125
+ ] }),
2126
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Section, { title: t("secModel"), children: /* @__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://..." }) }) }),
2127
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { fontSize: 12, color: t2.term, lineHeight: "18px", padding: "4px 0 8px" }, children: t("settingsEffectiveNote") }),
2128
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ModelStatusView, {})
1038
2129
  ] }) })
1039
2130
  ] });
1040
2131
  }
1041
2132
 
1042
2133
  // src/client.tsx
1043
2134
  var import_jsx_runtime2 = require("react/jsx-runtime");
1044
- var beepCtx = null;
2135
+ var isSpeechTrueCount = 0;
2136
+ var interruptFirstAt = 0;
2137
+ var isSpeechFalseRun = 0;
2138
+ var lastReenterAt = 0;
2139
+ var INT_CONFIRM_FRAMES = { 0: 3, 1: 2, 2: 1 };
1045
2140
  var inject = ["slots", "sessions", "settingsScope"];
2141
+ var TELEMETRY_VIEW = [
2142
+ { stage: "utterance-end", key: "telUtteranceEnd" },
2143
+ { stage: "endpoint-fired", key: "telEndpoint" },
2144
+ { stage: "submitted", key: "telSubmitted" },
2145
+ { stage: "first-llm-token", key: "telFirstToken" },
2146
+ { stage: "first-sentence-text", key: "telFirstSentence" },
2147
+ { stage: "first-tts-chunk", key: "telFirstChunk" },
2148
+ { stage: "first-audio-played", key: "telFirstPlayed" }
2149
+ ];
2150
+ var BUILD_TAG = "a76aad8";
2151
+ var TELEMETRY_FLAG = "dsh-voice-mode.telemetry";
2152
+ var telemetryEnabled = typeof localStorage !== "undefined" && localStorage.getItem(TELEMETRY_FLAG) === "1";
2153
+ console.log("[dsh-voice] build=" + BUILD_TAG);
2154
+ var debugLog = (event, fields = {}) => {
2155
+ if (!telemetryEnabled) return;
2156
+ const out = {};
2157
+ for (const [k, v] of Object.entries(fields)) {
2158
+ out[k] = typeof v === "number" && !Number.isInteger(v) ? Number(v.toFixed(4)) : v;
2159
+ }
2160
+ console.log("[dsh-voice]", event, JSON.stringify(out));
2161
+ };
2162
+ var beepCtx = null;
2163
+ function playToolBeep() {
2164
+ try {
2165
+ if (!beepCtx) beepCtx = new AudioContext();
2166
+ void beepCtx.resume?.();
2167
+ const ctx = beepCtx;
2168
+ const osc = ctx.createOscillator();
2169
+ const gain = ctx.createGain();
2170
+ gain.gain.setValueAtTime(0.08, ctx.currentTime);
2171
+ gain.gain.exponentialRampToValueAtTime(1e-3, ctx.currentTime + 0.1);
2172
+ osc.connect(gain);
2173
+ gain.connect(ctx.destination);
2174
+ osc.start();
2175
+ osc.stop(ctx.currentTime + 0.1);
2176
+ } catch {
2177
+ }
2178
+ }
2179
+ var SAMPLE_RATE_16K = 16e3;
2180
+ var ECHO_DELAY_MS = 0;
2181
+ var ECHO_TAIL_MS = 400;
1046
2182
  var WAVE_BARS = 14;
1047
2183
  var BASE_PATH2 = "/voice-mode";
2184
+ function getTabId() {
2185
+ try {
2186
+ const KEY = "dshvm-tabId";
2187
+ let id = sessionStorage.getItem(KEY);
2188
+ if (!id) {
2189
+ id = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : Math.random().toString(36).slice(2) + Date.now().toString(36);
2190
+ sessionStorage.setItem(KEY, id);
2191
+ }
2192
+ return id;
2193
+ } catch {
2194
+ return Math.random().toString(36).slice(2) + Date.now().toString(36);
2195
+ }
2196
+ }
2197
+ var TAB_ID = getTabId();
2198
+ function parseShortcut(s) {
2199
+ const parts = (s || "").split("+");
2200
+ const mods = { ctrl: false, shift: false, alt: false, meta: false };
2201
+ let key = "";
2202
+ for (const raw of parts) {
2203
+ const t3 = raw.trim().toLowerCase();
2204
+ if (t3 === "ctrl" || t3 === "control") mods.ctrl = true;
2205
+ else if (t3 === "shift") mods.shift = true;
2206
+ else if (t3 === "alt" || t3 === "option") mods.alt = true;
2207
+ else if (t3 === "meta" || t3 === "cmd" || t3 === "command") mods.meta = true;
2208
+ else if (t3.length === 1 && /^[a-z0-9]$/.test(t3)) {
2209
+ if (key) return null;
2210
+ key = t3;
2211
+ } else return null;
2212
+ }
2213
+ if (!key) return null;
2214
+ if (!mods.ctrl && !mods.shift && !mods.alt && !mods.meta) return null;
2215
+ return { ...mods, key };
2216
+ }
2217
+ function getLastVoiceSession() {
2218
+ try {
2219
+ return localStorage.getItem("dshvm-last-voice");
2220
+ } catch {
2221
+ return null;
2222
+ }
2223
+ }
2224
+ function setLastVoiceSession(id) {
2225
+ try {
2226
+ if (id) localStorage.setItem("dshvm-last-voice", id);
2227
+ else localStorage.removeItem("dshvm-last-voice");
2228
+ } catch {
2229
+ }
2230
+ }
1048
2231
  function apply(ctx) {
1049
2232
  const bus = createVoiceBus(void 0, ctx);
1050
2233
  ctx.slots.inject(
@@ -1089,6 +2272,7 @@ function apply(ctx) {
1089
2272
  () => ctx.slots.register(
1090
2273
  {
1091
2274
  name: "settings.plugin.item",
2275
+ id: "voice-mode",
1092
2276
  key: "voice-mode",
1093
2277
  order: 100,
1094
2278
  label: t("stateVoiceMode")
@@ -1098,74 +2282,173 @@ function apply(ctx) {
1098
2282
  );
1099
2283
  }
1100
2284
  }
1101
- function createAudioEngine(setUi) {
1102
- const queue = [];
1103
- const audio = new Audio();
1104
- const playNext = () => {
1105
- const frame = queue.shift();
2285
+ function createAudioEngine(setUi, onPlayed, onPlaybackRef, onAllPlayed) {
2286
+ const pending = [];
2287
+ const fallbackAudio = new Audio();
2288
+ let fallback = false;
2289
+ let ctx = null;
2290
+ let duckGain = null;
2291
+ let nextEndAt = 0;
2292
+ const activeSrcs = /* @__PURE__ */ new Set();
2293
+ let decoding = false;
2294
+ const captionQueue = [];
2295
+ const warm = () => {
2296
+ if (ctx) {
2297
+ void ctx.resume?.();
2298
+ return;
2299
+ }
2300
+ try {
2301
+ const AC = window.AudioContext ?? window.webkitAudioContext;
2302
+ ctx = new AC();
2303
+ duckGain = ctx.createGain();
2304
+ duckGain.gain.value = 1;
2305
+ duckGain.connect(ctx.destination);
2306
+ void ctx.resume?.();
2307
+ } catch {
2308
+ ctx = null;
2309
+ }
2310
+ };
2311
+ const playFallback = () => {
2312
+ const frame = pending.shift() ?? null;
1106
2313
  if (!frame) {
1107
2314
  setUi({ playing: false, playingCaption: null });
1108
2315
  return;
1109
2316
  }
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 = () => {
2317
+ const url = URL.createObjectURL(new Blob([frame.audio], { type: frame.mime === "audio/wav" ? "audio/wav" : "audio/mpeg" }));
2318
+ fallbackAudio.src = url;
2319
+ fallbackAudio.onended = () => {
1116
2320
  URL.revokeObjectURL(url);
1117
- playNext();
2321
+ playFallback();
1118
2322
  };
1119
- audio.onerror = () => {
2323
+ fallbackAudio.onerror = () => {
1120
2324
  URL.revokeObjectURL(url);
1121
- playNext();
2325
+ playFallback();
2326
+ };
2327
+ fallbackAudio.onplaying = () => {
2328
+ try {
2329
+ onPlayed?.();
2330
+ } catch {
2331
+ }
2332
+ try {
2333
+ if (ctx && frame.audio.length) {
2334
+ void ctx.decodeAudioData(frame.audio.buffer.slice(0)).then((buf) => {
2335
+ onPlaybackRef?.(buf.getChannelData(0), buf.sampleRate, performance.now());
2336
+ }).catch(() => {
2337
+ });
2338
+ }
2339
+ } catch {
2340
+ }
1122
2341
  };
1123
2342
  setUi({ playing: true, playingCaption: frame.text, ttsNotice: null });
1124
- void audio.play().catch(() => playNext());
2343
+ void fallbackAudio.play().catch(() => playFallback());
1125
2344
  };
1126
- const toolBeep = () => {
1127
- try {
1128
- if (!beepCtx) {
1129
- beepCtx = new AudioContext();
1130
- void beepCtx.resume?.();
2345
+ const drainPending = () => {
2346
+ if (decoding || !ctx || !duckGain || pending.length === 0) return;
2347
+ decoding = true;
2348
+ void (async () => {
2349
+ try {
2350
+ while (pending.length > 0) {
2351
+ const frame = pending[0];
2352
+ const buf = await ctx.decodeAudioData(frame.audio.buffer.slice(0));
2353
+ if (pending.length === 0 || pending[0] !== frame) return;
2354
+ pending.shift();
2355
+ const t0 = ctx.currentTime;
2356
+ const at = Math.max(t0 + 0.02, nextEndAt);
2357
+ const src = ctx.createBufferSource();
2358
+ src.buffer = buf;
2359
+ src.connect(duckGain);
2360
+ activeSrcs.add(src);
2361
+ src.onended = () => {
2362
+ activeSrcs.delete(src);
2363
+ captionQueue.shift();
2364
+ if (activeSrcs.size === 0 && pending.length === 0) {
2365
+ setUi({ playing: false, playingCaption: null });
2366
+ onAllPlayed?.();
2367
+ } else if (captionQueue.length > 0) {
2368
+ setUi({ playingCaption: captionQueue[0] });
2369
+ }
2370
+ };
2371
+ src.start(at);
2372
+ nextEndAt = at + buf.duration;
2373
+ try {
2374
+ const outLat = ctx.outputLatency ?? 0;
2375
+ const wallMs = performance.now() + (at + outLat - ctx.currentTime) * 1e3;
2376
+ onPlaybackRef?.(buf.getChannelData(0), buf.sampleRate, wallMs);
2377
+ } catch {
2378
+ }
2379
+ try {
2380
+ onPlayed?.();
2381
+ } catch {
2382
+ }
2383
+ captionQueue.push(frame.text);
2384
+ setUi({ playing: true, playingCaption: captionQueue[0], ttsNotice: null });
2385
+ }
2386
+ } catch {
2387
+ for (const src of activeSrcs) {
2388
+ try {
2389
+ src.stop();
2390
+ } catch {
2391
+ }
2392
+ }
2393
+ activeSrcs.clear();
2394
+ captionQueue.length = 0;
2395
+ fallback = true;
2396
+ playFallback();
2397
+ } finally {
2398
+ decoding = false;
1131
2399
  }
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
- }
2400
+ })();
1143
2401
  };
1144
2402
  return {
1145
2403
  push(frame) {
1146
- queue.push(frame);
1147
- if (audio.paused) playNext();
2404
+ if (fallback || !ctx) {
2405
+ pending.push(frame);
2406
+ if (fallbackAudio.paused) playFallback();
2407
+ return;
2408
+ }
2409
+ pending.push(frame);
2410
+ drainPending();
1148
2411
  },
1149
2412
  skip() {
1150
- queue.length = 0;
1151
- audio.pause();
1152
- audio.onended = null;
1153
- audio.onerror = null;
2413
+ pending.length = 0;
2414
+ nextEndAt = 0;
2415
+ fallbackAudio.pause();
2416
+ fallbackAudio.onended = null;
2417
+ fallbackAudio.onerror = null;
2418
+ for (const src of activeSrcs) {
2419
+ try {
2420
+ src.stop();
2421
+ } catch {
2422
+ }
2423
+ }
2424
+ activeSrcs.clear();
2425
+ captionQueue.length = 0;
1154
2426
  setUi({ playing: false, playingCaption: null });
1155
2427
  },
1156
- toolBeep
2428
+ warm,
2429
+ unduck() {
2430
+ if (!ctx || !duckGain) return;
2431
+ const now = ctx.currentTime;
2432
+ duckGain.gain.cancelScheduledValues(now);
2433
+ duckGain.gain.setTargetAtTime(1, now, 0.035);
2434
+ }
1157
2435
  };
1158
2436
  }
1159
2437
  function createVoiceBus(basePath = BASE_PATH2, ctx) {
1160
2438
  let activeSessionId = null;
1161
2439
  const DEFAULT_BOOT = {
1162
2440
  basePath: BASE_PATH2,
1163
- silenceMs: 2e3,
2441
+ silenceMs: 700,
1164
2442
  interruptLevel: 0,
1165
2443
  idleTimeoutMinutes: 10,
1166
2444
  autoSend: true,
2445
+ autoResume: false,
1167
2446
  mode: "toggle",
1168
- wakeWord: ""
2447
+ bargeInMode: "auto",
2448
+ echoGateDb: 6,
2449
+ shortcut: "Ctrl+Shift+V",
2450
+ wakeWord: "",
2451
+ toolBeep: false
1169
2452
  };
1170
2453
  const ui = {
1171
2454
  state: "idle",
@@ -1178,16 +2461,142 @@ function createVoiceBus(basePath = BASE_PATH2, ctx) {
1178
2461
  ttsNotice: null,
1179
2462
  boot: DEFAULT_BOOT,
1180
2463
  mode: "toggle",
2464
+ telemetry: null,
2465
+ turn: "idle",
1181
2466
  wakeWord: ""
1182
2467
  };
1183
2468
  const listeners = /* @__PURE__ */ new Set();
1184
2469
  const audioListeners = /* @__PURE__ */ new Set();
1185
- const toolListeners = /* @__PURE__ */ new Set();
1186
2470
  let source = null;
1187
- const engine = createAudioEngine((patch) => {
1188
- Object.assign(ui, patch);
2471
+ let playingEndAt = 0;
2472
+ const telemetryStages = {};
2473
+ const stampTelemetry = (stage, at) => {
2474
+ if (!telemetryEnabled) return;
2475
+ if (stage === "utterance-end") {
2476
+ for (const k of Object.keys(telemetryStages)) delete telemetryStages[k];
2477
+ }
2478
+ if (telemetryStages[stage] === void 0) {
2479
+ telemetryStages[stage] = at ?? Date.now();
2480
+ ui.telemetry = { ...telemetryStages };
2481
+ notify();
2482
+ }
2483
+ };
2484
+ const resetTelemetry = () => {
2485
+ if (!telemetryEnabled) return;
2486
+ for (const k of Object.keys(telemetryStages)) delete telemetryStages[k];
2487
+ ui.telemetry = null;
2488
+ ui.interruptConfirmMs = void 0;
1189
2489
  notify();
1190
- });
2490
+ };
2491
+ const refChunks = [];
2492
+ let refTotal = 0;
2493
+ let refStartWall = 0;
2494
+ let refActive = false;
2495
+ const pushRef = (pcmSrc, srcRate, startWallMs) => {
2496
+ const pcm = resampleLinear(pcmSrc, srcRate, SAMPLE_RATE_16K);
2497
+ if (!refActive) {
2498
+ refActive = true;
2499
+ refStartWall = startWallMs;
2500
+ refChunks.length = 0;
2501
+ refTotal = 0;
2502
+ }
2503
+ const tailWall = refStartWall + refTotal / SAMPLE_RATE_16K * 1e3;
2504
+ const gapMs = startWallMs - tailWall;
2505
+ if (gapMs > 250) {
2506
+ refChunks.length = 0;
2507
+ refTotal = 0;
2508
+ refStartWall = startWallMs;
2509
+ } else if (gapMs > 1) {
2510
+ const padN = Math.floor(gapMs / 1e3 * SAMPLE_RATE_16K);
2511
+ refChunks.push(new Float32Array(padN));
2512
+ refTotal += padN;
2513
+ }
2514
+ refChunks.push(pcm);
2515
+ refTotal += pcm.length;
2516
+ const maxTotal = SAMPLE_RATE_16K * 60;
2517
+ while (refTotal - (refChunks[0]?.length ?? 0) > maxTotal) {
2518
+ refTotal -= refChunks.shift().length;
2519
+ }
2520
+ };
2521
+ const refWindowAt = (tWallMs, n) => {
2522
+ const out = new Float32Array(n);
2523
+ if (!refActive || refTotal === 0) return out;
2524
+ const idx = Math.floor((tWallMs - ECHO_DELAY_MS - refStartWall) / 1e3 * SAMPLE_RATE_16K);
2525
+ if (idx < 0 || idx >= refTotal) return out;
2526
+ let acc = 0;
2527
+ let outOff = 0;
2528
+ for (const c of refChunks) {
2529
+ if (outOff >= n) break;
2530
+ if (idx >= acc + c.length) {
2531
+ acc += c.length;
2532
+ continue;
2533
+ }
2534
+ const start = Math.max(0, idx - acc);
2535
+ const cnt = Math.min(c.length - start, n - outOff);
2536
+ out.set(c.subarray(start, start + cnt), outOff);
2537
+ outOff += cnt;
2538
+ acc += c.length;
2539
+ }
2540
+ return out;
2541
+ };
2542
+ const aec = new NlmsAec({ filterLength: 1024, delay: 0 });
2543
+ let refDelaySamples = 0;
2544
+ let estMic = [];
2545
+ let estRef = [];
2546
+ const EST_CAP = SAMPLE_RATE_16K;
2547
+ let lastEstimateAt = 0;
2548
+ let echoBypass = false;
2549
+ const echoSource = {
2550
+ process: (mic, ref) => {
2551
+ if (echoBypass) return mic;
2552
+ const now = performance.now();
2553
+ if (ui.playing) {
2554
+ for (let i = 0; i < mic.length; i++) estMic.push(mic[i]);
2555
+ for (let i = 0; i < ref.length; i++) estRef.push(ref[i]);
2556
+ if (estMic.length > EST_CAP) {
2557
+ const drop = estMic.length - EST_CAP;
2558
+ estMic.splice(0, drop);
2559
+ estRef.splice(0, drop);
2560
+ }
2561
+ if (now - lastEstimateAt > 2e3 && estMic.length > SAMPLE_RATE_16K * 0.5) {
2562
+ lastEstimateAt = now;
2563
+ const est = estimateBulkDelay(
2564
+ Float32Array.from(estMic),
2565
+ Float32Array.from(estRef),
2566
+ { sampleRate: SAMPLE_RATE_16K, maxLag: Math.floor(0.25 * SAMPLE_RATE_16K) }
2567
+ );
2568
+ if (est.peak > 0.5) {
2569
+ refDelaySamples = refDelaySamples === 0 ? est.lag : Math.round(refDelaySamples * 0.8 + est.lag * 0.2);
2570
+ }
2571
+ estMic.length = 0;
2572
+ estRef.length = 0;
2573
+ }
2574
+ }
2575
+ let refForAec = ref;
2576
+ if (refDelaySamples > 0 && refActive && refTotal > refDelaySamples) {
2577
+ const shiftMs = refDelaySamples / SAMPLE_RATE_16K * 1e3;
2578
+ refForAec = refWindowAt(now - shiftMs, ref.length);
2579
+ }
2580
+ return aec.process(mic, refForAec);
2581
+ },
2582
+ windowAt: refWindowAt,
2583
+ // A2.5 双讲冻结:用户说话时暂停 NLMS 自适应。
2584
+ setFrozen: (frozen) => aec.setFrozen(frozen)
2585
+ };
2586
+ const engine = createAudioEngine(
2587
+ (patch) => {
2588
+ Object.assign(ui, patch);
2589
+ notify();
2590
+ },
2591
+ () => stampTelemetry("first-audio-played"),
2592
+ (pcm, sampleRate, wallMs) => pushRef(pcm, sampleRate, wallMs),
2593
+ // Fix:自然播完(无 TTS 在播)即清参考池——AEC 不再拿旧回合参考适配新语音。
2594
+ () => {
2595
+ refActive = false;
2596
+ refChunks.length = 0;
2597
+ refTotal = 0;
2598
+ }
2599
+ );
1191
2600
  const notify = () => {
1192
2601
  for (const fn of listeners) {
1193
2602
  try {
@@ -1198,13 +2607,23 @@ function createVoiceBus(basePath = BASE_PATH2, ctx) {
1198
2607
  };
1199
2608
  const connect = () => {
1200
2609
  if (source) return;
1201
- source = new EventSource(`${location.origin}${basePath}/stream`);
2610
+ source = new EventSource(`${location.origin}${basePath}/stream?tabId=${encodeURIComponent(TAB_ID)}`);
2611
+ source.addEventListener("open", () => {
2612
+ rejectSeqUpTo.clear();
2613
+ lastFinalSeq.clear();
2614
+ });
1202
2615
  source.addEventListener("mode", (e) => {
1203
2616
  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();
2617
+ const data = JSON.parse(e.data);
2618
+ const active = data.active ?? null;
2619
+ const ownerTabId = data.ownerTabId ?? null;
2620
+ const preempted = active !== activeSessionId || ownerTabId !== null && activeSessionId !== null && ownerTabId !== TAB_ID;
2621
+ if (activeSessionId !== null && preempted) {
2622
+ const prev = activeSessionId;
2623
+ activeSessionId = null;
2624
+ if (ui.turn !== "idle") ui.turn = "idle";
2625
+ doSkipAudio(prev);
2626
+ resetTelemetry();
1208
2627
  notify();
1209
2628
  }
1210
2629
  } catch {
@@ -1223,18 +2642,23 @@ function createVoiceBus(basePath = BASE_PATH2, ctx) {
1223
2642
  } catch {
1224
2643
  }
1225
2644
  });
1226
- source.addEventListener("tool", (e) => {
2645
+ source.addEventListener("turn", (e) => {
1227
2646
  try {
1228
2647
  const ev = JSON.parse(e.data);
1229
- for (const fn of toolListeners) {
1230
- try {
1231
- fn(ev);
1232
- } catch {
1233
- }
2648
+ if (ev.sessionId === activeSessionId && ev.state) {
2649
+ ui.turn = ev.state;
2650
+ notify();
1234
2651
  }
1235
2652
  } catch {
1236
2653
  }
1237
2654
  });
2655
+ source.addEventListener("latency", (e) => {
2656
+ try {
2657
+ const ev = JSON.parse(e.data);
2658
+ if (ev.sessionId === activeSessionId && ev.stage) stampTelemetry(ev.stage);
2659
+ } catch {
2660
+ }
2661
+ });
1238
2662
  source.addEventListener("asr-progress", (e) => {
1239
2663
  try {
1240
2664
  const p = JSON.parse(e.data);
@@ -1268,12 +2692,85 @@ function createVoiceBus(basePath = BASE_PATH2, ctx) {
1268
2692
  } catch {
1269
2693
  }
1270
2694
  });
2695
+ source.addEventListener("tool", (e) => {
2696
+ try {
2697
+ const p = JSON.parse(e.data);
2698
+ if (p.sessionId === activeSessionId && ui.boot.toolBeep === true) playToolBeep();
2699
+ } catch {
2700
+ }
2701
+ });
1271
2702
  };
1272
2703
  connect();
2704
+ const rejectSeqUpTo = /* @__PURE__ */ new Map();
2705
+ const lastFinalSeq = /* @__PURE__ */ new Map();
2706
+ let curSentenceId = null;
2707
+ let curChunks = [];
2708
+ let curBytes = 0;
2709
+ let curChunkCount = 0;
1273
2710
  audioListeners.add((frame) => {
1274
- if (frame.sessionId === activeSessionId) engine.push(frame);
2711
+ if (frame.sessionId !== activeSessionId) return;
2712
+ const rejectLine = rejectSeqUpTo.get(frame.sessionId);
2713
+ if (rejectLine !== void 0 && frame.sentenceId <= rejectLine) return;
2714
+ stampTelemetry("first-tts-chunk");
2715
+ if (frame.sentenceId !== curSentenceId) {
2716
+ curSentenceId = frame.sentenceId;
2717
+ curChunks = [];
2718
+ curBytes = 0;
2719
+ curChunkCount = 0;
2720
+ }
2721
+ if (frame.final) {
2722
+ if (frame.chunkId !== curChunkCount) {
2723
+ curSentenceId = null;
2724
+ curChunks = [];
2725
+ curBytes = 0;
2726
+ curChunkCount = 0;
2727
+ return;
2728
+ }
2729
+ const buf = new Uint8Array(curBytes);
2730
+ let off = 0;
2731
+ for (const c of curChunks) {
2732
+ buf.set(c, off);
2733
+ off += c.length;
2734
+ }
2735
+ curSentenceId = null;
2736
+ curChunks = [];
2737
+ curBytes = 0;
2738
+ curChunkCount = 0;
2739
+ if (buf.length === 0) return;
2740
+ const isMp3 = buf[0] === 255;
2741
+ const isWav = buf.length >= 4 && buf[0] === 82 && buf[1] === 73 && buf[2] === 70 && buf[3] === 70;
2742
+ if (!isMp3 && !isWav) return;
2743
+ engine.push({
2744
+ sessionId: frame.sessionId,
2745
+ seq: frame.sentenceId,
2746
+ text: frame.text ?? "",
2747
+ audio: buf,
2748
+ mime: frame.mime
2749
+ });
2750
+ lastFinalSeq.set(frame.sessionId, frame.sentenceId);
2751
+ return;
2752
+ }
2753
+ const bin = atob(frame.audio);
2754
+ const bytes = new Uint8Array(bin.length);
2755
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
2756
+ curChunks.push(bytes);
2757
+ curBytes += bytes.length;
2758
+ curChunkCount += 1;
1275
2759
  });
1276
- toolListeners.add(() => engine.toolBeep());
2760
+ const doSkipAudio = (sidArg) => {
2761
+ const sid = sidArg ?? activeSessionId;
2762
+ if (sid) {
2763
+ rejectSeqUpTo.set(sid, Math.max(lastFinalSeq.get(sid) ?? -1, curSentenceId ?? -1));
2764
+ }
2765
+ curSentenceId = null;
2766
+ curChunks = [];
2767
+ curBytes = 0;
2768
+ refActive = false;
2769
+ refChunks.length = 0;
2770
+ refTotal = 0;
2771
+ engine.skip();
2772
+ playingEndAt = 0;
2773
+ };
1277
2774
  return {
1278
2775
  get activeSessionId() {
1279
2776
  return activeSessionId;
@@ -1287,34 +2784,47 @@ function createVoiceBus(basePath = BASE_PATH2, ctx) {
1287
2784
  };
1288
2785
  },
1289
2786
  setUi(patch) {
2787
+ if (patch.playing === false && ui.playing === true) playingEndAt = Date.now();
1290
2788
  Object.assign(ui, patch);
1291
2789
  notify();
1292
2790
  },
2791
+ /** isPlaying 尾音截止墙钟:playing 或尾音宽限期内均视为「AI 正在朗读」。 */
2792
+ playingTailUntil() {
2793
+ return playingEndAt + ECHO_TAIL_MS + refDelaySamples / SAMPLE_RATE_16K * 1e3;
2794
+ },
1293
2795
  async enter(sessionId) {
1294
2796
  try {
1295
2797
  const res = await fetch(`${location.origin}${basePath}/toggle`, {
1296
2798
  method: "POST",
1297
2799
  headers: { "content-type": "application/json" },
1298
- body: JSON.stringify({ sessionId, on: true })
2800
+ body: JSON.stringify({ sessionId, on: true, tabId: TAB_ID })
1299
2801
  });
1300
2802
  const out = await res.json();
1301
- activeSessionId = out.active ?? null;
2803
+ activeSessionId = out.active === sessionId ? sessionId : null;
1302
2804
  notify();
1303
2805
  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") };
2806
+ if (out.active === sessionId) setLastVoiceSession(sessionId);
2807
+ return {
2808
+ ok: out.active === sessionId,
2809
+ preempted: out.active !== null && out.active !== sessionId,
2810
+ error: out.active === sessionId ? void 0 : t("enterFail")
2811
+ };
1305
2812
  } catch {
1306
2813
  return { ok: false, error: t("enterFail") };
1307
2814
  }
1308
2815
  },
1309
2816
  async exit(sessionId) {
2817
+ resetTelemetry();
2818
+ ui.turn = "idle";
2819
+ doSkipAudio();
1310
2820
  try {
1311
2821
  const res = await fetch(`${location.origin}${basePath}/toggle`, {
1312
2822
  method: "POST",
1313
2823
  headers: { "content-type": "application/json" },
1314
- body: JSON.stringify({ sessionId, on: false })
2824
+ body: JSON.stringify({ sessionId, on: false, tabId: TAB_ID })
1315
2825
  });
1316
- const out = await res.json();
1317
- activeSessionId = out.active ?? null;
2826
+ await res.json();
2827
+ activeSessionId = null;
1318
2828
  notify();
1319
2829
  } catch {
1320
2830
  }
@@ -1325,20 +2835,31 @@ function createVoiceBus(basePath = BASE_PATH2, ctx) {
1325
2835
  audioListeners.delete(fn);
1326
2836
  };
1327
2837
  },
1328
- onToolEvent(fn) {
1329
- toolListeners.add(fn);
1330
- return () => {
1331
- toolListeners.delete(fn);
1332
- };
1333
- },
1334
2838
  skipAudio() {
1335
- engine.skip();
2839
+ doSkipAudio();
2840
+ },
2841
+ echoForAsr() {
2842
+ return echoSource;
2843
+ },
2844
+ setEchoBypass(on) {
2845
+ echoBypass = on;
2846
+ },
2847
+ echoDelayMs() {
2848
+ return refDelaySamples / SAMPLE_RATE_16K * 1e3;
2849
+ },
2850
+ unduckAudio() {
2851
+ engine.unduck();
1336
2852
  },
1337
2853
  cancelTurn(sessionId) {
1338
2854
  try {
1339
2855
  ctx?.sessions?.binding?.(sessionId)?.session.cancel?.();
1340
2856
  } catch {
1341
2857
  }
2858
+ },
2859
+ stampTelemetry,
2860
+ resetTelemetry,
2861
+ warmAudio() {
2862
+ engine.warm();
1342
2863
  }
1343
2864
  };
1344
2865
  }
@@ -1372,8 +2893,12 @@ function MicButton({
1372
2893
  const submitTimerRef = (0, import_react2.useRef)(null);
1373
2894
  const idleTimerRef = (0, import_react2.useRef)(null);
1374
2895
  const runningRef = (0, import_react2.useRef)(false);
2896
+ const mountedRef = (0, import_react2.useRef)(true);
1375
2897
  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: "" };
2898
+ const manualHoldRef = (0, import_react2.useRef)(false);
2899
+ const breakRef = (0, import_react2.useRef)(null);
2900
+ const pausedForHiddenRef = (0, import_react2.useRef)(false);
2901
+ 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", wakeWord: "", toolBeep: false };
1377
2902
  useVoiceCss();
1378
2903
  const [, bumpUi] = (0, import_react2.useState)(0);
1379
2904
  (0, import_react2.useEffect)(
@@ -1398,8 +2923,13 @@ function MicButton({
1398
2923
  interruptLevel: c.interruptLevel ?? cur.interruptLevel,
1399
2924
  idleTimeoutMinutes: c.idleTimeoutMinutes ?? cur.idleTimeoutMinutes,
1400
2925
  autoSend: c.autoSend ?? cur.autoSend,
2926
+ autoResume: c.autoResume === true,
1401
2927
  mode: c.mode === "hold" ? "hold" : "toggle",
1402
- wakeWord: c.wakeWord ?? cur.wakeWord
2928
+ bargeInMode: c.bargeInMode === "manual" ? "manual" : "auto",
2929
+ echoGateDb: typeof c.echoGateDb === "number" ? Math.min(12, Math.max(3, c.echoGateDb)) : cur.echoGateDb,
2930
+ shortcut: typeof c.shortcut === "string" ? c.shortcut : cur.shortcut,
2931
+ wakeWord: typeof c.wakeWord === "string" ? c.wakeWord : cur.wakeWord,
2932
+ toolBeep: c.toolBeep === true
1403
2933
  };
1404
2934
  bus.setUi({ boot: next, mode: next.mode, wakeWord: next.wakeWord });
1405
2935
  return next;
@@ -1428,14 +2958,24 @@ function MicButton({
1428
2958
  if (bus.activeSessionId !== sid) {
1429
2959
  setLocalMode("off");
1430
2960
  clearIdle();
1431
- void engineRef.current?.stop();
2961
+ cancelPendingSubmit();
2962
+ clearBreakTimer();
2963
+ setHolding(false);
2964
+ isSpeechTrueCount = 0;
2965
+ breakRef.current = null;
2966
+ manualHoldRef.current = false;
2967
+ holdCtrlRef.current = false;
2968
+ const engine = engineRef.current;
1432
2969
  engineRef.current = null;
2970
+ if (engine) void engine.stop();
2971
+ bus.resetTelemetry();
2972
+ bus.setUi({ state: "idle", partial: "", levels: [], error: null, model: null, ttsNotice: null, isSpeech: void 0 });
1433
2973
  }
1434
2974
  });
1435
2975
  }, [bus]);
1436
2976
  const cancelPendingSubmit = () => {
1437
2977
  if (submitTimerRef.current) {
1438
- clearTimeout(submitTimerRef.current);
2978
+ clearInterval(submitTimerRef.current);
1439
2979
  submitTimerRef.current = null;
1440
2980
  }
1441
2981
  };
@@ -1444,33 +2984,175 @@ function MicButton({
1444
2984
  setLocalMode("off");
1445
2985
  clearIdle();
1446
2986
  cancelPendingSubmit();
2987
+ isSpeechTrueCount = 0;
2988
+ breakRef.current = null;
2989
+ manualHoldRef.current = false;
2990
+ clearBreakTimer();
2991
+ setHolding(false);
1447
2992
  const engine = engineRef.current;
1448
2993
  engineRef.current = null;
1449
- if (engine) void engine.stop();
1450
- bus.setUi({ state: "idle", partial: "", levels: [], error: null, model: null, ttsNotice: null });
2994
+ if (engine) await engine.stop();
2995
+ bus.resetTelemetry();
2996
+ bus.setUi({ state: "idle", partial: "", levels: [], error: null, model: null, ttsNotice: null, isSpeech: void 0 });
1451
2997
  const sid = sidRef.current;
1452
- if (sid) void bus.exit(sid);
2998
+ if (sid) await bus.exit(sid);
1453
2999
  };
1454
3000
  const enterMode = async () => {
1455
3001
  const sid = sidRef.current;
1456
3002
  if (!sid || localRef.current !== "off") return;
3003
+ isSpeechTrueCount = 0;
1457
3004
  setLocalMode("pending");
1458
3005
  try {
1459
3006
  const entered = await bus.enter(sid);
3007
+ if (!mountedRef.current) {
3008
+ if (entered.ok) void bus.exit(sid);
3009
+ return;
3010
+ }
1460
3011
  if (!entered.ok) {
1461
3012
  setLocalMode("off");
1462
- bus.setUi({
1463
- error: entered.error === "voice mode disabled" ? t("disabled") : entered.error ?? t("enterFail")
1464
- });
3013
+ if (!entered.preempted) {
3014
+ bus.setUi({
3015
+ error: entered.error === "voice mode disabled" ? t("disabled") : entered.error ?? t("enterFail")
3016
+ });
3017
+ }
1465
3018
  return;
1466
3019
  }
1467
3020
  const cfg = await fetchConfig();
1468
3021
  const basePath = cfg.basePath;
1469
3022
  const silenceMs = cfg.silenceMs;
1470
3023
  const interruptLevel = cfg.interruptLevel;
1471
- const engine = createAsrEngine({ silenceMs, interruptLevel, basePath, wakeWord: cfg.wakeWord }, sid);
1472
- bus.setUi({ mode: cfg.mode, wakeWord: cfg.wakeWord });
3024
+ const confirmFrames = INT_CONFIRM_FRAMES[interruptLevel] ?? 2;
3025
+ const bargeInMode = cfg.bargeInMode;
3026
+ debugLog("enter", {
3027
+ build: BUILD_TAG,
3028
+ mode: cfg.mode,
3029
+ bargeInMode,
3030
+ echoGateDb: cfg.echoGateDb,
3031
+ interruptLevel,
3032
+ silenceMs,
3033
+ sessionId: sid
3034
+ });
3035
+ const hardBreak = async () => {
3036
+ bus.skipAudio();
3037
+ bus.unduckAudio();
3038
+ if (runningRef.current && sidRef.current) {
3039
+ bus.cancelTurn(sidRef.current);
3040
+ }
3041
+ const cancelP = fetch(`${location.origin}${BASE_PATH2}/cancel`, {
3042
+ method: "POST",
3043
+ headers: { "content-type": "application/json" },
3044
+ // hold 按压中保留 host ASR 段(松手定稿续传前半句,防吃句)。
3045
+ body: JSON.stringify({ sessionId: sidRef.current, keepAsr: engineRef.current?.holding === true }),
3046
+ signal: AbortSignal.timeout(3e3)
3047
+ }).catch(() => {
3048
+ });
3049
+ if (engineRef.current && !engineRef.current.holding) await engineRef.current.discardSegment();
3050
+ await cancelP;
3051
+ bus.setUi({ partial: "\u2026" });
3052
+ };
3053
+ breakRef.current = hardBreak;
3054
+ const engine = createAsrEngine(
3055
+ {
3056
+ silenceMs,
3057
+ basePath,
3058
+ mode: cfg.mode,
3059
+ wakeWord: cfg.wakeWord,
3060
+ echoGateDb: cfg.echoGateDb,
3061
+ echo: bus.echoForAsr(),
3062
+ // 回声尾音宽限:playing 或尾音窗口内均视为朗读中,防句播完瞬间的残响漏入 ASR。
3063
+ isPlaying: () => bus.ui.playing || Date.now() < bus.playingTailUntil(),
3064
+ // 打断根治阶段二:服务端 Silero VAD 帧级检测下行 → 驱动打断(替代 RMS 能量快
3065
+ // 路径)。连续 confirmFrames 次 true(墙钟节拍 100ms/拍,三档确认约 0.3/0.2/0.1s)
3066
+ // 判真实人声前沿;仅 AI 朗读中(bus.ui.playing)触发 hardBreak,
3067
+ // 防 TTS 回声被 VAD 误判为语音而自打断。
3068
+ onIsSpeech: (speech) => {
3069
+ if (bargeInMode === "manual") return;
3070
+ if (!bus.ui.playing) {
3071
+ isSpeechTrueCount = 0;
3072
+ interruptFirstAt = 0;
3073
+ bus.setUi({ isSpeech: speech, echoDelayMs: bus.echoDelayMs(), echoLevels: engineRef.current?.echoLevels() });
3074
+ return;
3075
+ }
3076
+ if (speech === true && isSpeechTrueCount === 0) {
3077
+ const lv = engineRef.current?.echoLevels();
3078
+ debugLog("vad-speech-start", {
3079
+ playing: bus.ui.playing,
3080
+ delayMs: Math.round(bus.echoDelayMs()),
3081
+ floor: lv?.floorRms,
3082
+ resid: lv?.residualRms,
3083
+ peak: lv?.peakRms
3084
+ });
3085
+ }
3086
+ if (speech === true) {
3087
+ isSpeechFalseRun = 0;
3088
+ isSpeechTrueCount++;
3089
+ if (isSpeechTrueCount === 1) interruptFirstAt = Date.now();
3090
+ if (isSpeechTrueCount >= confirmFrames) {
3091
+ if (engineRef.current && !engineRef.current.aboveEchoFloor(cfg.echoGateDb ?? 6)) {
3092
+ const lv2 = engineRef.current?.echoLevels();
3093
+ debugLog("echo-gate-reject", {
3094
+ gateDb: cfg.echoGateDb ?? 6,
3095
+ floor: lv2?.floorRms,
3096
+ resid: lv2?.residualRms,
3097
+ peak: lv2?.peakRms,
3098
+ confirmFrames
3099
+ });
3100
+ isSpeechTrueCount = 0;
3101
+ interruptFirstAt = 0;
3102
+ return;
3103
+ }
3104
+ const confirmMs = interruptFirstAt > 0 ? Date.now() - interruptFirstAt : 0;
3105
+ const lv = engineRef.current?.echoLevels();
3106
+ debugLog("interrupt-trigger", {
3107
+ confirmMs,
3108
+ floor: lv?.floorRms,
3109
+ resid: lv?.residualRms,
3110
+ peak: lv?.peakRms,
3111
+ delayMs: Math.round(bus.echoDelayMs())
3112
+ });
3113
+ interruptFirstAt = 0;
3114
+ isSpeechTrueCount = 0;
3115
+ resetIdle();
3116
+ bus.resetTelemetry();
3117
+ bus.setUi({ interruptConfirmMs: confirmMs });
3118
+ void hardBreak();
3119
+ }
3120
+ } else {
3121
+ isSpeechFalseRun++;
3122
+ if (isSpeechFalseRun >= 2) {
3123
+ isSpeechFalseRun = 0;
3124
+ isSpeechTrueCount = Math.max(0, isSpeechTrueCount - 1);
3125
+ if (isSpeechTrueCount === 0) interruptFirstAt = 0;
3126
+ }
3127
+ }
3128
+ bus.setUi({ isSpeech: speech, echoDelayMs: bus.echoDelayMs(), echoLevels: engineRef.current?.echoLevels() });
3129
+ },
3130
+ onSessionExpired: async () => {
3131
+ if (localRef.current !== "on") return false;
3132
+ if (Date.now() - lastReenterAt < 2e3) return false;
3133
+ lastReenterAt = Date.now();
3134
+ bus.setUi({ error: t("sessionExpired") });
3135
+ const reentered = await bus.enter(sid);
3136
+ if (!reentered.ok) {
3137
+ bus.setUi({ error: t("sessionExpiredFail") });
3138
+ } else {
3139
+ bus.setUi({ error: null });
3140
+ }
3141
+ return reentered.ok;
3142
+ },
3143
+ // A1:原生 AEC 生效状态 → 状态条提示(外放且原生 AEC 失效时引导用耳机/手动打断)。
3144
+ onAecState: (on2) => {
3145
+ debugLog("aec-state", { nativeEchoCancellation: on2 });
3146
+ bus.setEchoBypass(on2);
3147
+ bus.setUi({ aecOff: !on2 });
3148
+ }
3149
+ },
3150
+ sid
3151
+ );
3152
+ bus.setUi({ mode: cfg.mode });
1473
3153
  engineRef.current = engine;
3154
+ engine.onTelemetry((e) => bus.stampTelemetry(e.stage, e.at));
3155
+ bus.warmAudio();
1474
3156
  try {
1475
3157
  if (!beepCtx) beepCtx = new AudioContext();
1476
3158
  void beepCtx.resume?.();
@@ -1491,6 +3173,7 @@ function MicButton({
1491
3173
  engine.onPartial((text) => bus.setUi({ partial: text }));
1492
3174
  engine.onSegment((text, meta) => {
1493
3175
  resetIdle();
3176
+ bus.setUi({ partial: "" });
1494
3177
  const actions = actionsRef.current;
1495
3178
  const trimmed = text.trim();
1496
3179
  if (!trimmed) return;
@@ -1507,6 +3190,7 @@ function MicButton({
1507
3190
  } catch {
1508
3191
  }
1509
3192
  }
3193
+ if (bus.ui.playing && !meta?.force) return;
1510
3194
  if (bootNow().autoSend === false && !meta?.force) return;
1511
3195
  const doSubmit = () => {
1512
3196
  try {
@@ -1522,29 +3206,35 @@ function MicButton({
1522
3206
  };
1523
3207
  cancelPendingSubmit();
1524
3208
  doSubmit();
1525
- submitTimerRef.current = setTimeout(() => {
3209
+ let retryCount = 0;
3210
+ submitTimerRef.current = setInterval(() => {
3211
+ retryCount++;
1526
3212
  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" });
3213
+ if (retryCount > 3 || phase === "submitting" || phase === "adjudicating" || draftRef.current.trim() !== trimmed) {
3214
+ cancelPendingSubmit();
3215
+ return;
3216
+ }
3217
+ doSubmit();
3218
+ }, 500);
1545
3219
  });
1546
3220
  bus.setUi({ state: "idle", partial: "", levels: [], error: null, model: null, ttsNotice: null });
3221
+ if (!mountedRef.current) {
3222
+ engineRef.current = null;
3223
+ void bus.exit(sid);
3224
+ return;
3225
+ }
1547
3226
  await engine.start();
3227
+ if (!mountedRef.current) {
3228
+ engineRef.current = null;
3229
+ await engine.stop();
3230
+ void bus.exit(sid);
3231
+ return;
3232
+ }
3233
+ if (bus.activeSessionId !== sid) {
3234
+ engineRef.current = null;
3235
+ await engine.stop();
3236
+ return;
3237
+ }
1548
3238
  setLocalMode("on");
1549
3239
  resetIdle();
1550
3240
  } catch (e) {
@@ -1569,21 +3259,40 @@ function MicButton({
1569
3259
  (0, import_react2.useEffect)(() => {
1570
3260
  sidRef.current = sessionId;
1571
3261
  }, [sessionId]);
3262
+ const autoResumeTriedForRef = (0, import_react2.useRef)(null);
3263
+ (0, import_react2.useEffect)(() => {
3264
+ const sid = sessionId;
3265
+ if (!sid || sid === autoResumeTriedForRef.current) return;
3266
+ autoResumeTriedForRef.current = sid;
3267
+ void (async () => {
3268
+ const cfg = await fetchConfig();
3269
+ if (!cfg.autoResume) return;
3270
+ if (getLastVoiceSession() !== sid) return;
3271
+ if (bus.activeSessionId !== null) return;
3272
+ if (localRef.current !== "off") return;
3273
+ await enterMode().catch(() => {
3274
+ setLocalMode("off");
3275
+ });
3276
+ })();
3277
+ }, [sessionId]);
1572
3278
  const runningSel = useSession ? useSession((s) => s === void 0 ? void 0 : s.running) : void 0;
1573
3279
  (0, import_react2.useEffect)(() => {
1574
3280
  runningRef.current = runningSel === true;
1575
3281
  }, [runningSel]);
1576
3282
  (0, import_react2.useEffect)(() => {
3283
+ mountedRef.current = true;
1577
3284
  return () => {
3285
+ mountedRef.current = false;
1578
3286
  clearIdle();
1579
3287
  cancelPendingSubmit();
3288
+ isSpeechTrueCount = 0;
1580
3289
  const sid = sidRef.current;
1581
- if (localRef.current === "on" && sid) {
3290
+ if ((localRef.current === "on" || localRef.current === "pending") && sid) {
1582
3291
  void engineRef.current?.stop();
1583
3292
  void fetch(`${location.origin}${BASE_PATH2}/toggle`, {
1584
3293
  method: "POST",
1585
3294
  headers: { "content-type": "application/json" },
1586
- body: JSON.stringify({ sessionId: sid, on: false }),
3295
+ body: JSON.stringify({ sessionId: sid, on: false, tabId: TAB_ID }),
1587
3296
  keepalive: true
1588
3297
  }).catch(() => {
1589
3298
  });
@@ -1592,6 +3301,8 @@ function MicButton({
1592
3301
  }, []);
1593
3302
  (0, import_react2.useEffect)(() => {
1594
3303
  let ctrlTimer = null;
3304
+ let ctrlHoldStart = 0;
3305
+ let otherKeyDuringCtrl = false;
1595
3306
  const cancelCtrl = () => {
1596
3307
  if (ctrlTimer) {
1597
3308
  clearTimeout(ctrlTimer);
@@ -1599,33 +3310,68 @@ function MicButton({
1599
3310
  }
1600
3311
  if (holdCtrlRef.current) {
1601
3312
  holdCtrlRef.current = false;
3313
+ setHolding(false);
3314
+ engineRef.current?.endHeld(false);
3315
+ }
3316
+ if (manualHoldRef.current) {
3317
+ manualHoldRef.current = false;
3318
+ setHolding(false);
1602
3319
  engineRef.current?.endHeld(false);
1603
3320
  }
1604
3321
  };
1605
3322
  const onKeyDown = (e) => {
1606
- if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "v" && !e.repeat) {
1607
- e.preventDefault();
1608
- cancelCtrl();
1609
- toggleRef.current();
3323
+ const combo = parseShortcut(bootNow().shortcut);
3324
+ const codeKey = e.code.replace("Key", "").replace("Digit", "").toLowerCase();
3325
+ 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) {
3326
+ const el = e.target;
3327
+ const editable = el instanceof HTMLElement && (el.tagName === "TEXTAREA" || el.tagName === "INPUT" || el.isContentEditable);
3328
+ if (!editable && !e.isComposing) {
3329
+ e.preventDefault();
3330
+ cancelCtrl();
3331
+ toggleRef.current();
3332
+ }
1610
3333
  return;
1611
3334
  }
1612
3335
  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;
3336
+ if (e.key === "Control" && !e.shiftKey && !e.altKey && !e.metaKey && !e.repeat && eng) {
3337
+ ctrlHoldStart = Date.now();
3338
+ otherKeyDuringCtrl = false;
3339
+ if (bootNow().mode === "hold") {
3340
+ ctrlTimer = setTimeout(() => {
3341
+ ctrlTimer = null;
3342
+ holdCtrlRef.current = true;
3343
+ setHolding(true);
3344
+ eng.beginHeld();
3345
+ }, 600);
3346
+ } else if (bootNow().bargeInMode === "manual" && bus.ui.playing) {
3347
+ manualHoldRef.current = true;
3348
+ setHolding(true);
1618
3349
  eng.beginHeld();
1619
- }, 600);
1620
- } else {
1621
- eng.forceSend();
3350
+ void breakRef.current?.();
3351
+ }
3352
+ return;
3353
+ }
3354
+ if (ctrlHoldStart > 0 && e.key !== "Control") {
3355
+ otherKeyDuringCtrl = true;
3356
+ if (ctrlTimer) {
3357
+ clearTimeout(ctrlTimer);
3358
+ ctrlTimer = null;
3359
+ }
1622
3360
  }
1623
3361
  };
1624
3362
  const onKeyUp = (e) => {
1625
- if (e.key === "Control") cancelCtrl();
3363
+ if (e.key !== "Control") return;
3364
+ if (bootNow().mode !== "hold" && !manualHoldRef.current && !otherKeyDuringCtrl && ctrlHoldStart > 0 && Date.now() - ctrlHoldStart >= 250) {
3365
+ engineRef.current?.forceSend();
3366
+ }
3367
+ cancelCtrl();
3368
+ ctrlHoldStart = 0;
3369
+ otherKeyDuringCtrl = false;
1626
3370
  };
1627
3371
  const onBlur = () => {
1628
3372
  cancelCtrl();
3373
+ ctrlHoldStart = 0;
3374
+ otherKeyDuringCtrl = false;
1629
3375
  if (localRef.current === "on" && bootNow().mode === "hold") engineRef.current?.endHeld(true);
1630
3376
  };
1631
3377
  window.addEventListener("keydown", onKeyDown);
@@ -1654,12 +3400,25 @@ function MicButton({
1654
3400
  if (localRef.current !== "on" || bootNow().mode !== "hold") return;
1655
3401
  engineRef.current?.endHeld(true);
1656
3402
  holdCtrlRef.current = false;
3403
+ setHolding(false);
1657
3404
  bus.setUi({ partial: "" });
1658
3405
  };
1659
3406
  const onVisibility = () => {
1660
- if (document.hidden && bootNow().mode === "hold") {
1661
- engineRef.current?.endHeld(true);
1662
- holdCtrlRef.current = false;
3407
+ if (document.hidden) {
3408
+ if (localRef.current === "on" && engineRef.current) {
3409
+ if (bootNow().mode === "hold") {
3410
+ engineRef.current?.endHeld(true);
3411
+ holdCtrlRef.current = false;
3412
+ setHolding(false);
3413
+ }
3414
+ pausedForHiddenRef.current = true;
3415
+ void engineRef.current.stop();
3416
+ }
3417
+ } else if (pausedForHiddenRef.current && localRef.current === "on") {
3418
+ pausedForHiddenRef.current = false;
3419
+ void engineRef.current?.start().catch(() => {
3420
+ setLocalMode("off");
3421
+ });
1663
3422
  }
1664
3423
  };
1665
3424
  window.addEventListener("keydown", onKeyDown);
@@ -1690,28 +3449,76 @@ function MicButton({
1690
3449
  const livePhase = useInput ? useInput((s) => s?.phase ?? "") : "";
1691
3450
  const phaseRef = (0, import_react2.useRef)("");
1692
3451
  phaseRef.current = livePhase;
1693
- const label = on ? busy ? t("recognizing") : holdMode ? t("holdToTalk") : t("voiceDetected") : local === "pending" ? t("entering") : t("voiceBtn");
3452
+ const [holding, setHolding] = (0, import_react2.useState)(false);
3453
+ const label = on ? busy ? t("recognizing") : holdMode ? holding ? t("releaseToSend") : t("holdToTalk") : bus.ui.state === "wake" ? t("sayWake").replace("{wake}", bus.ui.wakeWord || t("wakeWord")) : t("voiceDetected") : local === "pending" ? t("entering") : t("voiceBtn");
1694
3454
  const holdPtrRef = (0, import_react2.useRef)(null);
3455
+ const toggleHoldRef = (0, import_react2.useRef)(false);
3456
+ const suppressClickUntilRef = (0, import_react2.useRef)(0);
3457
+ const breakTimerRef = (0, import_react2.useRef)(null);
3458
+ const clearBreakTimer = () => {
3459
+ if (breakTimerRef.current !== null) {
3460
+ clearTimeout(breakTimerRef.current);
3461
+ breakTimerRef.current = null;
3462
+ }
3463
+ };
1695
3464
  const onPointerDown = (e) => {
1696
- if (bootNow().mode !== "hold") return;
1697
3465
  holdPtrRef.current = { t: Date.now(), y: e.clientY, id: e.pointerId };
1698
3466
  e.currentTarget.setPointerCapture?.(e.pointerId);
1699
- if (localRef.current === "on") engineRef.current?.beginHeld();
3467
+ if (bootNow().mode === "hold") {
3468
+ if (localRef.current === "on") {
3469
+ setHolding(true);
3470
+ const eng = engineRef.current;
3471
+ eng?.beginHeld();
3472
+ if (eng && bootNow().bargeInMode === "manual" && bus.ui.playing) {
3473
+ breakTimerRef.current = setTimeout(() => {
3474
+ breakTimerRef.current = null;
3475
+ if (holdPtrRef.current && bus.ui.playing) void breakRef.current?.();
3476
+ }, 250);
3477
+ }
3478
+ }
3479
+ } else if (localRef.current === "on" && bus.ui.playing) {
3480
+ toggleHoldRef.current = true;
3481
+ setHolding(true);
3482
+ const eng = engineRef.current;
3483
+ eng?.beginHeld();
3484
+ breakTimerRef.current = setTimeout(() => {
3485
+ breakTimerRef.current = null;
3486
+ if (holdPtrRef.current && bus.ui.playing) void breakRef.current?.();
3487
+ }, 250);
3488
+ }
1700
3489
  };
1701
3490
  const onPointerMove = (e) => {
1702
3491
  const p = holdPtrRef.current;
1703
3492
  if (!p || p.id !== e.pointerId) return;
1704
3493
  if (p.y - e.clientY >= 40) {
1705
3494
  holdPtrRef.current = null;
3495
+ toggleHoldRef.current = false;
3496
+ clearBreakTimer();
3497
+ setHolding(false);
1706
3498
  engineRef.current?.endHeld(true);
1707
3499
  bus.setUi({ partial: "" });
3500
+ suppressClickUntilRef.current = Date.now() + 500;
1708
3501
  }
1709
3502
  };
1710
3503
  const onPointerUp = (e) => {
1711
3504
  const p = holdPtrRef.current;
1712
3505
  holdPtrRef.current = null;
3506
+ clearBreakTimer();
3507
+ setHolding(false);
1713
3508
  if (!p || p.id !== e.pointerId) return;
1714
3509
  const ms = Date.now() - p.t;
3510
+ if (bootNow().mode !== "hold") {
3511
+ if (toggleHoldRef.current) {
3512
+ toggleHoldRef.current = false;
3513
+ if (ms >= 250) {
3514
+ suppressClickUntilRef.current = Date.now() + 500;
3515
+ engineRef.current?.endHeld(false);
3516
+ } else {
3517
+ engineRef.current?.endHeld(true);
3518
+ }
3519
+ }
3520
+ return;
3521
+ }
1715
3522
  if (ms < 250) {
1716
3523
  if (localRef.current === "on") {
1717
3524
  engineRef.current?.endHeld(true);
@@ -1725,12 +3532,16 @@ function MicButton({
1725
3532
  };
1726
3533
  const onPointerCancel = () => {
1727
3534
  holdPtrRef.current = null;
3535
+ toggleHoldRef.current = false;
3536
+ clearBreakTimer();
3537
+ setHolding(false);
1728
3538
  engineRef.current?.endHeld(true);
1729
3539
  };
1730
3540
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
1731
3541
  "button",
1732
3542
  {
1733
3543
  onClick: (e) => {
3544
+ if (Date.now() < suppressClickUntilRef.current) return;
1734
3545
  if (holdMode) {
1735
3546
  if (e.detail !== 0) return;
1736
3547
  }
@@ -1740,29 +3551,34 @@ function MicButton({
1740
3551
  onPointerMove,
1741
3552
  onPointerUp,
1742
3553
  onPointerCancel,
3554
+ onContextMenu: (e) => e.preventDefault(),
1743
3555
  "data-dshvm": "mic",
1744
3556
  "aria-label": on ? t("ariaActive") : t("ariaEnter"),
1745
3557
  "aria-pressed": on,
1746
3558
  title: on ? holdMode ? t("titleHold") : t("titleToggle") : t("titleEnter"),
1747
3559
  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",
3560
+ 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)",
3561
+ 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
3562
  cursor: "pointer",
1751
- padding: "4px 8px",
3563
+ padding: "5px 10px",
1752
3564
  borderRadius: 8,
1753
3565
  display: "flex",
1754
3566
  alignItems: "center",
1755
3567
  gap: 6,
1756
- fontSize: 11,
3568
+ fontSize: 12,
1757
3569
  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",
3570
+ color: holding ? "#f85149" : on ? holdMode ? "#58a6ff" : "#3fb950" : local === "pending" ? "#58a6ff" : "#8b949e",
3571
+ transition: "background 0.15s ease, color 0.2s ease, border-color 0.15s ease",
1760
3572
  touchAction: "none",
1761
3573
  // 触摸设备上让 pointer 事件独占(滑出取消可用)
1762
- userSelect: "none"
3574
+ userSelect: "none",
3575
+ WebkitUserSelect: "none",
3576
+ // iOS Safari 前缀,防长按选中文字
3577
+ WebkitTouchCallout: "none"
3578
+ // iOS 长按弹出「拷贝/选择」菜单
1763
3579
  },
1764
3580
  children: [
1765
- /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("svg", { viewBox: "0 0 24 24", width: 14, height: 14, "aria-hidden": "true", children: [
3581
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("svg", { viewBox: "0 0 24 24", width: 16, height: 16, "aria-hidden": "true", children: [
1766
3582
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1767
3583
  "path",
1768
3584
  {
@@ -1790,15 +3606,38 @@ function VoiceStatusBar({ bus, sessionId }) {
1790
3606
  }, [bus]);
1791
3607
  const isActive = b.active === sessionId;
1792
3608
  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");
3609
+ const stateText = b.ui.state === "loading-model" ? t("loadingModel") : b.ui.state === "transcribing" ? t("recognizing") : b.ui.state === "wake" ? t("sayWake").replace("{wake}", b.ui.wakeWord || t("wakeWord")) : 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
3610
  const bars = Array.from({ length: WAVE_BARS }, (_, i) => b.ui.levels[i] ?? 0);
3611
+ const telParts = [];
3612
+ const fmt = (ms) => ms >= 1e3 ? `${(ms / 1e3).toFixed(2)}s` : `${Math.round(ms)}ms`;
3613
+ const tel = b.ui.telemetry;
3614
+ if (tel) {
3615
+ for (let i = 1; i < TELEMETRY_VIEW.length; i++) {
3616
+ const cur = tel[TELEMETRY_VIEW[i].stage];
3617
+ const prev = tel[TELEMETRY_VIEW[i - 1].stage];
3618
+ if (cur === void 0 || prev === void 0) continue;
3619
+ telParts.push(`${t(TELEMETRY_VIEW[i].key)} ${fmt(cur - prev)}`);
3620
+ }
3621
+ const begin = tel["utterance-end"];
3622
+ const end = tel["first-audio-played"];
3623
+ if (begin !== void 0 && end !== void 0) telParts.push(`${t("telTotal")} ${fmt(end - begin)}`);
3624
+ }
3625
+ if (b.ui.interruptConfirmMs !== void 0) {
3626
+ telParts.push(`${t("interruptConfirm")} ${fmt(b.ui.interruptConfirmMs)}`);
3627
+ }
3628
+ if (b.ui.echoLevels) {
3629
+ const el = b.ui.echoLevels;
3630
+ telParts.push(
3631
+ `AEC delay=${Math.round(b.ui.echoDelayMs ?? 0)}ms floor=${el.floorRms.toFixed(4)} resid=${el.residualRms.toFixed(4)}`
3632
+ );
3633
+ }
1795
3634
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
1796
3635
  "div",
1797
3636
  {
1798
3637
  style: {
1799
3638
  display: "flex",
1800
- alignItems: "center",
1801
- gap: 8,
3639
+ flexDirection: "column",
3640
+ gap: 2,
1802
3641
  padding: "6px 12px",
1803
3642
  borderRadius: 10,
1804
3643
  fontSize: 12,
@@ -1809,34 +3648,84 @@ function VoiceStatusBar({ bus, sessionId }) {
1809
3648
  animation: "dshvm-fadein 0.2s ease"
1810
3649
  },
1811
3650
  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
3651
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: { display: "flex", alignItems: "center", gap: 8 }, children: [
3652
+ /* @__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)(
3653
+ "span",
3654
+ {
3655
+ className: "dshvm-bar",
3656
+ style: {
3657
+ height: `${3 + v * 12}px`,
3658
+ background: "#3fb950",
3659
+ opacity: 0.4 + v * 0.6
3660
+ }
3661
+ },
3662
+ i
3663
+ )) }),
3664
+ /* @__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 }),
3665
+ b.ui.isSpeech === true && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
3666
+ "span",
3667
+ {
3668
+ title: t("vadDetected"),
3669
+ style: {
3670
+ flexShrink: 0,
3671
+ padding: "0 6px",
3672
+ borderRadius: 8,
3673
+ fontSize: 10,
3674
+ lineHeight: "16px",
3675
+ color: "#ffa657",
3676
+ background: "rgba(255, 166, 87, 0.15)",
3677
+ border: "1px solid rgba(255, 166, 87, 0.35)"
3678
+ },
3679
+ children: t("vadDetected")
1820
3680
  }
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",
3681
+ ),
3682
+ b.ui.aecOff === true && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
3683
+ "span",
3684
+ {
3685
+ title: t("aecOffHint"),
3686
+ style: {
3687
+ flexShrink: 0,
3688
+ padding: "0 6px",
3689
+ borderRadius: 8,
3690
+ fontSize: 10,
3691
+ lineHeight: "16px",
3692
+ color: "#ffa657",
3693
+ background: "rgba(255, 166, 87, 0.15)",
3694
+ border: "1px solid rgba(255, 166, 87, 0.35)"
3695
+ },
3696
+ children: t("aecOff")
3697
+ }
3698
+ ),
3699
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
3700
+ "button",
3701
+ {
3702
+ onClick: () => {
3703
+ void bus.exit(sessionId);
3704
+ },
3705
+ style: {
3706
+ border: "none",
3707
+ background: "transparent",
3708
+ color: "#8b949e",
3709
+ cursor: "pointer",
3710
+ fontSize: 12,
3711
+ flexShrink: 0
3712
+ },
3713
+ children: t("exit")
3714
+ }
3715
+ )
3716
+ ] }),
3717
+ telParts.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
3718
+ "div",
1827
3719
  {
1828
- onClick: () => {
1829
- void bus.exit(sessionId);
1830
- },
1831
3720
  style: {
1832
- border: "none",
1833
- background: "transparent",
3721
+ fontSize: 11,
1834
3722
  color: "#8b949e",
1835
- cursor: "pointer",
1836
- fontSize: 12,
1837
- flexShrink: 0
3723
+ fontVariantNumeric: "tabular-nums",
3724
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
3725
+ whiteSpace: "nowrap",
3726
+ overflowX: "auto"
1838
3727
  },
1839
- children: t("exit")
3728
+ children: telParts.join(" \xB7 ")
1840
3729
  }
1841
3730
  )
1842
3731
  ]
@@ -1857,7 +3746,8 @@ function VoiceOverlay({ bus }) {
1857
3746
  style: {
1858
3747
  position: "fixed",
1859
3748
  right: 16,
1860
- bottom: 16,
3749
+ bottom: 96,
3750
+ // 上移,避免盖住底部输入框/麦克风按钮
1861
3751
  zIndex: 9999,
1862
3752
  display: "flex",
1863
3753
  alignItems: "center",
@@ -1866,7 +3756,8 @@ function VoiceOverlay({ bus }) {
1866
3756
  borderRadius: 999,
1867
3757
  fontSize: 12,
1868
3758
  fontFamily: "system-ui, sans-serif",
1869
- pointerEvents: "auto",
3759
+ pointerEvents: "none",
3760
+ // 浮层不挡输入框/麦克风按钮的点击(仅内部「跳过」按钮可点)
1870
3761
  background: "rgba(22, 24, 28, 0.85)",
1871
3762
  backdropFilter: "blur(14px)",
1872
3763
  WebkitBackdropFilter: "blur(14px)",
@@ -1904,7 +3795,9 @@ function VoiceOverlay({ bus }) {
1904
3795
  padding: "3px 12px",
1905
3796
  fontSize: 11,
1906
3797
  cursor: "pointer",
1907
- flexShrink: 0
3798
+ flexShrink: 0,
3799
+ pointerEvents: "auto"
3800
+ // 仅此按钮可点
1908
3801
  },
1909
3802
  children: t("skip")
1910
3803
  }