dsh-voice-mode 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,7 +29,7 @@ DeepSeek Harness 语音双工对话模式:会话内一键进入 → 边说边
29
29
  - **开口打断(barge-in)**:三档灵敏度的发声前沿检测 → 本地静音 + host 合成队列作废(epoch)+ 正在运行的回合取消(保留半截并自然续入你的新消息)
30
30
  - **模型懒加载与进度**:首次使用自动下载 zipformer2 中文流式模型(约 160MB,`.part` 断点续传),状态条实时显示下载进度;可用 `npm run prefetch` 预下载
31
31
  - **容错**:麦克风被拒红点提示、模型下载失败可见提示、TTS 连接失败状态条提示(自动重试)、提交失败文字留在草稿、SSE 断线自动重连
32
- - **设置**:设置 → 插件配置 → voice-mode,可调音色 / 语速 / 打断灵敏度 / 静音停顿 / 空闲超时 / 模型镜像 / 自动发送 / 交互模式 / 唤醒词 / 口语化提示词;**音色可试听**(「试听」按钮按当前音色 + 当前语速即时合成预览,无需进入语音模式;自定义 ShortName 同样可试听)
32
+ - **设置**:设置 → Plugins → 插件配置 → 语音模式(voice-mode),可调音色 / 语速 / 打断灵敏度 / 静音停顿 / 空闲超时 / 模型镜像 / 自动发送 / 交互模式 / 唤醒词 / 口语化提示词;**音色可试听**(「试听」按钮按当前音色 + 当前语速即时合成预览,无需进入语音模式;自定义 ShortName 同样可试听)
33
33
  - **空闲退出**:10 分钟无活动自动退出并释放麦克风
34
34
 
35
35
  ## 操作手势
@@ -198,6 +198,12 @@ input: mic ──RMS VAD(2s 静音切句)──▶ POST /voice-mode/asr(f
198
198
  - hero(新会话空态)没有语音入口:语音模式是会话级功能,请先进入会话使用输入框麦克风按钮
199
199
  - 「试听」的请求超时兜底使用 `AbortSignal.timeout`(Chrome 103+ / Firefox 100+ / Safari 16+);更老的浏览器点击试听会立即显示失败提示,属预期降级
200
200
  - `spokenFormat` 提示词经官方 `system-prompt/assemble` 瀑布注入;若当前会话使用**完整提示词**配置(persona `complete: true` 的 agent preset),其提示词会整体替换系统提示词(官方 complete 契约),此时口语化提示词不注入
201
+ - **苹果 Safari / iOS**:
202
+ - 需 **HTTPS 或 localhost**(iOS/macOS Safari 强制安全上下文;`http://` 局域网 IP 下麦克风不可用)
203
+ - 首次进入需授权麦克风;被拒后到「设置 → Safari → 麦克风」开启(iOS)
204
+ - iOS 后台/锁屏时识别与朗读暂停,回前台自动恢复(可能丢句);建议语音模式期间保持前台
205
+ - 桌面 macOS Safari 若提示音暂无声音,请先点一次麦克风按钮再触发(浏览器音频策略)
206
+ - **安全说明**:插件 HTTP 面(`/voice-mode/*`)遵循宿主安全模型——请勿将 dsh 端口直接暴露公网;经反向代理发布时由代理层(如 basic auth)鉴权;插件侧对敏感操作保留会话归属校验(sessionId 门控)
201
207
 
202
208
  ## 故障排查
203
209
 
@@ -243,6 +249,8 @@ pnpm install && pnpm build # esbuild:lib/index.js(host)+ lib/client.js
243
249
  pnpm test # segmenter/wakeword 单测 + 发布前自检(均无需网络)
244
250
  node test/hold-e2e.js # hold 模式验收(独立浏览器,/asr 路由拦截)
245
251
  bash test/spoken-prompt-rpc.sh # 口语化提示词验证(RPC 直发,无需浏览器;需在线 TTS)
252
+ # 注:hold-e2e/spoken-prompt-rpc/spoken-toggle-ui-check 等集成探测脚本位于仓库根 test/
253
+ #(不在 npm 包内);npm 包内 test/ 仅含无需网络的离线单测。
246
254
  systemctl restart dsh # Linux;其他平台重启 dsh 进程
247
255
  ```
248
256
 
package/lib/client.js CHANGED
@@ -73,6 +73,15 @@ var INTERRUPT_LEVELS = {
73
73
  function createAsrEngine(config, sessionId) {
74
74
  let state = "idle";
75
75
  const stateListeners = /* @__PURE__ */ new Set();
76
+ const errorListeners = /* @__PURE__ */ new Set();
77
+ const emitError = (msg) => {
78
+ for (const fn of errorListeners) {
79
+ try {
80
+ fn(msg);
81
+ } catch {
82
+ }
83
+ }
84
+ };
76
85
  const transcriptListeners = /* @__PURE__ */ new Set();
77
86
  const partialListeners = /* @__PURE__ */ new Set();
78
87
  const speechStartListeners = /* @__PURE__ */ new Set();
@@ -108,11 +117,11 @@ function createAsrEngine(config, sessionId) {
108
117
  }
109
118
  };
110
119
  const emit = (listeners, text, meta) => {
111
- const t2 = text.trim();
112
- if (!t2) return;
120
+ const t3 = text.trim();
121
+ if (!t3) return;
113
122
  for (const fn of listeners) {
114
123
  try {
115
- fn(t2, meta);
124
+ fn(t3, meta);
116
125
  } catch {
117
126
  }
118
127
  }
@@ -138,7 +147,7 @@ function createAsrEngine(config, sessionId) {
138
147
  let res = await fetch(asrUrl(false), {
139
148
  method: "POST",
140
149
  headers: { "content-type": "application/octet-stream" },
141
- body: samples.slice().buffer
150
+ body: samples.buffer
142
151
  });
143
152
  if (res.status === 202) {
144
153
  setState("loading-model");
@@ -148,11 +157,11 @@ function createAsrEngine(config, sessionId) {
148
157
  const r2 = await fetch(asrUrl(false), {
149
158
  method: "POST",
150
159
  headers: { "content-type": "application/octet-stream" },
151
- body: samples.slice().buffer
160
+ body: samples.buffer
152
161
  });
153
162
  resolve(r2);
154
163
  } catch {
155
- resolve(new Response(null, { status: 0 }));
164
+ resolve(new Response(null, { status: 503 }));
156
165
  }
157
166
  }, 5e3);
158
167
  });
@@ -205,7 +214,7 @@ function createAsrEngine(config, sessionId) {
205
214
  let res = await fetch(asrUrl(true), {
206
215
  method: "POST",
207
216
  headers: { "content-type": "application/octet-stream" },
208
- body: samples.slice().buffer
217
+ body: samples.buffer
209
218
  });
210
219
  if (res.status === 202) {
211
220
  setState("loading-model");
@@ -216,11 +225,11 @@ function createAsrEngine(config, sessionId) {
216
225
  await fetch(asrUrl(true), {
217
226
  method: "POST",
218
227
  headers: { "content-type": "application/octet-stream" },
219
- body: samples.slice().buffer
228
+ body: samples.buffer
220
229
  })
221
230
  );
222
231
  } catch {
223
- resolve(new Response(null, { status: 0 }));
232
+ resolve(new Response(null, { status: 503 }));
224
233
  }
225
234
  }, 5e3);
226
235
  });
@@ -233,6 +242,7 @@ function createAsrEngine(config, sessionId) {
233
242
  if (out.text) emit(transcriptListeners, out.text, meta);
234
243
  } catch {
235
244
  setState(active ? speechActive ? "speech" : "listening" : "idle");
245
+ emitError("recognitionFail");
236
246
  }
237
247
  })();
238
248
  };
@@ -358,6 +368,10 @@ function createAsrEngine(config, sessionId) {
358
368
  });
359
369
  const AC = window.AudioContext ?? window.webkitAudioContext;
360
370
  audioCtx = new AC({ sampleRate: SAMPLE_RATE });
371
+ try {
372
+ await audioCtx.resume?.();
373
+ } catch {
374
+ }
361
375
  ctxRate = audioCtx.sampleRate;
362
376
  const source = audioCtx.createMediaStreamSource(stream);
363
377
  processor = audioCtx.createScriptProcessor(BUFFER_SIZE, 1, 1);
@@ -387,7 +401,7 @@ function createAsrEngine(config, sessionId) {
387
401
  }
388
402
  processor = null;
389
403
  try {
390
- stream?.getTracks().forEach((t2) => t2.stop());
404
+ stream?.getTracks().forEach((t3) => t3.stop());
391
405
  } catch {
392
406
  }
393
407
  stream = null;
@@ -470,6 +484,12 @@ function createAsrEngine(config, sessionId) {
470
484
  transcriptListeners.delete(fn);
471
485
  };
472
486
  },
487
+ onError(fn) {
488
+ errorListeners.add(fn);
489
+ return () => {
490
+ errorListeners.delete(fn);
491
+ };
492
+ },
473
493
  onPartial(fn) {
474
494
  partialListeners.add(fn);
475
495
  return () => {
@@ -498,10 +518,138 @@ function createAsrEngine(config, sessionId) {
498
518
  };
499
519
  }
500
520
 
521
+ // src/strings.ts
522
+ var zh = {
523
+ stateVoiceMode: "\u8BED\u97F3\u6A21\u5F0F",
524
+ ttsNoticeFail: "\u6717\u8BFB\u8FDE\u63A5\u5931\u8D25\uFF1A\u6B63\u5728\u91CD\u8BD5\u2026",
525
+ enterFail: "\u8FDB\u5165\u8BED\u97F3\u6A21\u5F0F\u5931\u8D25",
526
+ disabled: "\u8BED\u97F3\u6A21\u5F0F\u5DF2\u7981\u7528\uFF08\u63D2\u4EF6 enabled=false\uFF09",
527
+ sendFailKept: "\u53D1\u9001\u5931\u8D25\uFF0C\u5DF2\u4FDD\u7559\u5728\u8349\u7A3F",
528
+ micDenied: "\u9EA6\u514B\u98CE\u88AB\u62D2\u7EDD\uFF1A\u8BF7\u5728\u6D4F\u89C8\u5668\u5730\u5740\u680F\u5141\u8BB8\u9EA6\u514B\u98CE\u6743\u9650",
529
+ micUnavailable: "\u9EA6\u514B\u98CE\u4E0D\u53EF\u7528",
530
+ hold: "\u6309\u4F4F",
531
+ recognizing: "\u8BC6\u522B\u4E2D\u2026",
532
+ holdToTalk: "\u6309\u4F4F\u8BF4\u8BDD",
533
+ voiceDetected: "\u8BED\u97F3\u4E2D",
534
+ entering: "\u8FDB\u5165\u4E2D\u2026",
535
+ voiceBtn: "\u8BED\u97F3",
536
+ ariaActive: "\u8BED\u97F3\u6A21\u5F0F\u8FDB\u884C\u4E2D",
537
+ ariaEnter: "\u8FDB\u5165\u8BED\u97F3\u5BF9\u8BDD\u6A21\u5F0F",
538
+ titleHold: "\u8BED\u97F3\u6A21\u5F0F\u8FDB\u884C\u4E2D \xB7 \u6309\u4F4F\u8BF4\u8BDD\u3001\u677E\u624B\u53D1\u9001\uFF1B\u77ED\u6309\u9000\u51FA\uFF1BEsc/\u5931\u53BB\u7126\u70B9\u653E\u5F03\uFF1BCtrl+Shift+V \u9000\u51FA",
539
+ titleToggle: "\u8BED\u97F3\u6A21\u5F0F\u8FDB\u884C\u4E2D \xB7 \u70B9\u51FB\u9000\u51FA\uFF08Ctrl+Shift+V\uFF09\xB7 \u6309\u4F4F Ctrl \u7ACB\u5373\u53D1\u9001",
540
+ titleEnter: "\u8FDB\u5165\u8BED\u97F3\u5BF9\u8BDD\u6A21\u5F0F\uFF08Ctrl+Shift+V\uFF09",
541
+ loadingModel: "\u6B63\u5728\u52A0\u8F7D\u6A21\u578B\u2026",
542
+ listening: "\u8046\u542C\u4E2D\u2026",
543
+ wakeWord: "\u5524\u9192\u8BCD",
544
+ barHold: "\u8BED\u97F3\u6A21\u5F0F \xB7 \u6309\u4F4F\u8BF4\u8BDD\uFF08\u77ED\u6309\u9000\u51FA\uFF09",
545
+ barListening: "\u8BED\u97F3\u6A21\u5F0F \xB7 \u8046\u542C\u4E2D\u2026",
546
+ reading: "\u6717\u8BFB\u4E2D\u2026",
547
+ recognitionFail: "\u8BC6\u522B\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5",
548
+ 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
+ startFail: "\u8BED\u97F3\u6A21\u5F0F\u542F\u52A8\u5931\u8D25\uFF1A{err}",
550
+ holdDots: "\u6309\u4F4F\u8BF4\u8BDD\u2026",
551
+ sayWake: "\u8BF4\u300C{wake}\u300D\u5F00\u59CB",
552
+ exit: "\u9000\u51FA",
553
+ skip: "\u8DF3\u8FC7",
554
+ configUnavailableNote: "\uFF08\u8BBE\u7F6E\u6587\u6863\u672A\u5C31\u7EEA\uFF0C\u9762\u677F\u5C31\u7EEA\u540E\u4F1A\u81EA\u52A8\u51FA\u73B0\uFF09\u3002",
555
+ // settings-form
556
+ previewNameFirst: "\u8BF7\u5148\u586B\u5199\u97F3\u8272\u540D\uFF08ShortName\uFF09",
557
+ previewDisabled: "\u8BED\u97F3\u6A21\u5F0F\u5DF2\u7981\u7528\uFF08\u63D2\u4EF6 enabled=false\uFF09\uFF0C\u65E0\u6CD5\u8BD5\u542C",
558
+ previewPlayFail: "\u8BD5\u542C\u5931\u8D25\uFF1A\u65E0\u6CD5\u64AD\u653E\u8BE5\u97F3\u8272",
559
+ previewAutoplay: "\u6D4F\u89C8\u5668\u62E6\u622A\u4E86\u81EA\u52A8\u64AD\u653E\uFF0C\u8BF7\u518D\u70B9\u4E00\u6B21\u8BD5\u542C",
560
+ previewCheck: "\u8BD5\u542C\u5931\u8D25\uFF1A\u8BF7\u68C0\u67E5\u7F51\u7EDC\u6216\u97F3\u8272\u540D\uFF08ShortName\uFF09\u662F\u5426\u6B63\u786E",
561
+ previewBtnTitle: "\u8BD5\u542C\u5F53\u524D\u97F3\u8272\uFF08\u5F53\u524D\u8BED\u901F\uFF09",
562
+ synthesizing: "\u5408\u6210\u4E2D\u2026",
563
+ preview: "\u8BD5\u542C",
564
+ custom: "\u81EA\u5B9A\u4E49",
565
+ // 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",
567
+ 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",
569
+ sev0: "0 \u9AD8\u95E8\u69DB",
570
+ sev1: "1 \u4E2D",
571
+ 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",
573
+ descIdle: "\u65E0\u6D3B\u52A8\u81EA\u52A8\u9000\u51FA\u8BED\u97F3\u6A21\u5F0F\u7684\u5206\u949F\u6570\uFF08\u9ED8\u8BA4 10\uFF09",
574
+ 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
+ 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",
577
+ descMode: "\u4EA4\u4E92\u6A21\u5F0F\uFF08toggle \u6301\u7EED\u8046\u542C+\u9759\u97F3\u65AD\u53E5 / hold \u6309\u4F4F\u8BF4\u8BDD\uFF09",
578
+ modeToggle: "\u6301\u7EED\u8046\u542C",
579
+ modeHold: "\u6309\u4F4F\u8BF4\u8BDD",
580
+ descWakeWord: "\u5524\u9192\u8BCD\uFF08\u9ED8\u8BA4\u5173\uFF1B\u5982\u300C\u4F60\u597D\u5C0FD\u300D\uFF0C\u8BF4\u51FA\u540E\u5F00\u59CB\u8BC6\u522B\uFF09",
581
+ wakePlaceholder: "\u5982\uFF1A\u4F60\u597D\u5C0FD",
582
+ settingsCardDesc: "\u97F3\u8272 / \u8BED\u901F / \u6253\u65AD\u7075\u654F\u5EA6 / \u9759\u97F3\u505C\u987F / \u7A7A\u95F2\u8D85\u65F6 / \u6A21\u578B\u955C\u50CF / \u81EA\u52A8\u53D1\u9001 / \u4EA4\u4E92\u6A21\u5F0F / \u5524\u9192\u8BCD / \u53E3\u8BED\u5316\u63D0\u793A\u8BCD",
583
+ configUnavailable: "\u914D\u7F6E\u6682\u4E0D\u53EF\u7528"
584
+ };
585
+ var en = {
586
+ stateVoiceMode: "Voice Mode",
587
+ ttsNoticeFail: "Read-aloud connection lost: retrying\u2026",
588
+ enterFail: "Failed to enter voice mode",
589
+ disabled: "Voice mode disabled (plugin enabled=false)",
590
+ sendFailKept: "Send failed; text kept in draft",
591
+ micDenied: "Microphone denied: allow mic access for this site",
592
+ micUnavailable: "Microphone unavailable",
593
+ hold: "Hold",
594
+ recognizing: "Recognizing\u2026",
595
+ holdToTalk: "Hold to talk",
596
+ voiceDetected: "Voice active",
597
+ entering: "Entering\u2026",
598
+ voiceBtn: "Voice",
599
+ ariaActive: "Voice mode active",
600
+ ariaEnter: "Enter voice mode",
601
+ titleHold: "Voice mode \xB7 hold to talk, release to send; tap to exit; Esc/blur cancels; Ctrl+Shift+V exits",
602
+ titleToggle: "Voice mode \xB7 click to exit (Ctrl+Shift+V) \xB7 hold Ctrl to send now",
603
+ titleEnter: "Enter voice mode (Ctrl+Shift+V)",
604
+ loadingModel: "Loading model\u2026",
605
+ listening: "Listening\u2026",
606
+ wakeWord: "Wake word",
607
+ barHold: "Voice mode \xB7 hold to talk (tap to exit)",
608
+ barListening: "Voice mode \xB7 listening\u2026",
609
+ reading: "Reading\u2026",
610
+ recognitionFail: "Recognition failed, try again",
611
+ modelDownloadFail: "Model download failed ({file}): check network and re-enter voice mode",
612
+ startFail: "Voice mode failed to start: {err}",
613
+ holdDots: "Hold to talk\u2026",
614
+ sayWake: 'Say "{wake}" to start',
615
+ exit: "Exit",
616
+ skip: "Skip",
617
+ configUnavailableNote: " (settings document not ready; the panel will appear when it is).",
618
+ previewNameFirst: "Enter a voice ShortName first",
619
+ previewDisabled: "Voice mode disabled; preview unavailable",
620
+ previewPlayFail: "Preview failed: cannot play this voice",
621
+ previewAutoplay: "Autoplay blocked \u2014 click preview again",
622
+ previewCheck: "Preview failed: check network or ShortName",
623
+ previewBtnTitle: "Preview voice (current rate)",
624
+ synthesizing: "Synthesizing\u2026",
625
+ preview: "Preview",
626
+ custom: "Custom",
627
+ descVoice: "Edge TTS voice (presets, or a custom ShortName)",
628
+ descRate: "Speech rate (0.5 slow \u2013 2.0 fast, 1.0 normal)",
629
+ descInterrupt: "Interrupt sensitivity (0 high barrier / 2 low)",
630
+ sev0: "0 high",
631
+ sev1: "1 medium",
632
+ sev2: "2 low",
633
+ descSilence: "Silence pause before a sentence is committed (default 2000 ms)",
634
+ descIdle: "Auto-exit voice mode after idle minutes (default 10)",
635
+ descModelHost: "ASR model download source (official source / mirror, or any custom URL)",
636
+ 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)",
638
+ descMode: "Interaction mode (toggle: continuous listen + auto-send / hold: press to talk)",
639
+ modeToggle: "Continue listen",
640
+ modeHold: "Hold to talk",
641
+ descWakeWord: "Wake word (default off; e.g. Hey D)",
642
+ wakePlaceholder: "e.g. Hey D",
643
+ settingsCardDesc: "Voice / rate / interrupt / silence / idle / model host / auto-send / mode / wake word / spoken format",
644
+ configUnavailable: "Configuration unavailable"
645
+ };
646
+ var lang = typeof navigator !== "undefined" && /^zh\b/i.test(navigator.language ?? "") ? "zh" : "en";
647
+ var t = (key) => lang === "zh" ? zh[key] : en[key] ?? zh[key];
648
+
501
649
  // src/settings-form.tsx
502
650
  var import_react = require("react");
503
651
  var import_jsx_runtime = require("react/jsx-runtime");
504
- var t = {
652
+ var t2 = {
505
653
  bg: "var(--dsw-alias-bg-layer-3)",
506
654
  bgOpen: "var(--dsw-alias-bg-layer-2)",
507
655
  border: "var(--dsw-alias-border-l2)",
@@ -511,8 +659,8 @@ var t = {
511
659
  };
512
660
  var BASE_PATH = "/voice-mode";
513
661
  var cardStyle = {
514
- border: `1px solid ${t.border}`,
515
- background: t.bg,
662
+ border: `1px solid ${t2.border}`,
663
+ background: t2.bg,
516
664
  borderRadius: 12,
517
665
  overflow: "hidden"
518
666
  };
@@ -532,18 +680,18 @@ var setHeader = {
532
680
  display: "flex"
533
681
  };
534
682
  var setHeadText = { flexDirection: "column", flex: 1, gap: 4, minWidth: 0, display: "flex" };
535
- var setName = { color: t.label, fontSize: 15, fontWeight: 600, lineHeight: 1.4 };
536
- var setDesc = { color: t.term, fontSize: 13, lineHeight: 1.5 };
537
- var setChevron = { color: t.term, flex: "none", transition: "transform .16s", display: "inline-flex" };
538
- var setBody = { borderTop: `1px solid ${t.border}`, margin: "0 16px", paddingBottom: 8 };
683
+ var setName = { color: t2.label, fontSize: 15, fontWeight: 600, lineHeight: 1.4 };
684
+ var setDesc = { color: t2.term, fontSize: 13, lineHeight: 1.5 };
685
+ var setChevron = { color: t2.term, flex: "none", transition: "transform .16s", display: "inline-flex" };
686
+ var setBody = { borderTop: `1px solid ${t2.border}`, margin: "0 16px", paddingBottom: 8 };
539
687
  var setRow = { alignItems: "center", gap: 12, padding: "12px 0", display: "flex" };
540
688
  var setLabelBox = { flexDirection: "column", flex: 1, gap: 3, minWidth: 0, display: "flex" };
541
689
  var setLabel = { fontSize: 13, lineHeight: "20px" };
542
- var setHint = { color: t.term, fontSize: 12, lineHeight: "18px" };
543
- var setSeg = { border: `1px solid ${t.border}`, borderRadius: 8, flexShrink: 0, gap: 2, padding: 2, display: "inline-flex" };
690
+ var setHint = { color: t2.term, fontSize: 12, lineHeight: "18px" };
691
+ var setSeg = { border: `1px solid ${t2.border}`, borderRadius: 8, flexShrink: 0, gap: 2, padding: 2, display: "inline-flex" };
544
692
  var setSegBtn = (on) => ({
545
693
  font: "inherit",
546
- color: on ? t.label : "var(--dsw-alias-label-secondary)",
694
+ color: on ? t2.label : "var(--dsw-alias-label-secondary)",
547
695
  cursor: "pointer",
548
696
  background: on ? "var(--dsw-alias-bg-layer-2)" : "transparent",
549
697
  border: "none",
@@ -559,9 +707,9 @@ var inputStyle = {
559
707
  maxWidth: "100%",
560
708
  padding: "7px 10px",
561
709
  borderRadius: 8,
562
- border: `1px solid ${t.border}`,
710
+ border: `1px solid ${t2.border}`,
563
711
  background: "var(--dsw-alias-bg-layer-2)",
564
- color: t.label,
712
+ color: t2.label,
565
713
  fontSize: 13,
566
714
  fontFamily: "inherit",
567
715
  outline: "none"
@@ -696,7 +844,10 @@ function SelectField({
696
844
  },
697
845
  children: [
698
846
  options.map((o) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: o.v, children: o.label }, o.v)),
699
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: "__custom__", children: "\u81EA\u5B9A\u4E49\u2026" })
847
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("option", { value: "__custom__", children: [
848
+ t("custom"),
849
+ "\u2026"
850
+ ] })
700
851
  ]
701
852
  }
702
853
  ),
@@ -724,7 +875,7 @@ function VoicePreviewButton({ voice, rate }) {
724
875
  if (busy) return;
725
876
  const v = voice.trim();
726
877
  if (!v) {
727
- setNote("\u8BF7\u5148\u586B\u5199\u97F3\u8272\u540D\uFF08ShortName\uFF09");
878
+ setNote(t("previewNameFirst"));
728
879
  return;
729
880
  }
730
881
  setBusy(true);
@@ -745,7 +896,7 @@ function VoicePreviewButton({ voice, rate }) {
745
896
  signal: AbortSignal.timeout(15e3)
746
897
  });
747
898
  if (res.status === 403) {
748
- setNote("\u8BED\u97F3\u6A21\u5F0F\u5DF2\u7981\u7528\uFF08\u63D2\u4EF6 enabled=false\uFF09\uFF0C\u65E0\u6CD5\u8BD5\u542C");
899
+ setNote(t("previewDisabled"));
749
900
  return;
750
901
  }
751
902
  if (!res.ok) throw new Error(`preview http ${res.status}`);
@@ -755,18 +906,18 @@ function VoicePreviewButton({ voice, rate }) {
755
906
  audio.onended = () => URL.revokeObjectURL(url);
756
907
  audio.onerror = () => {
757
908
  URL.revokeObjectURL(url);
758
- setNote("\u8BD5\u542C\u5931\u8D25\uFF1A\u65E0\u6CD5\u64AD\u653E\u8BE5\u97F3\u8272");
909
+ setNote(t("previewPlayFail"));
759
910
  };
760
911
  try {
761
912
  await audio.play();
762
913
  } catch (e) {
763
914
  URL.revokeObjectURL(url);
764
915
  setNote(
765
- e instanceof DOMException && e.name === "NotAllowedError" ? "\u6D4F\u89C8\u5668\u62E6\u622A\u4E86\u81EA\u52A8\u64AD\u653E\uFF0C\u8BF7\u518D\u70B9\u4E00\u6B21\u8BD5\u542C" : "\u8BD5\u542C\u5931\u8D25\uFF1A\u65E0\u6CD5\u64AD\u653E\u8BE5\u97F3\u8272"
916
+ e instanceof DOMException && e.name === "NotAllowedError" ? t("previewAutoplay") : t("previewPlayFail")
766
917
  );
767
918
  }
768
919
  } catch {
769
- setNote("\u8BD5\u542C\u5931\u8D25\uFF1A\u8BF7\u68C0\u67E5\u7F51\u7EDC\u6216\u97F3\u8272\u540D\uFF08ShortName\uFF09\u662F\u5426\u6B63\u786E");
920
+ setNote(t("previewCheck"));
770
921
  } finally {
771
922
  setBusy(false);
772
923
  }
@@ -779,18 +930,18 @@ function VoicePreviewButton({ voice, rate }) {
779
930
  gap: 5,
780
931
  alignSelf: "flex-start",
781
932
  cursor: busy ? "default" : "pointer",
782
- color: t.label,
933
+ color: t2.label,
783
934
  background: "var(--dsw-alias-bg-layer-2)",
784
- border: `1px solid ${t.border}`,
935
+ border: `1px solid ${t2.border}`,
785
936
  borderRadius: 6,
786
937
  padding: "4px 10px",
787
938
  fontSize: 12,
788
939
  lineHeight: "18px"
789
940
  };
790
941
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: { display: "flex", flexDirection: "column", gap: 4, alignItems: "flex-start" }, children: [
791
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("button", { type: "button", onClick: play, disabled: busy, style: btnStyle, title: "\u8BD5\u542C\u5F53\u524D\u97F3\u8272\uFF08\u5F53\u524D\u8BED\u901F\uFF09", children: [
942
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("button", { type: "button", onClick: play, disabled: busy, style: btnStyle, title: t("previewBtnTitle"), children: [
792
943
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", { viewBox: "0 0 16 16", width: 11, height: 11, "aria-hidden": "true", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { fill: "currentColor", d: "M4 3l9 5-9 5z" }) }),
793
- busy ? "\u5408\u6210\u4E2D\u2026" : "\u8BD5\u542C"
944
+ busy ? t("synthesizing") : t("preview")
794
945
  ] }),
795
946
  note && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { color: "var(--dsw-alias-state-error-primary)", fontSize: 12, lineHeight: "18px" }, children: note })
796
947
  ] });
@@ -824,22 +975,22 @@ function VoiceSettingsCard({ scope }) {
824
975
  const value = snap?.value ?? {};
825
976
  const unavailable = snap?.status === "unavailable" || snap?.status === "error";
826
977
  if (unavailable) {
827
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { "data-dshvm-settings": "card", style: { color: t.term, fontSize: 12, padding: "14px 16px", ...cardStyle }, children: [
828
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { color: "var(--dsw-alias-state-error-primary)" }, children: "\u914D\u7F6E\u6682\u4E0D\u53EF\u7528" }),
829
- "\uFF08\u8BBE\u7F6E\u6587\u6863\u672A\u5C31\u7EEA\uFF0C\u9762\u677F\u5C31\u7EEA\u540E\u4F1A\u81EA\u52A8\u51FA\u73B0\uFF09\u3002"
978
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { "data-dshvm-settings": "card", style: { color: t2.term, fontSize: 12, padding: "14px 16px", ...cardStyle }, children: [
979
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { color: "var(--dsw-alias-state-error-primary)" }, children: t("configUnavailable") }),
980
+ t("configUnavailableNote")
830
981
  ] });
831
982
  }
832
983
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { "data-dshvm-settings": "card", style: cardStyle, children: [
833
984
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("style", { children: focusVisibleCss }),
834
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("button", { type: "button", "aria-expanded": !collapsed, onClick: () => setCollapsed((c) => !c), style: { ...setHeader, background: collapsed ? "transparent" : t.bgOpen }, children: [
985
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("button", { type: "button", "aria-expanded": !collapsed, onClick: () => setCollapsed((c) => !c), style: { ...setHeader, background: collapsed ? "transparent" : t2.bgOpen }, children: [
835
986
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: setHeadText, children: [
836
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: setName, children: "\u8BED\u97F3\u6A21\u5F0F" }),
837
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: setDesc, children: "\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" })
987
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: setName, children: t("stateVoiceMode") }),
988
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: setDesc, children: t("settingsCardDesc") })
838
989
  ] }),
839
990
  /* @__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" }) }) })
840
991
  ] }),
841
992
  !collapsed && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: setBody, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { marginTop: 4 }, children: [
842
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "voice", desc: "Edge TTS \u97F3\u8272\uFF08\u4E0B\u62C9\u5E38\u7528\uFF0C\u5176\u4F59\u9009\u300C\u81EA\u5B9A\u4E49\u300D\u624B\u52A8\u586B ShortName\uFF09", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
993
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "voice", desc: t("descVoice"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
843
994
  SelectField,
844
995
  {
845
996
  score: scope,
@@ -850,44 +1001,45 @@ function VoiceSettingsCard({ scope }) {
850
1001
  footer: (v) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(VoicePreviewButton, { voice: v, rate: Number(value.rate ?? 1) })
851
1002
  }
852
1003
  ) }),
853
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "rate", desc: "\u6717\u8BFB\u8BED\u901F\u500D\u7387\uFF080.5 \u6162\u901F \uFF5E 2.0 \u5FEB\u901F\uFF0C1.0 \u6B63\u5E38\uFF09", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NumberField, { score: scope, field: "rate", value: value.rate ?? 1, min: 0.5, max: 2, step: 0.1 }) }),
854
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "interruptLevel", desc: "\u53D1\u58F0\u6253\u65AD\u7075\u654F\u5EA6\uFF080 \u9AD8\u95E8\u69DB / 1 \u4E2D / 2 \u4F4E\uFF09", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1004
+ /* @__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 }) }),
1005
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "interruptLevel", desc: t("descInterrupt"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
855
1006
  SegGroup,
856
1007
  {
857
1008
  score: scope,
858
1009
  field: "interruptLevel",
859
1010
  value: value.interruptLevel,
860
1011
  options: [
861
- { v: 0, label: "0 \u9AD8\u95E8\u69DB" },
862
- { v: 1, label: "1 \u4E2D" },
863
- { v: 2, label: "2 \u4F4E" }
1012
+ { v: 0, label: t("sev0") },
1013
+ { v: 1, label: t("sev1") },
1014
+ { v: 2, label: t("sev2") }
864
1015
  ]
865
1016
  }
866
1017
  ) }),
867
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "silenceMs", desc: "\u8BF4\u5B8C\u6574\u4E00\u53E5\u7684\u9759\u97F3\u505C\u987F\u6BEB\u79D2\u6570\uFF08\u9ED8\u8BA4 2000 = 2 \u79D2\uFF09", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NumberField, { score: scope, field: "silenceMs", value: value.silenceMs ?? 2e3, min: 500, max: 3e4, step: 100 }) }),
868
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "idleTimeoutMinutes", desc: "\u65E0\u6D3B\u52A8\u81EA\u52A8\u9000\u51FA\u8BED\u97F3\u6A21\u5F0F\u7684\u5206\u949F\u6570\uFF08\u9ED8\u8BA4 10\uFF09", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NumberField, { score: scope, field: "idleTimeoutMinutes", value: value.idleTimeoutMinutes ?? 10, min: 1, max: 120, step: 1 }) }),
869
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "modelHost", desc: "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", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SelectField, { score: scope, field: "modelHost", value: value.modelHost ?? "", options: HOST_OPTIONS, placeholder: "https://..." }) }),
870
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "autoSend", desc: "\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", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { type: "checkbox", checked: Boolean(value.autoSend), onChange: (e) => void scope.set("autoSend", e.target.checked) }) }),
871
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "spokenFormat", desc: "\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", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { type: "checkbox", checked: Boolean(value.spokenFormat), onChange: (e) => void scope.set("spokenFormat", e.target.checked) }) }),
872
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "mode", desc: "\u4EA4\u4E92\u6A21\u5F0F\uFF08toggle \u6301\u7EED\u8046\u542C+\u9759\u97F3\u65AD\u53E5 / hold \u6309\u4F4F\u8BF4\u8BDD\uFF09", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1018
+ /* @__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 }) }),
1019
+ /* @__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 }) }),
1020
+ /* @__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://..." }) }),
1021
+ /* @__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) }) }),
1022
+ /* @__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) }) }),
1023
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "mode", desc: t("descMode"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
873
1024
  SegGroup,
874
1025
  {
875
1026
  score: scope,
876
1027
  field: "mode",
877
1028
  value: value.mode,
878
1029
  options: [
879
- { v: "toggle", label: "\u6301\u7EED\u8046\u542C" },
880
- { v: "hold", label: "\u6309\u4F4F\u8BF4\u8BDD" }
1030
+ { v: "toggle", label: t("modeToggle") },
1031
+ { v: "hold", label: t("modeHold") }
881
1032
  ]
882
1033
  }
883
1034
  ) }),
884
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "wakeWord", desc: "\u5524\u9192\u8BCD\uFF08\u9ED8\u8BA4\u5173\uFF1B\u5982\u300C\u4F60\u597D\u5C0FD\u300D\uFF0C\u8BF4\u51FA\u540E\u5F00\u59CB\u8BC6\u522B\uFF09", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TextField, { score: scope, field: "wakeWord", value: value.wakeWord ?? "", placeholder: "\u5982\uFF1A\u4F60\u597D\u5C0FD" }) })
1035
+ /* @__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") }) })
885
1036
  ] }) })
886
1037
  ] });
887
1038
  }
888
1039
 
889
1040
  // src/client.tsx
890
1041
  var import_jsx_runtime2 = require("react/jsx-runtime");
1042
+ var beepCtx = null;
891
1043
  var inject = ["slots", "sessions", "settingsScope"];
892
1044
  var WAVE_BARS = 14;
893
1045
  var BASE_PATH2 = "/voice-mode";
@@ -937,7 +1089,7 @@ function apply(ctx) {
937
1089
  name: "settings.plugin.item",
938
1090
  key: "voice-mode",
939
1091
  order: 100,
940
- label: "\u8BED\u97F3\u6A21\u5F0F"
1092
+ label: t("stateVoiceMode")
941
1093
  },
942
1094
  () => React.createElement(VoiceSettingsCard, { scope: ctx.settingsScope.bind({ namespace: "voice-mode" }) })
943
1095
  )
@@ -969,10 +1121,12 @@ function createAudioEngine(setUi) {
969
1121
  setUi({ playing: true, playingCaption: frame.text, ttsNotice: null });
970
1122
  void audio.play().catch(() => playNext());
971
1123
  };
972
- let beepCtx = null;
973
1124
  const toolBeep = () => {
974
1125
  try {
975
- if (!beepCtx) beepCtx = new AudioContext();
1126
+ if (!beepCtx) {
1127
+ beepCtx = new AudioContext();
1128
+ void beepCtx.resume?.();
1129
+ }
976
1130
  const osc = beepCtx.createOscillator();
977
1131
  const gain = beepCtx.createGain();
978
1132
  osc.frequency.value = 880;
@@ -1096,7 +1250,7 @@ function createVoiceBus(basePath = BASE_PATH2, ctx) {
1096
1250
  source.addEventListener("asr-error", (e) => {
1097
1251
  try {
1098
1252
  const p = JSON.parse(e.data);
1099
- ui.error = `\u8BED\u97F3\u6A21\u578B\u4E0B\u8F7D\u5931\u8D25\uFF08${p.file ?? ""}\uFF09\uFF1A\u8BF7\u68C0\u67E5\u7F51\u7EDC\u540E\u91CD\u65B0\u8FDB\u5165\u8BED\u97F3\u6A21\u5F0F\u91CD\u8BD5`;
1253
+ ui.error = t("modelDownloadFail").replace("{file}", p.file ?? "");
1100
1254
  ui.model = null;
1101
1255
  notify();
1102
1256
  } catch {
@@ -1106,7 +1260,7 @@ function createVoiceBus(basePath = BASE_PATH2, ctx) {
1106
1260
  try {
1107
1261
  const p = JSON.parse(e.data);
1108
1262
  if (p.sessionId === activeSessionId) {
1109
- ui.ttsNotice = "\u6717\u8BFB\u8FDE\u63A5\u5931\u8D25\uFF1A\u6B63\u5728\u91CD\u8BD5\u2026";
1263
+ ui.ttsNotice = t("ttsNoticeFail");
1110
1264
  notify();
1111
1265
  }
1112
1266
  } catch {
@@ -1144,10 +1298,10 @@ function createVoiceBus(basePath = BASE_PATH2, ctx) {
1144
1298
  const out = await res.json();
1145
1299
  activeSessionId = out.active ?? null;
1146
1300
  notify();
1147
- if (!res.ok) return { ok: false, error: out.error ?? "\u8FDB\u5165\u8BED\u97F3\u6A21\u5F0F\u5931\u8D25" };
1148
- return { ok: out.active === sessionId, error: out.active === sessionId ? void 0 : "\u8FDB\u5165\u8BED\u97F3\u6A21\u5F0F\u5931\u8D25" };
1301
+ if (!res.ok) return { ok: false, error: out.error ?? t("enterFail") };
1302
+ return { ok: out.active === sessionId, error: out.active === sessionId ? void 0 : t("enterFail") };
1149
1303
  } catch {
1150
- return { ok: false, error: "\u8FDB\u5165\u8BED\u97F3\u6A21\u5F0F\u5931\u8D25" };
1304
+ return { ok: false, error: t("enterFail") };
1151
1305
  }
1152
1306
  },
1153
1307
  async exit(sessionId) {
@@ -1222,7 +1376,7 @@ function MicButton({
1222
1376
  const [, bumpUi] = (0, import_react2.useState)(0);
1223
1377
  (0, import_react2.useEffect)(
1224
1378
  () => bus.subscribe(() => {
1225
- bumpUi((t2) => t2 + 1);
1379
+ bumpUi((t3) => t3 + 1);
1226
1380
  }),
1227
1381
  [bus]
1228
1382
  );
@@ -1304,7 +1458,7 @@ function MicButton({
1304
1458
  if (!entered.ok) {
1305
1459
  setLocalMode("off");
1306
1460
  bus.setUi({
1307
- error: entered.error === "voice mode disabled" ? "\u8BED\u97F3\u6A21\u5F0F\u5DF2\u7981\u7528\uFF08\u63D2\u4EF6 enabled=false\uFF09" : entered.error ?? "\u8FDB\u5165\u8BED\u97F3\u6A21\u5F0F\u5931\u8D25"
1461
+ error: entered.error === "voice mode disabled" ? t("disabled") : entered.error ?? t("enterFail")
1308
1462
  });
1309
1463
  return;
1310
1464
  }
@@ -1315,10 +1469,18 @@ function MicButton({
1315
1469
  const engine = createAsrEngine({ silenceMs, interruptLevel, basePath, wakeWord: cfg.wakeWord }, sid);
1316
1470
  bus.setUi({ mode: cfg.mode, wakeWord: cfg.wakeWord });
1317
1471
  engineRef.current = engine;
1472
+ try {
1473
+ if (!beepCtx) beepCtx = new AudioContext();
1474
+ void beepCtx.resume?.();
1475
+ } catch {
1476
+ }
1318
1477
  engine.onState((s) => {
1319
1478
  bus.setUi({ state: s });
1320
1479
  if (s === "idle") resetIdle();
1321
1480
  });
1481
+ engine.onError((key) => {
1482
+ bus.setUi({ error: t(key) });
1483
+ });
1322
1484
  engine.onLevel((l) => {
1323
1485
  const cur = bus.ui.levels;
1324
1486
  const next = cur.length < WAVE_BARS ? [...cur, l] : [...cur.slice(1), l];
@@ -1349,11 +1511,11 @@ function MicButton({
1349
1511
  const r = actions?.submit?.();
1350
1512
  if (r && typeof r.then === "function") {
1351
1513
  r.catch(() => {
1352
- bus.setUi({ error: "\u53D1\u9001\u5931\u8D25\uFF0C\u5DF2\u4FDD\u7559\u5728\u8349\u7A3F" });
1514
+ bus.setUi({ error: t("sendFailKept") });
1353
1515
  });
1354
1516
  }
1355
1517
  } catch {
1356
- bus.setUi({ error: "\u53D1\u9001\u5931\u8D25\uFF0C\u5DF2\u4FDD\u7559\u5728\u8349\u7A3F" });
1518
+ bus.setUi({ error: t("sendFailKept") });
1357
1519
  }
1358
1520
  };
1359
1521
  cancelPendingSubmit();
@@ -1385,7 +1547,7 @@ function MicButton({
1385
1547
  resetIdle();
1386
1548
  } catch (e) {
1387
1549
  setLocalMode("off");
1388
- const msg = e instanceof DOMException ? e.name === "NotAllowedError" ? "\u9EA6\u514B\u98CE\u88AB\u62D2\u7EDD\uFF1A\u8BF7\u5728\u6D4F\u89C8\u5668\u5730\u5740\u680F\u5141\u8BB8\u9EA6\u514B\u98CE\u6743\u9650" : "\u9EA6\u514B\u98CE\u4E0D\u53EF\u7528" : `\u8BED\u97F3\u6A21\u5F0F\u542F\u52A8\u5931\u8D25\uFF1A${String(e instanceof Error ? e.message : e)}`;
1550
+ const msg = e instanceof DOMException ? e.name === "NotAllowedError" ? t("micDenied") : t("micUnavailable") : t("startFail").replace("{err}", String(e instanceof Error ? e.message : e));
1389
1551
  bus.setUi({ error: msg });
1390
1552
  const sid2 = sidRef.current;
1391
1553
  if (sid2) void bus.exit(sid2);
@@ -1476,8 +1638,8 @@ function MicButton({
1476
1638
  }, []);
1477
1639
  (0, import_react2.useEffect)(() => {
1478
1640
  const onInput = (e) => {
1479
- const t2 = e.target;
1480
- if (!(t2 instanceof HTMLTextAreaElement)) return;
1641
+ const t3 = e.target;
1642
+ if (!(t3 instanceof HTMLTextAreaElement)) return;
1481
1643
  if (localRef.current !== "on") return;
1482
1644
  void exitModeRef.current("typing");
1483
1645
  };
@@ -1526,7 +1688,7 @@ function MicButton({
1526
1688
  const livePhase = useInput ? useInput((s) => s?.phase ?? "") : "";
1527
1689
  const phaseRef = (0, import_react2.useRef)("");
1528
1690
  phaseRef.current = livePhase;
1529
- const label = on ? busy ? "\u8BC6\u522B\u4E2D\u2026" : holdMode ? "\u6309\u4F4F\u8BF4\u8BDD" : "\u8BED\u97F3\u4E2D" : local === "pending" ? "\u8FDB\u5165\u4E2D\u2026" : "\u8BED\u97F3";
1691
+ const label = on ? busy ? t("recognizing") : holdMode ? t("holdToTalk") : t("voiceDetected") : local === "pending" ? t("entering") : t("voiceBtn");
1530
1692
  const holdPtrRef = (0, import_react2.useRef)(null);
1531
1693
  const onPointerDown = (e) => {
1532
1694
  if (bootNow().mode !== "hold") return;
@@ -1577,9 +1739,9 @@ function MicButton({
1577
1739
  onPointerUp,
1578
1740
  onPointerCancel,
1579
1741
  "data-dshvm": "mic",
1580
- "aria-label": on ? "\u8BED\u97F3\u6A21\u5F0F\u8FDB\u884C\u4E2D" : "\u8FDB\u5165\u8BED\u97F3\u5BF9\u8BDD\u6A21\u5F0F",
1742
+ "aria-label": on ? t("ariaActive") : t("ariaEnter"),
1581
1743
  "aria-pressed": on,
1582
- title: on ? holdMode ? "\u8BED\u97F3\u6A21\u5F0F\u8FDB\u884C\u4E2D \xB7 \u6309\u4F4F\u8BF4\u8BDD\u3001\u677E\u624B\u53D1\u9001\uFF1B\u77ED\u6309\u9000\u51FA\uFF1BEsc/\u5931\u53BB\u7126\u70B9\u653E\u5F03\uFF1BCtrl+Shift+V \u9000\u51FA" : "\u8BED\u97F3\u6A21\u5F0F\u8FDB\u884C\u4E2D \xB7 \u70B9\u51FB\u9000\u51FA\uFF08Ctrl+Shift+V\uFF09\xB7 \u6309\u4F4F Ctrl \u7ACB\u5373\u53D1\u9001" : "\u8FDB\u5165\u8BED\u97F3\u5BF9\u8BDD\u6A21\u5F0F\uFF08Ctrl+Shift+V\uFF09",
1744
+ title: on ? holdMode ? t("titleHold") : t("titleToggle") : t("titleEnter"),
1583
1745
  style: {
1584
1746
  border: "none",
1585
1747
  background: on ? holdMode ? "rgba(88, 166, 255, 0.16)" : "rgba(63, 185, 80, 0.16)" : local === "pending" ? "rgba(88, 166, 255, 0.14)" : "transparent",
@@ -1626,7 +1788,7 @@ function VoiceStatusBar({ bus, sessionId }) {
1626
1788
  }, [bus]);
1627
1789
  const isActive = b.active === sessionId;
1628
1790
  if (!isActive) return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_jsx_runtime2.Fragment, {});
1629
- const stateText = b.ui.state === "loading-model" ? "\u6B63\u5728\u52A0\u8F7D\u6A21\u578B\u2026" : b.ui.state === "transcribing" ? "\u8BC6\u522B\u4E2D\u2026" : b.ui.state === "speech" ? b.ui.mode === "hold" ? "\u6309\u4F4F\u8BF4\u8BDD\u2026" : "\u8046\u542C\u4E2D\u2026" : b.ui.state === "wake" ? `\u8BF4\u300C${b.ui.wakeWord || "\u5524\u9192\u8BCD"}\u300D\u5F00\u59CB` : b.ui.mode === "hold" ? "\u8BED\u97F3\u6A21\u5F0F \xB7 \u6309\u4F4F\u8BF4\u8BDD\uFF08\u77ED\u6309\u9000\u51FA\uFF09" : "\u8BED\u97F3\u6A21\u5F0F \xB7 \u8046\u542C\u4E2D\u2026";
1791
+ 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");
1630
1792
  const bars = Array.from({ length: WAVE_BARS }, (_, i) => b.ui.levels[i] ?? 0);
1631
1793
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
1632
1794
  "div",
@@ -1657,7 +1819,7 @@ function VoiceStatusBar({ bus, sessionId }) {
1657
1819
  },
1658
1820
  i
1659
1821
  )) }),
1660
- /* @__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 ? `\u6B63\u5728\u52A0\u8F7D\u6A21\u578B\u2026 ${b.ui.model.file} ${b.ui.model.percent}%` : stateText : b.ui.partial ? b.ui.partial : b.ui.ttsNotice ? b.ui.ttsNotice : stateText }),
1822
+ /* @__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 }),
1661
1823
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1662
1824
  "button",
1663
1825
  {
@@ -1672,7 +1834,7 @@ function VoiceStatusBar({ bus, sessionId }) {
1672
1834
  fontSize: 12,
1673
1835
  flexShrink: 0
1674
1836
  },
1675
- children: "\u9000\u51FA"
1837
+ children: t("exit")
1676
1838
  }
1677
1839
  )
1678
1840
  ]
@@ -1688,6 +1850,8 @@ function VoiceOverlay({ bus }) {
1688
1850
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
1689
1851
  "div",
1690
1852
  {
1853
+ role: "status",
1854
+ "aria-live": "polite",
1691
1855
  style: {
1692
1856
  position: "fixed",
1693
1857
  right: 16,
@@ -1725,7 +1889,7 @@ function VoiceOverlay({ bus }) {
1725
1889
  },
1726
1890
  i
1727
1891
  )) }),
1728
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: b.ui.playingCaption ?? "\u6717\u8BFB\u4E2D\u2026" }, b.ui.playingCaption ?? "idle"),
1892
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: b.ui.playingCaption ?? t("reading") }, b.ui.playingCaption ?? "idle"),
1729
1893
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1730
1894
  "button",
1731
1895
  {
@@ -1740,7 +1904,7 @@ function VoiceOverlay({ bus }) {
1740
1904
  cursor: "pointer",
1741
1905
  flexShrink: 0
1742
1906
  },
1743
- children: "\u8DF3\u8FC7"
1907
+ children: t("skip")
1744
1908
  }
1745
1909
  )
1746
1910
  ]
package/lib/index.js CHANGED
@@ -342,7 +342,7 @@ var TtsQueue = class {
342
342
  enqueue(sessionId, text) {
343
343
  let q = this.queues.get(sessionId);
344
344
  if (!q) {
345
- q = { pending: [], busy: false, seq: 0, epoch: 0, errorNotified: false };
345
+ q = { pending: [], busy: false, seq: 0, epoch: 0, errorNotified: false, backoff: 0 };
346
346
  this.queues.set(sessionId, q);
347
347
  }
348
348
  q.pending.push({ text, epoch: q.epoch });
@@ -404,7 +404,12 @@ var TtsQueue = class {
404
404
  }
405
405
  } finally {
406
406
  q.busy = false;
407
- if (q.pending.length > 0) void this.pump(sessionId, q);
407
+ if (q.pending.length > 0) {
408
+ const delay = q.errorNotified ? q.backoff : 0;
409
+ q.backoff = Math.min(8e3, delay + 1e3);
410
+ if (delay > 0) setTimeout(() => void this.pump(sessionId, q), delay);
411
+ else void this.pump(sessionId, q);
412
+ }
408
413
  }
409
414
  }
410
415
  async close() {
@@ -439,10 +444,10 @@ function createVoiceSettingsSchema(defs) {
439
444
  voice: z.string().default(d.voice).description(
440
445
  "Edge TTS \u97F3\u8272\uFF08\u5927\u9646\u81EA\u7136\u97F3\uFF1Azh-CN-XiaoxiaoNeural \u6653\u6653\xB7\u5973 / zh-CN-XiaoyiNeural \u6653\u4F0A\xB7\u5973 / zh-CN-YunxiNeural \u4E91\u5E0C\xB7\u7537 / zh-CN-YunjianNeural \u4E91\u5065\xB7\u7537 / zh-CN-YunyangNeural \u4E91\u626C\xB7\u7537 / zh-CN-YunxiaNeural \u4E91\u590F\xB7\u7537\uFF1B\u65B9\u8A00\uFF1A\u4E1C\u5317-\u5C0F\u5317 / \u9655\u897F-\u5C0F\u59AE\uFF1B\u7CA4\u8BED\uFF1AHiuGaai/HiuMaan/WanLung\uFF1B\u53F0\u6E7E\uFF1AHsiaoChen/HsiaoYu/YunJhe\uFF1B\u5B8C\u6574\u6E05\u5355\u89C1 scripts/list-voices.mjs\uFF09"
441
446
  ),
442
- rate: z.number().default(d.rate).description("\u6717\u8BFB\u8BED\u901F\u500D\u7387\uFF080.5 = \u6162\u901F\uFF0C2.0 = \u5FEB\u901F\uFF0C1.0 = \u6B63\u5E38\uFF09"),
447
+ rate: z.number().min(0.5).max(2).default(d.rate).description("\u6717\u8BFB\u8BED\u901F\u500D\u7387\uFF080.5 = \u6162\u901F\uFF0C2.0 = \u5FEB\u901F\uFF0C1.0 = \u6B63\u5E38\uFF09"),
443
448
  interruptLevel: z.union([z.const(0), z.const(1), z.const(2)]).default(d.interruptLevel).description("\u53D1\u58F0\u6253\u65AD\u7075\u654F\u5EA6\uFF1A0 \u9AD8\u95E8\u69DB\uFF08\u5B89\u9759\u73AF\u5883\uFF0C\u9ED8\u8BA4\uFF09/ 1 \u4E2D / 2 \u4F4E\uFF08\u5608\u6742\u73AF\u5883\u66F4\u5BB9\u6613\u6253\u65AD\uFF09"),
444
- silenceMs: z.number().default(d.silenceMs).description("\u8BF4\u5B8C\u6574\u4E00\u53E5\u7684\u9759\u97F3\u505C\u987F\u6BEB\u79D2\u6570\uFF08\u9ED8\u8BA4 2000 = 2 \u79D2\uFF09"),
445
- idleTimeoutMinutes: z.number().default(d.idleTimeoutMinutes).description("\u65E0\u6D3B\u52A8\u81EA\u52A8\u9000\u51FA\u8BED\u97F3\u6A21\u5F0F\u7684\u5206\u949F\u6570\uFF08\u9ED8\u8BA4 10\uFF09"),
449
+ silenceMs: z.number().min(500).max(3e4).default(d.silenceMs).description("\u8BF4\u5B8C\u6574\u4E00\u53E5\u7684\u9759\u97F3\u505C\u987F\u6BEB\u79D2\u6570\uFF08\u9ED8\u8BA4 2000 = 2 \u79D2\uFF09"),
450
+ idleTimeoutMinutes: z.number().min(1).max(120).default(d.idleTimeoutMinutes).description("\u65E0\u6D3B\u52A8\u81EA\u52A8\u9000\u51FA\u8BED\u97F3\u6A21\u5F0F\u7684\u5206\u949F\u6570\uFF08\u9ED8\u8BA4 10\uFF09"),
446
451
  modelHost: z.string().default(d.modelHost).description("ASR \u6A21\u578B\u4E0B\u8F7D\u6E90\uFF08\u7559\u7A7A\u7528\u9ED8\u8BA4\u6E90\uFF1B\u56FD\u5185\u7F51\u7EDC\u53EF\u586B https://hf-mirror.com\uFF09"),
447
452
  autoSend: z.boolean().default(d.autoSend).description("\u8BC6\u522B\u5B9A\u7A3F\u540E\u81EA\u52A8\u53D1\u9001\uFF08\u5173\u95ED\u5219\u53EA\u8FDB\u8349\u7A3F\u4F9B\u7F16\u8F91\uFF1B\u6309\u4F4F Ctrl / hold \u677E\u624B\u4ECD\u4F1A\u53D1\u9001\uFF09"),
448
453
  mode: z.union([z.const("toggle"), z.const("hold")]).default(d.mode).description("\u4EA4\u4E92\u6A21\u5F0F\uFF1Atoggle \u6301\u7EED\u8046\u542C + \u9759\u97F3\u81EA\u52A8\u65AD\u53E5\uFF08\u9ED8\u8BA4\uFF09\uFF1Bhold \u6309\u4F4F\u8BF4\u8BDD\u3001\u677E\u624B\u53D1\u9001\uFF08\u77ED\u6309\u9000\u51FA\uFF09"),
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 sentence-by-sentence read-aloud with live captions, true barge-in — on-device ASR, no API key. · DSH 语音双工对话:流式识别入草稿、按句朗读+实时字幕、开口即打断,识别本地推理、无需 API Key",
4
- "version": "0.1.4",
4
+ "version": "0.2.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "repository": {