dsh-voice-mode 0.6.0 → 0.7.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
@@ -72,9 +72,288 @@ function matchWakeWord(partial, wakeWord) {
72
72
  return false;
73
73
  }
74
74
 
75
+ // src/fixture-recorder.ts
76
+ var FLAG = "dsh-voice-mode.record";
77
+ var MAX_SECONDS = 180;
78
+ var SAMPLE_RATE = 16e3;
79
+ function recordMode() {
80
+ try {
81
+ const v = localStorage.getItem(FLAG);
82
+ if (v === "full") return "full";
83
+ if (v === "meta" || v === "1") return "meta";
84
+ } catch {
85
+ }
86
+ return "off";
87
+ }
88
+ var Track = class {
89
+ chunks = [];
90
+ total = 0;
91
+ push(f) {
92
+ const out = new Int16Array(f.length);
93
+ for (let i = 0; i < f.length; i++) {
94
+ const v = Math.max(-1, Math.min(1, f[i]));
95
+ out[i] = v < 0 ? v * 32768 : v * 32767;
96
+ }
97
+ this.chunks.push(out);
98
+ this.total += out.length;
99
+ }
100
+ get length() {
101
+ return this.total;
102
+ }
103
+ toBase64() {
104
+ const all = new Int16Array(this.total);
105
+ let off = 0;
106
+ for (const c of this.chunks) {
107
+ all.set(c, off);
108
+ off += c.length;
109
+ }
110
+ const bytes = new Uint8Array(all.buffer);
111
+ let bin = "";
112
+ const STEP = 32768;
113
+ for (let i = 0; i < bytes.length; i += STEP) {
114
+ bin += String.fromCharCode(...bytes.subarray(i, i + STEP));
115
+ }
116
+ return btoa(bin);
117
+ }
118
+ clear() {
119
+ this.chunks = [];
120
+ this.total = 0;
121
+ }
122
+ };
123
+ var FixtureRecorder = class {
124
+ mode = "off";
125
+ active = false;
126
+ startedAt = 0;
127
+ frames = [];
128
+ marks = [];
129
+ detects = [];
130
+ micTrack = new Track();
131
+ refTrack = new Track();
132
+ resTrack = new Track();
133
+ /** 残差与 mic 是否出现过差异(原生 AEC 失效时自研 NLMS 生效)——决定是否落残差轨。 */
134
+ resDiffers = false;
135
+ env = {};
136
+ badge = null;
137
+ userSpeaking = false;
138
+ keyHandler = null;
139
+ get isActive() {
140
+ return this.active;
141
+ }
142
+ /** 进入语音模式时调用。档位为 off 时什么都不做。 */
143
+ begin(env) {
144
+ const mode = recordMode();
145
+ if (mode === "off") {
146
+ this.mode = "off";
147
+ return;
148
+ }
149
+ if (this.active) this.save("restart");
150
+ this.mode = mode;
151
+ this.active = true;
152
+ this.startedAt = Date.now();
153
+ this.frames = [];
154
+ this.marks = [];
155
+ this.detects = [];
156
+ this.micTrack.clear();
157
+ this.refTrack.clear();
158
+ this.resTrack.clear();
159
+ this.resDiffers = false;
160
+ this.userSpeaking = false;
161
+ this.env = env;
162
+ this.mark("begin", mode);
163
+ this.mountBadge();
164
+ this.bindKeys();
165
+ try {
166
+ ;
167
+ window.__dshvmRec = {
168
+ \u8BF4\u8BDD\u5F00\u59CB: () => this.setUserSpeaking(true),
169
+ \u8BF4\u8BDD\u7ED3\u675F: () => this.setUserSpeaking(false),
170
+ \u4FDD\u5B58: () => this.save("console"),
171
+ \u6807\u6CE8: (kind, note) => this.mark(kind, note),
172
+ \u72B6\u6001: () => ({ mode: this.mode, \u5E27\u6570: this.frames.length, \u6807\u6CE8\u6570: this.marks.length, \u8BF4\u8BDD\u4E2D: this.userSpeaking })
173
+ };
174
+ } catch {
175
+ }
176
+ console.log(
177
+ `[dsh-voice][rec] \u5F00\u59CB\u5F55\u5236\uFF08${mode}\uFF09\xB7 F8=\u6807\u6CE8\u8BF4\u8BDD \xB7 F9=\u4FDD\u5B58\u4E0B\u8F7D
178
+ [dsh-voice][rec] \u952E\u76D8\u4E0D\u7075\u65F6\u7528\u63A7\u5236\u53F0\uFF1A__dshvmRec.\u8BF4\u8BDD\u5F00\u59CB() / .\u8BF4\u8BDD\u7ED3\u675F() / .\u4FDD\u5B58() / .\u72B6\u6001()`
179
+ );
180
+ }
181
+ /**
182
+ * 逐帧写入。在 asr.ts handleAudio 里 AEC 与门控统计算完之后调用。
183
+ * micPre = AEC 前(重采样后);ref = 参考窗;res = AEC 后残差(判定链实际输入)。
184
+ */
185
+ frame(micPre, ref, res, stats) {
186
+ if (!this.active) return;
187
+ const t3 = Date.now() - this.startedAt;
188
+ if (t3 > MAX_SECONDS * 1e3) {
189
+ this.mark("auto-stop", `\u5230\u8FBE ${MAX_SECONDS}s \u4E0A\u9650`);
190
+ this.save("maxlen");
191
+ return;
192
+ }
193
+ this.frames.push({
194
+ t: t3,
195
+ rms: round6(stats.rms),
196
+ mic: round6(rmsOf(micPre)),
197
+ ref: round6(ref ? rmsOf(ref) : 0),
198
+ fl: round6(stats.floorRms),
199
+ pk: round6(stats.peakRms),
200
+ dt: stats.doubleTalk ? 1 : 0,
201
+ pt: stats.playingTail ? 1 : 0
202
+ });
203
+ if (this.mode === "full") {
204
+ this.micTrack.push(micPre);
205
+ this.refTrack.push(ref ?? new Float32Array(micPre.length));
206
+ if (res !== micPre) {
207
+ if (!this.resDiffers) this.resDiffers = true;
208
+ this.resTrack.push(res);
209
+ } else if (this.resDiffers) {
210
+ this.resTrack.push(res);
211
+ }
212
+ }
213
+ this.refreshBadge();
214
+ }
215
+ /**
216
+ * 检测通道一次往返(A 档埋点):分离「请求在途慢」与「客户端没排上」。
217
+ * 停顿归因的唯一依据——回报间隔 = 往返耗时 + 客户端等待,此处记的是前者。
218
+ */
219
+ noteDetect(sentAt, rttMs, ok, samples) {
220
+ if (!this.active) return;
221
+ this.detects.push({ t: sentAt - this.startedAt, rtt: rttMs, ok: ok ? 1 : 0, n: samples });
222
+ }
223
+ /** host 下行 isSpeech:盖在最近一帧上。 */
224
+ noteIsSpeech(speech) {
225
+ if (!this.active || speech === void 0) return;
226
+ const last = this.frames[this.frames.length - 1];
227
+ if (last) last.spk = speech ? 1 : 0;
228
+ }
229
+ /** 播放态(裸口径)变化 / 打断触发 / 句子边界等事件。 */
230
+ mark(kind, note) {
231
+ if (!this.active && kind !== "begin") return;
232
+ this.marks.push({ t: Date.now() - this.startedAt, kind, note });
233
+ }
234
+ /** 保存并触发下载。reason 只用于文件名与日志。 */
235
+ save(reason = "manual") {
236
+ if (!this.active) return;
237
+ this.active = false;
238
+ this.unbindKeys();
239
+ this.unmountBadge();
240
+ const durationMs = Date.now() - this.startedAt;
241
+ const payload = {
242
+ schema: "dsh-voice-mode/fixture@1",
243
+ recordedAt: new Date(this.startedAt).toISOString(),
244
+ reason,
245
+ mode: this.mode,
246
+ sampleRate: SAMPLE_RATE,
247
+ durationMs,
248
+ env: this.env,
249
+ frames: this.frames,
250
+ marks: this.marks,
251
+ detects: this.detects
252
+ };
253
+ if (this.mode === "full" && this.micTrack.length > 0) {
254
+ payload.audio = {
255
+ encoding: "int16le-base64",
256
+ mic: this.micTrack.toBase64(),
257
+ ref: this.refTrack.toBase64(),
258
+ ...this.resDiffers ? { res: this.resTrack.toBase64() } : {}
259
+ };
260
+ }
261
+ const name = `dshvm-fixture-${new Date(this.startedAt).toISOString().replace(/[:.]/g, "-")}-${reason}.json`;
262
+ try {
263
+ const blob = new Blob([JSON.stringify(payload)], { type: "application/json" });
264
+ const url = URL.createObjectURL(blob);
265
+ const a = document.createElement("a");
266
+ a.href = url;
267
+ a.download = name;
268
+ document.body.appendChild(a);
269
+ a.click();
270
+ a.remove();
271
+ setTimeout(() => URL.revokeObjectURL(url), 1e4);
272
+ console.log(
273
+ `[dsh-voice][rec] \u5DF2\u4FDD\u5B58 ${name}\uFF1A${this.frames.length} \u5E27 / ${(durationMs / 1e3).toFixed(1)}s / \u6807\u6CE8 ${this.marks.length} \u6761 / \u68C0\u6D4B\u5F80\u8FD4 ${this.detects.length} \u6B21`
274
+ );
275
+ } catch (e) {
276
+ console.warn("[dsh-voice][rec] \u4FDD\u5B58\u5931\u8D25\uFF1A" + String(e));
277
+ }
278
+ this.micTrack.clear();
279
+ this.refTrack.clear();
280
+ this.resTrack.clear();
281
+ }
282
+ /** 说话区间标注(键盘与控制台入口共用;重复置同一状态不产生重复标注)。 */
283
+ setUserSpeaking(on) {
284
+ if (!this.active || this.userSpeaking === on) return;
285
+ this.userSpeaking = on;
286
+ this.mark(on ? "user-speech-start" : "user-speech-end");
287
+ console.log(`[dsh-voice][rec] \u6807\u6CE8\uFF1A${on ? "\u5F00\u59CB\u8BF4\u8BDD" : "\u8BF4\u5B8C\u4E86"}`);
288
+ this.refreshBadge();
289
+ }
290
+ // ── 键盘标注 ──
291
+ bindKeys() {
292
+ this.keyHandler = (e) => {
293
+ if (e.key === "F8") {
294
+ e.preventDefault();
295
+ this.setUserSpeaking(!this.userSpeaking);
296
+ } else if (e.key === "F9") {
297
+ e.preventDefault();
298
+ this.save("hotkey");
299
+ }
300
+ };
301
+ window.addEventListener("keydown", this.keyHandler, true);
302
+ }
303
+ unbindKeys() {
304
+ if (this.keyHandler) window.removeEventListener("keydown", this.keyHandler, true);
305
+ this.keyHandler = null;
306
+ }
307
+ // ── 徽标(自包含 DOM,不接 React)──
308
+ mountBadge() {
309
+ try {
310
+ const el = document.createElement("div");
311
+ el.style.cssText = [
312
+ "position:fixed",
313
+ "top:8px",
314
+ "right:8px",
315
+ "z-index:2147483647",
316
+ "padding:6px 10px",
317
+ "border-radius:6px",
318
+ "background:rgba(180,20,20,.92)",
319
+ "color:#fff",
320
+ "font:12px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace",
321
+ "pointer-events:none",
322
+ "white-space:pre"
323
+ ].join(";");
324
+ document.body.appendChild(el);
325
+ this.badge = el;
326
+ this.refreshBadge();
327
+ } catch {
328
+ }
329
+ }
330
+ refreshBadge() {
331
+ if (!this.badge) return;
332
+ const s = ((Date.now() - this.startedAt) / 1e3).toFixed(1);
333
+ const last = this.frames[this.frames.length - 1];
334
+ this.badge.textContent = `\u25CF REC ${this.mode} ${s}s ${this.frames.length}\u5E27` + (this.userSpeaking ? " [\u8BF4\u8BDD\u4E2D]" : "") + (last ? `
335
+ resid ${last.rms.toFixed(4)} floor ${last.fl.toFixed(4)} peak ${last.pk.toFixed(4)}` : "") + "\nF8 \u6807\u6CE8\u8BF4\u8BDD \xB7 F9 \u4FDD\u5B58";
336
+ }
337
+ unmountBadge() {
338
+ try {
339
+ this.badge?.remove();
340
+ } catch {
341
+ }
342
+ this.badge = null;
343
+ }
344
+ };
345
+ function rmsOf(x) {
346
+ if (x.length === 0) return 0;
347
+ let s = 0;
348
+ for (let i = 0; i < x.length; i++) s += x[i] * x[i];
349
+ return Math.sqrt(s / x.length);
350
+ }
351
+ var round6 = (v) => Math.round(v * 1e6) / 1e6;
352
+ var fixtureRecorder = new FixtureRecorder();
353
+
75
354
  // src/asr.ts
76
355
  var workletBlobUrl = null;
77
- var SAMPLE_RATE = 16e3;
356
+ var SAMPLE_RATE2 = 16e3;
78
357
  var SPEECH_RMS = 0.015;
79
358
  var LEVEL_CEILING = 0.25;
80
359
  var MAX_SEGMENT_MS = 3e4;
@@ -120,7 +399,7 @@ function createAsrEngine(config, sessionId) {
120
399
  let stopRequested = false;
121
400
  let startSeq = 0;
122
401
  let inFlush = false;
123
- let ctxRate = SAMPLE_RATE;
402
+ let ctxRate = SAMPLE_RATE2;
124
403
  let speechActive = false;
125
404
  let segment = [];
126
405
  let segmentMs = 0;
@@ -179,7 +458,7 @@ function createAsrEngine(config, sessionId) {
179
458
  const requestPartial = async () => {
180
459
  if (partialInFlight || segment.length === 0) return;
181
460
  const total = segment.reduce((n, c) => n + c.length, 0);
182
- const seconds = total / SAMPLE_RATE;
461
+ const seconds = total / SAMPLE_RATE2;
183
462
  if (seconds < PARTIAL_MIN_S || seconds > PARTIAL_MAX_S) return;
184
463
  const from = uploadedSamples;
185
464
  if (total - from <= 0) return;
@@ -257,7 +536,7 @@ function createAsrEngine(config, sessionId) {
257
536
  const requestDetect = async () => {
258
537
  if (detectInFlight) return;
259
538
  let total = detectChunks.reduce((n, c) => n + c.length, 0);
260
- const MAX_DETECT_PENDING = 30 * SAMPLE_RATE;
539
+ const MAX_DETECT_PENDING = 1 * SAMPLE_RATE2;
261
540
  if (total - detectSent > MAX_DETECT_PENDING) {
262
541
  detectSent = Math.max(0, total - MAX_DETECT_PENDING);
263
542
  }
@@ -271,6 +550,8 @@ function createAsrEngine(config, sessionId) {
271
550
  const epoch = segmentEpoch;
272
551
  const gen = detectGeneration;
273
552
  detectInFlight = true;
553
+ const sentAt = Date.now();
554
+ let rttMs = -1;
274
555
  try {
275
556
  const res = await fetch(asrUrl(false, detectSent, epoch) + "&vadOnly=1", {
276
557
  method: "POST",
@@ -279,6 +560,7 @@ function createAsrEngine(config, sessionId) {
279
560
  signal: AbortSignal.timeout(5e3)
280
561
  // 防服务端挂起长期锁死 detectInFlight(Important#2)
281
562
  });
563
+ rttMs = Date.now() - sentAt;
282
564
  if (epoch !== segmentEpoch || gen !== detectGeneration) return;
283
565
  if (!res.ok) return;
284
566
  const out = await res.json();
@@ -292,6 +574,9 @@ function createAsrEngine(config, sessionId) {
292
574
  } catch {
293
575
  } finally {
294
576
  detectInFlight = false;
577
+ if (fixtureRecorder.isActive) {
578
+ fixtureRecorder.noteDetect(sentAt, rttMs < 0 ? Date.now() - sentAt : rttMs, rttMs >= 0, samples.length);
579
+ }
295
580
  }
296
581
  };
297
582
  const resetHostStream = async () => {
@@ -409,15 +694,18 @@ function createAsrEngine(config, sessionId) {
409
694
  let echoPeak = 0;
410
695
  const handleAudio = (raw) => {
411
696
  if (!active || inFlush) return;
412
- let data = ctxRate !== SAMPLE_RATE ? resampleLinear(raw, ctxRate, SAMPLE_RATE) : raw;
697
+ let data = ctxRate !== SAMPLE_RATE2 ? resampleLinear(raw, ctxRate, SAMPLE_RATE2) : raw;
698
+ const recMicPre = data;
699
+ let recRef = null;
413
700
  if (echo) {
414
701
  const ref = echo.windowAt(performance.now(), data.length);
702
+ recRef = ref;
415
703
  data = echo.process(data, ref);
416
704
  }
417
705
  let sum = 0;
418
706
  for (let i = 0; i < data.length; i++) sum += data[i] * data[i];
419
707
  const rms = Math.sqrt(sum / data.length);
420
- const durationMs = data.length / SAMPLE_RATE * 1e3;
708
+ const durationMs = data.length / SAMPLE_RATE2 * 1e3;
421
709
  for (const fn of levelListeners) {
422
710
  try {
423
711
  fn(Math.min(1, rms / LEVEL_CEILING));
@@ -442,6 +730,15 @@ function createAsrEngine(config, sessionId) {
442
730
  if (echo) {
443
731
  echo.setFrozen(doubleTalk);
444
732
  }
733
+ if (fixtureRecorder.isActive) {
734
+ fixtureRecorder.frame(recMicPre, recRef, data, {
735
+ rms,
736
+ floorRms: echoFloorRms,
737
+ peakRms: echoPeak,
738
+ doubleTalk,
739
+ playingTail: playingNow
740
+ });
741
+ }
445
742
  if (playingNow && !holdActive && config.mode !== "hold") detectChunks.push(data);
446
743
  if (holdActive) {
447
744
  if (!speechActive) {
@@ -468,23 +765,23 @@ function createAsrEngine(config, sessionId) {
468
765
  } else if (rms > SPEECH_RMS) {
469
766
  if (config.isPlaying?.()) {
470
767
  if (speechActive) finalizeSegment(true);
471
- return;
472
- }
473
- if (!speechActive) {
474
- speechActive = true;
475
- detectChunks = [];
476
- detectSent = 0;
477
- detectGeneration++;
478
- utteranceEndAt = null;
479
- setState("speech");
480
- for (const p of prePad) segment.push(p);
481
- prePad = [];
768
+ } else {
769
+ if (!speechActive) {
770
+ speechActive = true;
771
+ detectChunks = [];
772
+ detectSent = 0;
773
+ detectGeneration++;
774
+ utteranceEndAt = null;
775
+ setState("speech");
776
+ for (const p of prePad) segment.push(p);
777
+ prePad = [];
778
+ }
779
+ speechMs += durationMs;
780
+ segmentMs += durationMs;
781
+ silenceMs = 0;
782
+ segment.push(data);
783
+ if (segmentMs > MAX_SEGMENT_MS) finalizeSegment();
482
784
  }
483
- speechMs += durationMs;
484
- segmentMs += durationMs;
485
- silenceMs = 0;
486
- segment.push(data);
487
- if (segmentMs > MAX_SEGMENT_MS) finalizeSegment();
488
785
  } else if (speechActive) {
489
786
  if (utteranceEndAt === null) {
490
787
  utteranceEndAt = Date.now();
@@ -515,7 +812,7 @@ function createAsrEngine(config, sessionId) {
515
812
  let total = 0;
516
813
  let cut = 0;
517
814
  for (let i = prePad.length - 1; i >= 0; i--) {
518
- total += prePad[i].length / SAMPLE_RATE * 1e3;
815
+ total += prePad[i].length / SAMPLE_RATE2 * 1e3;
519
816
  if (total > PRE_PAD_MS) {
520
817
  cut = i + 1;
521
818
  break;
@@ -526,11 +823,15 @@ function createAsrEngine(config, sessionId) {
526
823
  const nowMs = Date.now();
527
824
  if (nowMs - lastPollAt >= PARTIAL_INTERVAL_MS) {
528
825
  if (playingNow && !speechActive && !holdActive) {
529
- lastPollAt = nowMs;
530
- void requestDetect();
826
+ if (!detectInFlight) {
827
+ lastPollAt = nowMs;
828
+ void requestDetect();
829
+ }
531
830
  } else if (speechActive || holdActive || state === "wake") {
532
- lastPollAt = nowMs;
533
- void requestPartial();
831
+ if (!partialInFlight) {
832
+ lastPollAt = nowMs;
833
+ void requestPartial();
834
+ }
534
835
  }
535
836
  }
536
837
  };
@@ -559,7 +860,7 @@ function createAsrEngine(config, sessionId) {
559
860
  }
560
861
  config.onAecState?.(aecOn);
561
862
  const AC = window.AudioContext ?? window.webkitAudioContext;
562
- audioCtx = new AC({ sampleRate: SAMPLE_RATE });
863
+ audioCtx = new AC({ sampleRate: SAMPLE_RATE2 });
563
864
  try {
564
865
  await audioCtx.resume?.();
565
866
  } catch {
@@ -578,7 +879,7 @@ function createAsrEngine(config, sessionId) {
578
879
  };
579
880
  source.connect(workletNode);
580
881
  workletNode.connect(audioCtx.destination);
581
- ctxRate = SAMPLE_RATE;
882
+ ctxRate = SAMPLE_RATE2;
582
883
  active = true;
583
884
  return;
584
885
  } catch {
@@ -633,7 +934,7 @@ function createAsrEngine(config, sessionId) {
633
934
  } catch {
634
935
  }
635
936
  audioCtx = null;
636
- ctxRate = SAMPLE_RATE;
937
+ ctxRate = SAMPLE_RATE2;
637
938
  inFlush = false;
638
939
  };
639
940
  return {
@@ -681,7 +982,7 @@ function createAsrEngine(config, sessionId) {
681
982
  setState("idle");
682
983
  },
683
984
  forceSend() {
684
- const speechS = segment.reduce((n, c) => n + c.length, 0) / SAMPLE_RATE;
985
+ const speechS = segment.reduce((n, c) => n + c.length, 0) / SAMPLE_RATE2;
685
986
  if (speechActive && speechS >= 0.25) {
686
987
  forcePending = true;
687
988
  lastPollAt = 0;
@@ -2184,7 +2485,7 @@ var TELEMETRY_VIEW = [
2184
2485
  { stage: "first-tts-chunk", key: "telFirstChunk" },
2185
2486
  { stage: "first-audio-played", key: "telFirstPlayed" }
2186
2487
  ];
2187
- var BUILD_TAG = "d4c61da";
2488
+ var BUILD_TAG = "054e486";
2188
2489
  var TELEMETRY_FLAG = "dsh-voice-mode.telemetry";
2189
2490
  var telemetryEnabled = typeof localStorage !== "undefined" && localStorage.getItem(TELEMETRY_FLAG) === "1";
2190
2491
  console.log("[dsh-voice] build=" + BUILD_TAG);
@@ -2377,6 +2678,7 @@ function createAudioEngine(setUi, onPlayed, onPlaybackRef, onAllPlayed) {
2377
2678
  }
2378
2679
  };
2379
2680
  setUi({ playing: true, playingCaption: frame.text, ttsNotice: null });
2681
+ fixtureRecorder.mark("tts-sentence", frame.text);
2380
2682
  void fallbackAudio.play().catch(() => playFallback());
2381
2683
  };
2382
2684
  const drainPending = () => {
@@ -2419,6 +2721,7 @@ function createAudioEngine(setUi, onPlayed, onPlaybackRef, onAllPlayed) {
2419
2721
  }
2420
2722
  captionQueue.push(frame.text);
2421
2723
  setUi({ playing: true, playingCaption: captionQueue[0], ttsNotice: null });
2724
+ fixtureRecorder.mark("tts-sentence", frame.text);
2422
2725
  }
2423
2726
  } catch {
2424
2727
  for (const src of activeSrcs) {
@@ -2911,6 +3214,16 @@ function useVoiceCss() {
2911
3214
  @keyframes dshvm-eq { 0%, 100% { transform: scaleY(0.35) } 50% { transform: scaleY(1) } }
2912
3215
  @keyframes dshvm-spin { to { transform: rotate(360deg) } }
2913
3216
  .dshvm-bar { width: 3px; border-radius: 99px; transition: height 0.08s linear, opacity 0.08s linear }
3217
+ /* \u9EA6\u514B\u98CE\u6309\u94AE\u6240\u5728\u7684\u5BBF\u4E3B\u5BB9\u5668\u4E5F\u7981\u9009\uFF1A\u624B\u6307\u504F\u5927\u65F6\u957F\u6309\u53EF\u80FD\u547D\u4E2D\u6309\u94AE\u5916\u4FA7\u7684\u5BB9\u5668\u7559\u767D\uFF0C
3218
+ \u6D4F\u89C8\u5668\u5C31\u8FD1\u9009\u4E2D\u300C\u8BED\u97F3\u300D\u6807\u7B7E\u6587\u5B57\u3002 */
3219
+ :has(> [data-dshvm="mic"]) { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none }
3220
+ /* \u6309\u4F4F\u8BF4\u8BDD\u671F\u95F4\u6574\u9875\u7981\u9009\uFF08!important \u538B\u8FC7\u5BBF\u4E3B\u6837\u5F0F\uFF09\uFF1A\u5B89\u5353/\u684C\u9762\u5728\u957F\u6309\u6216\u6309\u4F4F\u5FAE\u62D6\u65F6
3221
+ \u4F1A\u4ECE\u6309\u94AE\u9644\u8FD1\u5F00\u59CB\u9009\u533A\uFF0C\u51FA\u73B0\u84DD\u8272\u9AD8\u4EAE\u548C\u9009\u62E9\u624B\u67C4\uFF0C\u5BFC\u81F4\u300C\u6309\u4F4F\u8BF4\u8BDD\u300D\u4E0D\u53EF\u7528\u3002 */
3222
+ html.dshvm-holding, html.dshvm-holding * {
3223
+ -webkit-user-select: none !important;
3224
+ user-select: none !important;
3225
+ -webkit-touch-callout: none !important;
3226
+ }
2914
3227
  `;
2915
3228
  document.head.appendChild(el);
2916
3229
  }, []);
@@ -3022,6 +3335,7 @@ function MicButton({
3022
3335
  clearIdle();
3023
3336
  cancelPendingSubmit();
3024
3337
  isSpeechTrueCount = 0;
3338
+ fixtureRecorder.save("exit");
3025
3339
  breakRef.current = null;
3026
3340
  manualHoldRef.current = false;
3027
3341
  clearBreakTimer();
@@ -3103,6 +3417,7 @@ function MicButton({
3103
3417
  // 判真实人声前沿;仅 AI 朗读中(bus.ui.playing)触发 hardBreak,
3104
3418
  // 防 TTS 回声被 VAD 误判为语音而自打断。
3105
3419
  onIsSpeech: (speech) => {
3420
+ fixtureRecorder.noteIsSpeech(speech);
3106
3421
  if (bargeInMode === "manual") return;
3107
3422
  if (!bus.ui.playing) {
3108
3423
  isSpeechTrueCount = 0;
@@ -3149,6 +3464,7 @@ function MicButton({
3149
3464
  });
3150
3465
  interruptFirstAt = 0;
3151
3466
  isSpeechTrueCount = 0;
3467
+ fixtureRecorder.mark("interrupt", `confirmMs=${confirmMs}`);
3152
3468
  resetIdle();
3153
3469
  bus.resetTelemetry();
3154
3470
  bus.setUi({ interruptConfirmMs: confirmMs });
@@ -3182,12 +3498,20 @@ function MicButton({
3182
3498
  debugLog("aec-state", { nativeEchoCancellation: on2 });
3183
3499
  bus.setEchoBypass(on2);
3184
3500
  bus.setUi({ aecOff: !on2 });
3501
+ fixtureRecorder.mark("native-aec", on2 ? "on\uFF08\u81EA\u7814 NLMS \u65C1\u8DEF\uFF09" : "off\uFF08\u81EA\u7814 NLMS \u751F\u6548\uFF09");
3185
3502
  }
3186
3503
  },
3187
3504
  sid
3188
3505
  );
3189
3506
  bus.setUi({ mode: cfg.mode });
3190
3507
  engineRef.current = engine;
3508
+ fixtureRecorder.begin({
3509
+ build: BUILD_TAG,
3510
+ mode: cfg.mode,
3511
+ bargeInMode,
3512
+ echoGateDb: cfg.echoGateDb,
3513
+ interruptLevel
3514
+ });
3191
3515
  engine.onTelemetry((e) => bus.stampTelemetry(e.stage, e.at));
3192
3516
  bus.warmAudio();
3193
3517
  try {
@@ -3502,7 +3826,53 @@ function MicButton({
3502
3826
  breakTimerRef.current = null;
3503
3827
  }
3504
3828
  };
3829
+ const selectGuardRef = (0, import_react2.useRef)(null);
3830
+ const unlockSelection = () => {
3831
+ const off = selectGuardRef.current;
3832
+ if (!off) return;
3833
+ selectGuardRef.current = null;
3834
+ off();
3835
+ try {
3836
+ window.getSelection()?.removeAllRanges();
3837
+ } catch {
3838
+ }
3839
+ };
3840
+ const lockSelection = () => {
3841
+ if (selectGuardRef.current) return;
3842
+ const root = document.documentElement;
3843
+ root.classList.add("dshvm-holding");
3844
+ try {
3845
+ window.getSelection()?.removeAllRanges();
3846
+ } catch {
3847
+ }
3848
+ const stopSelect = (ev) => ev.preventDefault();
3849
+ const release = () => unlockSelection();
3850
+ document.addEventListener("selectstart", stopSelect, true);
3851
+ document.addEventListener("contextmenu", stopSelect, true);
3852
+ window.addEventListener("pointerup", release, true);
3853
+ window.addEventListener("pointercancel", release, true);
3854
+ selectGuardRef.current = () => {
3855
+ root.classList.remove("dshvm-holding");
3856
+ document.removeEventListener("selectstart", stopSelect, true);
3857
+ document.removeEventListener("contextmenu", stopSelect, true);
3858
+ window.removeEventListener("pointerup", release, true);
3859
+ window.removeEventListener("pointercancel", release, true);
3860
+ };
3861
+ };
3862
+ (0, import_react2.useEffect)(() => unlockSelection, []);
3863
+ const btnRef = (0, import_react2.useRef)(null);
3864
+ (0, import_react2.useEffect)(() => {
3865
+ const el = btnRef.current;
3866
+ if (!el) return;
3867
+ const onTouchStart = (ev) => {
3868
+ if (bootNow().mode !== "hold") return;
3869
+ if (ev.cancelable) ev.preventDefault();
3870
+ };
3871
+ el.addEventListener("touchstart", onTouchStart, { passive: false });
3872
+ return () => el.removeEventListener("touchstart", onTouchStart);
3873
+ }, []);
3505
3874
  const onPointerDown = (e) => {
3875
+ lockSelection();
3506
3876
  holdPtrRef.current = { t: Date.now(), y: e.clientY, id: e.pointerId };
3507
3877
  e.currentTarget.setPointerCapture?.(e.pointerId);
3508
3878
  if (bootNow().mode === "hold") {
@@ -3596,6 +3966,7 @@ function MicButton({
3596
3966
  onPointerUp,
3597
3967
  onPointerCancel,
3598
3968
  onContextMenu: (e) => e.preventDefault(),
3969
+ ref: btnRef,
3599
3970
  "data-dshvm": "mic",
3600
3971
  "aria-label": on ? t("ariaActive") : t("ariaEnter"),
3601
3972
  "aria-pressed": on,
package/lib/index.js CHANGED
@@ -1968,8 +1968,9 @@ function apply(ctx, config) {
1968
1968
  return next();
1969
1969
  });
1970
1970
  ctx.on("llm/stream", (options, next) => {
1971
- const sessionId = options.sessionId;
1972
- if (!config.enabled || sessionId === void 0 || options.purpose !== void 0) return next();
1971
+ const rawSessionId = options.sessionId;
1972
+ if (!config.enabled || rawSessionId === void 0 || options.purpose !== void 0) return next();
1973
+ const sessionId = rawSessionId;
1973
1974
  if (activeVoiceSession !== sessionId) return next();
1974
1975
  const gen = (turnGen.get(sessionId) ?? 0) + 1;
1975
1976
  turnGen.set(sessionId, gen);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-voice-mode",
3
3
  "description": "Full-duplex voice mode for DeepSeek Harness: zipformer2 streaming ASR → editable draft, Edge TTS (default, fast) or local (VITS / Kokoro int8, privacy-first) read-aloud with live captions, true barge-in; hardened HTTP surface (loopback/origin/rate-limit) + model SHA256 pinning. · DSH 语音双工对话:流式识别入草稿、Edge 默认朗读或本地(VITS / Kokoro int8)朗读+实时字幕、开口即打断;HTTP 面安全加固。",
4
- "version": "0.6.0",
4
+ "version": "0.7.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "repository": {
@@ -33,6 +33,7 @@
33
33
  "scripts/list-voices.mjs",
34
34
  "scripts/bench-asr.mjs",
35
35
  "scripts/verify.mjs",
36
+ "scripts/check-anchors.mjs",
36
37
  "README.md",
37
38
  "README.en.md",
38
39
  "LICENSE"
@@ -45,8 +46,14 @@
45
46
  "prefetch": "node scripts/prefetch.mjs",
46
47
  "list-voices": "node scripts/list-voices.mjs",
47
48
  "bench-asr": "node scripts/bench-asr.mjs",
49
+ "bench:echo-gate": "node scripts/bench-echo-gate.mjs",
50
+ "analyze:fixture": "node scripts/analyze-fixture.mjs",
48
51
  "verify": "node scripts/verify.mjs",
49
- "typecheck": "node node_modules/typescript/bin/tsc -p tsconfig.json --noEmit && node node_modules/typescript/bin/tsc -p tsconfig.client.json --noEmit"
52
+ "typecheck": "node node_modules/typescript/bin/tsc -p tsconfig.json --noEmit && node node_modules/typescript/bin/tsc -p tsconfig.client.json --noEmit",
53
+ "check:anchors": "node scripts/check-anchors.mjs",
54
+ "typecheck:dual": "bash scripts/typecheck-dual.sh",
55
+ "smoke:runtime": "bash scripts/smoke-runtime.sh",
56
+ "verify:dual": "bash scripts/verify-dual.sh"
50
57
  },
51
58
  "dsh": {
52
59
  "bundle": {
@@ -55,8 +62,15 @@
55
62
  "client": {
56
63
  "platform": "web",
57
64
  "inject": [
58
- "@deepseek-ai/dsh-client-runtime",
59
- "@deepseek-ai/dsh-client-ui-slots"
65
+ "@deepseek-ai/dsh-client-connection",
66
+ "@deepseek-ai/dsh-cordis-client-runner",
67
+ "@deepseek-ai/dsh-api-remotes",
68
+ "@deepseek-ai/dsh-client-locale",
69
+ "@deepseek-ai/dsh-client-ui-renderer",
70
+ "@deepseek-ai/dsh-client-ui-conversation",
71
+ "@deepseek-ai/dsh-client-ui-layout",
72
+ "@deepseek-ai/dsh-client-ui-settings",
73
+ "@deepseek-ai/dsh-client-ui-settings-plugins"
60
74
  ]
61
75
  }
62
76
  },
@@ -68,18 +82,18 @@
68
82
  },
69
83
  "peerDependencies": {
70
84
  "@deepseek-ai/cordis": "^4.0.1",
71
- "@deepseek-ai/dsh-web": "^0.1.0-rc.6 || ^0.1.1-rc.0",
72
85
  "react": "^18.2.0"
73
86
  },
74
87
  "devDependencies": {
75
88
  "@deepseek-ai/cordis": "^4.0.1",
76
- "@deepseek-ai/dsh-host-webserver": "^0.1.1-rc.2",
77
- "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
78
- "@deepseek-ai/dsh-settings": "^0.1.1-rc.2",
79
- "@deepseek-ai/dsh-system-prompt": "^0.1.1-rc.2",
89
+ "@deepseek-ai/dsh-host-webserver": "0.1.2-alpha.5",
90
+ "@deepseek-ai/dsh-llm": "0.1.2-alpha.5",
91
+ "@deepseek-ai/dsh-settings": "0.1.2-alpha.5",
92
+ "@deepseek-ai/dsh-system-prompt": "0.1.2-alpha.5",
80
93
  "@types/node": "^26.0.0",
81
94
  "@types/react": "^19.0.0",
82
95
  "esbuild": "^0.25.0",
96
+ "playwright-core": "1.62.1",
83
97
  "typescript": "^5.9.2"
84
98
  },
85
99
  "keywords": [
@@ -97,4 +111,4 @@
97
111
  "access": "public"
98
112
  },
99
113
  "license": "MIT"
100
- }
114
+ }
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 锚点健康检查(防回归 I-2)。
4
+ * 读取 package.json 的 dsh.client.inject 锚点,逐一校验这些锚点包在「目标 dsh 版本线」
5
+ * 上是否存在(默认 0.1.1-rc.2 与 0.1.2-alpha.4)。
6
+ *
7
+ * 目的:dsh 每次 minor 升级都可能删/改客户端锚点包(0.1.1→0.1.2 已删 dsh-client-runtime、
8
+ * 降级 dsh-client-ui-slots)。升级 dsh 前先跑本脚本,若某个锚点在新版本线缺失,脚本非零退出并点名,
9
+ * 避免「升级后客户端静默挂不上」。
10
+ *
11
+ * 用法:
12
+ * node scripts/check-anchors.mjs [目标版本...]
13
+ * (默认检查 0.1.1-rc.2 与 0.1.2-alpha.4;也可显式传版本,如 node scripts/check-anchors.mjs 0.1.3-rc.0)
14
+ *
15
+ * ── 实现说明(2026-09-02 改)──
16
+ * 初版用 `execFileSync('npm', ['view', ...])` 查询。该写法在 **Windows 上必然失败**:
17
+ * - `npm` 实为 `npm.cmd`,不经 shell 时 execFileSync 报 ENOENT;
18
+ * - 显式写 `npm.cmd` 又会被 Node 20+ 的安全限制拒绝(EINVAL,禁止 execFile 直接跑 .cmd/.bat)。
19
+ * 两种错误都被 catch 吞成「锚点不存在」,导致**全量假失败**(实测 win32 上 9 锚点 × 2 版本
20
+ * = 18 个假失败,而同样的包用 registry 直查全部存在)。
21
+ * 现改为直接走 registry HTTP:不 spawn 子进程,跨平台一致,且比逐个 `npm view` 快得多
22
+ * (每个包只取一次 packument,多版本复用)。
23
+ */
24
+ import { readFileSync } from 'node:fs'
25
+ import { dirname, join } from 'node:path'
26
+ import { fileURLToPath } from 'node:url'
27
+
28
+ const here = dirname(fileURLToPath(import.meta.url))
29
+ const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8'))
30
+ const anchors = pkg?.dsh?.client?.inject ?? []
31
+ const targets = process.argv.slice(2).length ? process.argv.slice(2) : ['0.1.1-rc.2', '0.1.2-alpha.4']
32
+
33
+ if (anchors.length === 0) {
34
+ console.error('package.json 未声明 dsh.client.inject 锚点')
35
+ process.exit(1)
36
+ }
37
+
38
+ /** registry:优先用 npm 注入的环境变量(npm run 时可得),否则官方源。 */
39
+ const REGISTRY = (process.env.npm_config_registry || 'https://registry.npmjs.org').replace(/\/+$/, '')
40
+
41
+ /** 取一次 packument,返回该包的版本集合;网络/服务端异常抛错(与「包不存在」区分)。 */
42
+ async function fetchVersions(name) {
43
+ // scoped 包的 `/` 在 registry 路径里要编码成 %2f
44
+ const url = `${REGISTRY}/${name.replace('/', '%2f')}`
45
+ const res = await fetch(url, { headers: { accept: 'application/vnd.npm.install-v1+json' } })
46
+ if (res.status === 404) return null // 包本身不存在
47
+ if (!res.ok) throw new Error(`${name}: HTTP ${res.status}`)
48
+ const body = await res.json()
49
+ return new Set(Object.keys(body.versions ?? {}))
50
+ }
51
+
52
+ console.log(`registry: ${REGISTRY}`)
53
+
54
+ /** 先把每个包的版本集合取回来(每包一次请求)。 */
55
+ const versionsByAnchor = new Map()
56
+ for (const anchor of anchors) {
57
+ try {
58
+ versionsByAnchor.set(anchor, await fetchVersions(anchor))
59
+ } catch (e) {
60
+ // 网络/registry 故障是**环境问题**,不该报成「锚点缺失」——用独立退出码 2 区分。
61
+ console.error(`\n⚠ 查询 ${anchor} 失败:${e.message}`)
62
+ console.error('这是环境/网络问题,不是锚点缺失。请检查网络或 registry 配置后重跑。')
63
+ process.exit(2)
64
+ }
65
+ }
66
+
67
+ let failures = 0
68
+ for (const target of targets) {
69
+ console.log(`\n=== 目标 dsh ${target} 下的锚点存在性 ===`)
70
+ for (const anchor of anchors) {
71
+ const versions = versionsByAnchor.get(anchor)
72
+ if (versions && versions.has(target)) {
73
+ console.log(` ✅ ${anchor} @ ${target}`)
74
+ } else {
75
+ console.error(` ❌ ${anchor} @ ${target} 不存在`)
76
+ failures++
77
+ }
78
+ }
79
+ }
80
+
81
+ if (failures > 0) {
82
+ console.error(`\n✗ 有 ${failures} 个锚点缺失:dsh.client.inject 需收敛为交集,或等待该包在新版本提供。`)
83
+ process.exit(1)
84
+ }
85
+ console.log(`\n✓ 全部 ${anchors.length} 个锚点在 ${targets.join(' 与 ')} 均存在。`)