dsh-voice-mode 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -11,7 +11,7 @@
11
11
  >
12
12
  > 中文说明见 [README.md](./README.md)。
13
13
 
14
- > **Version note (0.6.0)**: Edge cloud TTS by default (fast & natural); local TTS (VITS / Kokoro, privacy-first) optional + HTTP hardening + model SHA256 pinning form the merged core; Kokoro adds a model-precision choice (`int8` default 109 MB / `fp32` better quality 311 MB); `wakeWord` (wake word) and `toolBeep` (tool-call beep) are fully wired; the early fork's `asrModel` (bilingual paraformer) and `punctuate` (neural punctuation) were removed — SenseVoice finalization already adds punctuation, and streaming ASR is fixed to zipformer2. Silence split defaults to 700 ms.
14
+ > **Version note (0.6.0)**: Edge cloud TTS by default (fast & natural); local TTS (VITS / Kokoro, privacy-first) optional + HTTP hardening + model SHA256 pinning form the merged core; Kokoro adds a model-precision choice (`int8` default 109 MB / `fp32` better quality 311 MB); `wakeWord` (wake word) and `toolBeep` (tool-call beep) are fully wired; the early fork's `asrModel` (bilingual paraformer) and `punctuate` (neural punctuation) were removed — SenseVoice finalization already adds punctuation, and streaming ASR is fixed to zipformer2. Silence split defaults to 1500 ms.
15
15
 
16
16
  ## Fork enhancements (this repo)
17
17
 
@@ -19,7 +19,7 @@
19
19
  - **103 Kokoro voices** (F0-measured gender labels, 4 favourite male voices pinned), browsed with a ◀▶ stepper;
20
20
  - **Delta transport**: partials upload only the new 0.9 s — long push-to-talk segments finalize in seconds;
21
21
  - **Interaction**: a mode-switch button next to the mic (continuous ⇄ hold, persisted); hold mode records only while held;
22
- - **Long segments**: hold up to 10 min (pauses don't split), continuous sentences up to 3 min, 700 ms silence split by default;
22
+ - **Long segments**: continuous listening stitches consecutive segments into one message (internally chunked at 30 s and concatenated across chunks); 1500 ms silence split by default; hold keeps pauses from splitting;
23
23
  - **Hardening**: session-existence check, loopback + Origin guards, per-endpoint rate limits, model SHA256 pinning, download-host allowlist.
24
24
 
25
25
  > ⚠️ The screenshots below (and `assets/demo.gif`) show the **upstream legacy single-button UI**; the current UI adds a mode-switch button next to the mic.
@@ -29,7 +29,7 @@
29
29
 
30
30
  - **Voice mode**: toggle with the microphone button in the input toolbar or the global shortcut `Ctrl+Shift+V`; globally single-active (only one session is in voice mode at a time; switching sessions yields automatically)
31
31
  - **Two interaction modes (switchable in settings, plus a mode-switch button beside the mic)**:
32
- - `toggle` (default) continuous listening: RMS VAD segmentation → streaming zipformer2 ASR (words appear as you speak, live caption preview) → automatic sentence split and send after 700 ms of silence; hold `Ctrl` to force an immediate send
32
+ - `toggle` (default) continuous listening: RMS VAD segmentation → streaming zipformer2 ASR (words appear as you speak, live caption preview) → automatic sentence split after 1500 ms of silence into the draft, consecutive segments joined into one message, then auto-sent after ~1500 ms more of silence (≈3 s total); hold `Ctrl` to force an immediate send
33
33
  - `hold` push-to-talk: short tap to enter/exit, **hold the mic button to talk, release to send** (swipe up to cancel, `Esc`/blur abandons the segment; pauses do not split while held, up to 10 min); hold `Ctrl` to record-by-keyboard, release to send
34
34
  - **Wake word (optional, off by default)**: after setting `wakeWord`, entering voice mode starts in standby, and recognition only begins once the wake word is spoken (e.g. `你好小D`), preventing accidental triggers
35
35
  - **Output pipeline**: only the final answer's `text-delta` is read (reasoning/tool calls are skipped), streamed sentence-by-sentence (Edge cloud by default; local VITS / Kokoro, int8/fp32, optional) with a live caption overlay at the bottom-right; tool calls trigger a beep; the full text is still written to the chat; in voice mode a spoken-format system prompt is injected (short natural sentences, no Markdown decoration), and the reader side strips markers as well for a smoother listening experience
@@ -44,7 +44,7 @@
44
44
  | Gesture | Behaviour |
45
45
  | --- | --- |
46
46
  | Click the mic button / `Ctrl+Shift+V` | Enter / exit voice mode |
47
- | Just speak, pause 700 ms (toggle) | Auto sentence split and send |
47
+ | Just speak, pause ~1500 ms (toggle) | Accumulate into draft, auto-send after ~3 s of quiet |
48
48
  | Hold `Ctrl` (toggle, ≥250 ms speech) | Force-send the current segment immediately |
49
49
  | **Hold the mic button (hold)** | Hold to talk, release to send; swipe up / `Esc` / blur abandons the segment; <250 ms tap exits the mode |
50
50
  | Hold `Ctrl` (hold, ≥600 ms) | Keyboard push-to-talk, release to send |
@@ -86,7 +86,7 @@ npm run prefetch # run inside the plugin dir; writes to the platform ca
86
86
  ## Usage
87
87
 
88
88
  1. Click the mic button in the input toolbar (or press `Ctrl+Shift+V`) to enter voice mode; a status bar appears above the input box
89
- 2. Choose how to speak: just talk and let the 700 ms pause auto-send (toggle); or hold the mic button and release to send (hold)
89
+ 2. Choose how to speak: just talk and let the ~1500 ms pause split and ~3 s of quiet auto-send (toggle); or hold the mic button and release to send (hold)
90
90
  3. The AI answer is read sentence-by-sentence with a caption overlay at the bottom-right; click "Skip" or just start speaking to interrupt
91
91
  4. Click "Exit" in the status bar (or press `Ctrl+Shift+V` again) to leave voice mode
92
92
 
@@ -103,11 +103,11 @@ If a wake word is configured, you land in standby first (the status bar prompts
103
103
  | `voice` | per engine | Voice: 5 VITS speakers; 103 Kokoro voices (◀▶ stepper; 62/68/75/76 favourite males pinned); Edge ShortNames below. The inline "试听" button previews it at the current rate |
104
104
  | `rate` | `1.0` | Reading speed multiplier (0.5 slow ~ 2.0 fast), **applies live** |
105
105
  | `interruptLevel` | `0` | Barge-in sensitivity (host-side VAD frame detection + echo gate): 0 high threshold / 1 medium / 2 low |
106
- | `silenceMs` | `700` | Silence pause in ms that marks the end of a complete sentence |
106
+ | `silenceMs` | `1500` | Silence pause in ms that marks the end of a complete sentence |
107
107
  | `idleTimeoutMinutes` | `10` | Minutes of inactivity before auto-exiting voice mode (reading counts as activity) |
108
108
  | `modelHost` | default | Model download host (use `https://hf-mirror.com` on mainland networks) |
109
109
  | `autoSend` | `true` | Auto-send after a finalized transcript; when off, text only goes to the draft (hold `Ctrl` / release in hold mode still sends) |
110
- | `mode` | `toggle` | Interaction mode: `toggle` continuous listening + 700 ms silence split; `hold` push-to-talk, release to send (short tap exits) |
110
+ | `mode` | `toggle` | Interaction mode: `toggle` continuous listening + 1500 ms silence split; `hold` push-to-talk, release to send (short tap exits) |
111
111
  | `wakeWord` | empty (off) | Wake word (e.g. `你好小D`): speak it after entering to activate, avoiding accidental triggers; empty = off |
112
112
 
113
113
  Effect timing: `voice`/`rate`/`ttsEngine`/`kokoroModel`/`spokenFormat` take effect **immediately** (TTS hot-swap); the rest apply on the next voice-mode entry. Defaults come from the plugin config (`base` layer) — they follow the config unless explicitly changed.
@@ -145,7 +145,7 @@ You can also edit the `voice-mode:` section of `~/.dsh/settings.yaml` directly (
145
145
  voice: zh-CN-XiaoxiaoNeural
146
146
  rate: 1.0
147
147
  interruptLevel: 0
148
- silenceMs: 700
148
+ silenceMs: 1500
149
149
  idleTimeoutMinutes: 10
150
150
  modelHost: https://huggingface.co
151
151
  ```
@@ -181,7 +181,7 @@ You can also edit the `voice-mode:` section of `~/.dsh/settings.yaml` directly (
181
181
  ![architecture](https://raw.githubusercontent.com/qishuilalala/dsh-voice-mode/HEAD/plugin/dsh-voice-mode/assets/architecture.svg)
182
182
 
183
183
  ```
184
- input: mic ──RMS VAD (700 ms silence split)──▶ POST /voice-mode/asr (f32 PCM, 16k, incremental)
184
+ input: mic ──RMS VAD (1500 ms silence split)──▶ POST /voice-mode/asr (f32 PCM, 16k, incremental)
185
185
  │ zipformer2 streaming ASR (host-side WASM)
186
186
 
187
187
  composer draft ──autoSend──▶ model stream ──llm/stream tap (active voice session only)
package/README.md CHANGED
@@ -13,7 +13,7 @@ DeepSeek Harness 语音双工对话模式:会话内一键进入 → 边说边
13
13
 
14
14
  ![语音模式:实时字幕与状态条](https://raw.githubusercontent.com/qishuilalala/dsh-voice-mode/HEAD/assets/screenshot-voice.png)
15
15
 
16
- > **版本说明(0.6.0)**:朗读默认 **Edge 云端**(快速自然),本地 TTS(VITS / Kokoro)可选(隐私优先)+ HTTP 安全加固 + 模型 SHA256 固定为合入核心;Kokoro 新增**模型精度可选**(`int8` 默认 109MB / `fp32` 音质更好 311MB);`wakeWord`(唤醒词)与 `toolBeep`(工具提示音)已完整接入;早期 fork 的 `asrModel`(双语 paraformer)与 `punctuate`(神经标点)已移除——SenseVoice 定稿本身已带标点,流式识别固定为 zipformer2。静音断句默认 700 毫秒。
16
+ > **版本说明(0.6.0)**:朗读默认 **Edge 云端**(快速自然),本地 TTS(VITS / Kokoro)可选(隐私优先)+ HTTP 安全加固 + 模型 SHA256 固定为合入核心;Kokoro 新增**模型精度可选**(`int8` 默认 109MB / `fp32` 音质更好 311MB);`wakeWord`(唤醒词)与 `toolBeep`(工具提示音)已完整接入;早期 fork 的 `asrModel`(双语 paraformer)与 `punctuate`(神经标点)已移除——SenseVoice 定稿本身已带标点,流式识别固定为 zipformer2。静音断句默认 1500 毫秒。
17
17
 
18
18
  ## Fork 增强(本仓库新增)
19
19
 
@@ -26,7 +26,7 @@ DeepSeek Harness 语音双工对话模式:会话内一键进入 → 边说边
26
26
  - **Kokoro 音色全量 103 个**(F0 实测标定性别),四个常用男声置顶带编号;音色面板用 **下拉列表 + ◀▶ 步进**切换;
27
27
  - **增量传输**:partial 只传新增 0.9 秒,长段按住说话松手**秒出定稿**(不再整段重传重解码);
28
28
  - **交互增强**:输入框旁**模式切换按钮**(持续聆听 ⇄ 按住说话,保存到设置);按住说模式下**按住才录、不按住不打断**;
29
- - **长段支持**:按住说整段上限 10 分钟(停顿不断句);持续聆听单句上限 3 分钟;静音断句默认 700 毫秒;
29
+ - **长段支持**:持续聆听连续多段自动拼成一条消息(内部按 30s 分块识别,跨块累积拼接),静音断句默认 1500 毫秒;按住说停顿不断句;
30
30
  - **朗读稳定性**:打断即终止在途合成释放 CPU;句间不再有 3-5 秒停顿;长朗读不触发空闲下线;
31
31
  - **安全加固**:会话存在性校验 / 回环+Origin 校验 / 全端点限流 / **ASR+TTS 全模型 SHA256 固定** / 下载域名白名单 / 重定向守卫。
32
32
 
@@ -36,7 +36,7 @@ DeepSeek Harness 语音双工对话模式:会话内一键进入 → 边说边
36
36
 
37
37
  - **语音模式**:输入框工具排麦克风按钮或全局快捷键 `Ctrl+Shift+V` 进入/退出;全局单活(同一时刻仅一个会话处于语音模式,切换会话自动让出)
38
38
  - **两种交互模式(输入框旁按钮或设置可切换,切换即持久化)**:
39
- - `toggle`(默认)持续聆听:RMS VAD 分段 → zipformer2 流式识别(边说边出字,实时字幕预览)→ 静音约 700 毫秒自动断句进草稿并自动发送;按住 `Ctrl` 强制立即发送
39
+ - `toggle`(默认)持续聆听:RMS VAD 分段 → zipformer2 流式识别(边说边出字,实时字幕预览)→ 静音约 1500 毫秒断句进草稿,连续多段拼成一条消息,再静音约 1500 毫秒(合计约 3 秒)自动发送;按住 `Ctrl` 强制立即发送
40
40
  - `hold` 按住说话:短按进入/退出,**按住麦克风按钮说话、松手即发**(滑出取消、`Esc`/失焦放弃本段);按住期间停顿不断句(上限 10 分钟);`Ctrl` 按住即录、松开即发
41
41
  - **唤醒词(可选,默认关)**:设置 `wakeWord` 后进入待机态,说出唤醒词才开始识别(如「你好小D」)
42
42
  - **输出链路**:只朗读最终答复的 `text-delta`(reasoning/工具调用不读),按句流式朗读(默认 Edge 云端;可切本地 VITS/Kokoro,中英混读选 Kokoro)+ 右下角实时字幕浮层;工具调用触发提示音;全文照常写入聊天记录;口语化提示词(设置 `spokenFormat`,默认开)让回复为自然短句、不带 Markdown 排版符号
@@ -60,7 +60,7 @@ bundle 插件安装后需重启 dsh 生效(Linux:`systemctl restart dsh`;
60
60
  | 手势 | 作用 |
61
61
  | --- | --- |
62
62
  | `Ctrl+Shift+V` | 进入 / 退出语音模式 |
63
- | 直接说话 | `toggle`:边说边出字,停顿约 700 毫秒自动发送;按住 `Ctrl` 强制立即发送 |
63
+ | 直接说话 | `toggle`:边说边出字,停顿约 1500 毫秒断句进草稿、再静音约 1500 毫秒(合计约 3 秒)自动发送;按住 `Ctrl` 强制立即发送 |
64
64
  | 按住麦克风按钮 | `hold`:松手发送;短按退出;滑出 / `Esc` / 失焦放弃本段 |
65
65
  | 点输入框旁模式按钮 | 在「持续聆听 ⇄ 按住说」间切换(保存到设置) |
66
66
  | 说唤醒词 | 待机态激活识别(配置后) |
@@ -77,11 +77,11 @@ bundle 插件安装后需重启 dsh 生效(Linux:`systemctl restart dsh`;
77
77
  | `voice` | 按引擎 | 音色:VITS 五说话人;Kokoro 103 个(下拉+◀▶,62 深沉/68 浑厚/75 清亮/76 磁性置顶);Edge 进入时自动加载全量 322 个。行内「试听」可即时预览 |
78
78
  | `rate` | `1.0` | 朗读语速倍率(0.5 慢速 ~ 2.0 快速),**即时生效** |
79
79
  | `interruptLevel` | `0` | 发声打断灵敏度(服务端 VAD 帧级检测 + 回声门控):0 高门槛 / 1 中 / 2 低 |
80
- | `silenceMs` | `700` | 说完整一句的静音停顿毫秒数 |
80
+ | `silenceMs` | `1500` | 说完整一句的静音停顿毫秒数 |
81
81
  | `idleTimeoutMinutes` | `10` | 无活动自动退出语音模式的分钟数(朗读计为活动) |
82
82
  | `modelHost` | 默认源 | 模型下载源(国内网络填 `https://hf-mirror.com`) |
83
83
  | `autoSend` | `true` | 识别定稿后自动发送;关闭则只进草稿(按住 `Ctrl` / hold 松手仍会发送) |
84
- | `mode` | `toggle` | 交互模式:`toggle` 持续聆听 + 700ms 静音断句;`hold` 按住说话、松手发送(短按退出) |
84
+ | `mode` | `toggle` | 交互模式:`toggle` 持续聆听 + 1500ms 静音断句;`hold` 按住说话、松手发送(短按退出) |
85
85
  | `wakeWord` | 空(关) | 唤醒词(如「你好小D」):进入后先说唤醒词激活,避免误触;空 = 关闭 |
86
86
  | `spokenFormat` | `true` | 语音会话注入口语化提示词:开启后**仅当前语音会话**的回复被注入「口语化短句、不用 Markdown 排版符号」提示词(朗读更顺),**即时生效** |
87
87
 
package/assets/demo.gif CHANGED
Binary file
package/lib/client.js CHANGED
@@ -992,7 +992,6 @@ function createAsrEngine(config, sessionId) {
992
992
  beginHeld() {
993
993
  if (!active || holdActive) return;
994
994
  holdActive = true;
995
- forcePending = true;
996
995
  segmentEpoch++;
997
996
  utteranceEndAt = null;
998
997
  segment = [];
@@ -1307,10 +1306,10 @@ var zh = {
1307
1306
  sev0: "0 \u9AD8\u95E8\u69DB",
1308
1307
  sev1: "1 \u4E2D",
1309
1308
  sev2: "2 \u4F4E",
1310
- 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",
1309
+ descSilence: "\u8BF4\u5B8C\u6574\u4E00\u53E5\u7684\u9759\u97F3\u505C\u987F\u6BEB\u79D2\u6570\uFF08\u9ED8\u8BA4 1500 \u6BEB\u79D2\uFF0C\u7ED9\u601D\u8003\u505C\u987F\u7559\u7A7A\u95F4\uFF1B\u81F3\u5C11 250ms \u8BED\u97F3\u624D\u5224\u53E5\uFF0C\u9632\u77ED\u4FC3\u566A\u58F0\u8BEF\u89E6\u53D1\uFF09",
1311
1310
  descIdle: "\u65E0\u6D3B\u52A8\u81EA\u52A8\u9000\u51FA\u8BED\u97F3\u6A21\u5F0F\u7684\u5206\u949F\u6570\uFF08\u9ED8\u8BA4 10\uFF09",
1312
1311
  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",
1313
- 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",
1312
+ descAutoSend: "\u9759\u97F3\u5230\u70B9\u81EA\u52A8\u53D1\u9001\uFF08\u8FDE\u7EED\u591A\u6BB5\u62FC\u6210\u4E00\u6761\uFF1B\u5173=\u53EA\u8FDB\u8349\u7A3F\uFF1B\u6309\u4F4F Ctrl / hold \u677E\u624B\u4ECD\u53D1\u9001\uFF09",
1314
1313
  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",
1315
1314
  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",
1316
1315
  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",
@@ -1448,10 +1447,10 @@ var en = {
1448
1447
  sev0: "0 high",
1449
1448
  sev1: "1 medium",
1450
1449
  sev2: "2 low",
1451
- descSilence: "Silence pause before a sentence is committed (default 700 ms; at least 250 ms of speech required, guards against noise triggers)",
1450
+ descSilence: "Silence pause before a sentence is committed (default 1500 ms; at least 250 ms of speech required, guards against noise triggers)",
1452
1451
  descIdle: "Auto-exit voice mode after idle minutes (default 10)",
1453
1452
  descModelHost: "ASR model download source (official source / mirror, or any custom URL)",
1454
- descAutoSend: "Auto-send after finalized recognition (off = draft only; Ctrl / hold still sends)",
1453
+ descAutoSend: "Auto-send once quiet (consecutive segments join into one message; off = draft only; Ctrl / hold still sends)",
1455
1454
  descAutoResume: "Auto-resume voice mode when switching back to the last voice session (default off, requires granted mic permission)",
1456
1455
  descSpokenFormat: "Inject spoken-format prompt into voice replies (colloquial, short sentences, no Markdown; default on, live)",
1457
1456
  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)",
@@ -2458,7 +2457,7 @@ function VoiceSettingsCard({ scope }) {
2458
2457
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Section, { title: t("secRecognition"), children: [
2459
2458
  /* @__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) }) }),
2460
2459
  /* @__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) }) }),
2461
- /* @__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 }) }),
2460
+ /* @__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 ?? 1500, min: 500, max: 3e4, step: 100 }) }),
2462
2461
  /* @__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 }) })
2463
2462
  ] }),
2464
2463
  /* @__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://..." }) }) }),
@@ -2485,7 +2484,7 @@ var TELEMETRY_VIEW = [
2485
2484
  { stage: "first-tts-chunk", key: "telFirstChunk" },
2486
2485
  { stage: "first-audio-played", key: "telFirstPlayed" }
2487
2486
  ];
2488
- var BUILD_TAG = "697c538";
2487
+ var BUILD_TAG = "4ab9c0d";
2489
2488
  var TELEMETRY_FLAG = "dsh-voice-mode.telemetry";
2490
2489
  var telemetryEnabled = typeof localStorage !== "undefined" && localStorage.getItem(TELEMETRY_FLAG) === "1";
2491
2490
  console.log("[dsh-voice] build=" + BUILD_TAG);
@@ -2778,7 +2777,7 @@ function createVoiceBus(basePath = BASE_PATH2, ctx) {
2778
2777
  let activeSessionId = null;
2779
2778
  const DEFAULT_BOOT = {
2780
2779
  basePath: BASE_PATH2,
2781
- silenceMs: 700,
2780
+ silenceMs: 1500,
2782
2781
  interruptLevel: 0,
2783
2782
  idleTimeoutMinutes: 10,
2784
2783
  autoSend: true,
@@ -3241,6 +3240,7 @@ function MicButton({
3241
3240
  const engineRef = (0, import_react2.useRef)(null);
3242
3241
  const actionsRef = (0, import_react2.useRef)(inputActions);
3243
3242
  const submitTimerRef = (0, import_react2.useRef)(null);
3243
+ const autoSendTimerRef = (0, import_react2.useRef)(null);
3244
3244
  const idleTimerRef = (0, import_react2.useRef)(null);
3245
3245
  const runningRef = (0, import_react2.useRef)(false);
3246
3246
  const mountedRef = (0, import_react2.useRef)(true);
@@ -3248,7 +3248,7 @@ function MicButton({
3248
3248
  const manualHoldRef = (0, import_react2.useRef)(false);
3249
3249
  const breakRef = (0, import_react2.useRef)(null);
3250
3250
  const pausedForHiddenRef = (0, import_react2.useRef)(false);
3251
- 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 };
3251
+ const bootNow = () => bus.ui.boot ?? { basePath: "/voice-mode", silenceMs: 1500, interruptLevel: 0, idleTimeoutMinutes: 10, autoSend: true, autoResume: false, mode: "toggle", bargeInMode: "auto", echoGateDb: 6, shortcut: "Ctrl+Shift+V", wakeWord: "", toolBeep: false };
3252
3252
  useVoiceCss();
3253
3253
  const [, bumpUi] = (0, import_react2.useState)(0);
3254
3254
  (0, import_react2.useEffect)(
@@ -3309,6 +3309,7 @@ function MicButton({
3309
3309
  setLocalMode("off");
3310
3310
  clearIdle();
3311
3311
  cancelPendingSubmit();
3312
+ cancelAutoSend();
3312
3313
  clearBreakTimer();
3313
3314
  setHolding(false);
3314
3315
  isSpeechTrueCount = 0;
@@ -3329,11 +3330,61 @@ function MicButton({
3329
3330
  submitTimerRef.current = null;
3330
3331
  }
3331
3332
  };
3333
+ const cancelAutoSend = () => {
3334
+ if (autoSendTimerRef.current) {
3335
+ clearTimeout(autoSendTimerRef.current);
3336
+ autoSendTimerRef.current = null;
3337
+ }
3338
+ };
3339
+ const submitDraftNow = (expectedText) => {
3340
+ cancelPendingSubmit();
3341
+ const actions = actionsRef.current;
3342
+ const submitFn = actions?.submit;
3343
+ if (typeof submitFn !== "function") return;
3344
+ const draftSnapshot = (expectedText ?? draftRef.current).trim();
3345
+ if (!draftSnapshot) return;
3346
+ const doSubmit = () => {
3347
+ try {
3348
+ const r = submitFn();
3349
+ if (r && typeof r.then === "function") {
3350
+ r.catch(() => {
3351
+ bus.setUi({ error: t("sendFailKept") });
3352
+ });
3353
+ }
3354
+ } catch {
3355
+ bus.setUi({ error: t("sendFailKept") });
3356
+ }
3357
+ };
3358
+ doSubmit();
3359
+ let retryCount = 0;
3360
+ submitTimerRef.current = setInterval(() => {
3361
+ retryCount++;
3362
+ const phase = phaseRef.current;
3363
+ if (retryCount > 3 || phase === "submitting" || phase === "adjudicating" || draftRef.current.trim() !== draftSnapshot) {
3364
+ cancelPendingSubmit();
3365
+ return;
3366
+ }
3367
+ doSubmit();
3368
+ }, 500);
3369
+ };
3370
+ const scheduleAutoSend = () => {
3371
+ cancelAutoSend();
3372
+ const delay = bootNow().silenceMs;
3373
+ autoSendTimerRef.current = setTimeout(() => {
3374
+ autoSendTimerRef.current = null;
3375
+ const eng = engineRef.current;
3376
+ if (eng && (eng.state === "speech" || eng.holding)) return;
3377
+ if (bus.ui.playing) return;
3378
+ if (bootNow().autoSend === false) return;
3379
+ submitDraftNow();
3380
+ }, delay);
3381
+ };
3332
3382
  const exitMode = async (_reason) => {
3333
3383
  if (localRef.current === "off") return;
3334
3384
  setLocalMode("off");
3335
3385
  clearIdle();
3336
3386
  cancelPendingSubmit();
3387
+ cancelAutoSend();
3337
3388
  isSpeechTrueCount = 0;
3338
3389
  fixtureRecorder.save("exit");
3339
3390
  breakRef.current = null;
@@ -3521,6 +3572,7 @@ function MicButton({
3521
3572
  }
3522
3573
  engine.onState((s) => {
3523
3574
  bus.setUi({ state: s });
3575
+ if (s === "speech") cancelAutoSend();
3524
3576
  if (s === "idle") resetIdle();
3525
3577
  });
3526
3578
  engine.onError((key) => {
@@ -3538,45 +3590,27 @@ function MicButton({
3538
3590
  const actions = actionsRef.current;
3539
3591
  const trimmed = text.trim();
3540
3592
  if (!trimmed) return;
3593
+ let nextDraft = trimmed;
3541
3594
  try {
3542
3595
  const curText = draftRef.current;
3543
- const nextDraft = curText ? `${curText} ${trimmed}` : trimmed;
3596
+ nextDraft = curText ? `${curText} ${trimmed}` : trimmed;
3544
3597
  if (typeof actions?.setDraft === "function") actions.setDraft(nextDraft);
3545
3598
  else if (typeof actions?.setDraft === "function") actions.setDraft(nextDraft);
3546
- else {
3547
- }
3548
3599
  } catch {
3549
3600
  try {
3550
3601
  actions?.setDraft?.(trimmed);
3551
3602
  } catch {
3552
3603
  }
3553
3604
  }
3554
- if (bus.ui.playing && !meta?.force) return;
3555
- if (bootNow().autoSend === false && !meta?.force) return;
3556
- const doSubmit = () => {
3557
- try {
3558
- const r = actions?.submit?.();
3559
- if (r && typeof r.then === "function") {
3560
- r.catch(() => {
3561
- bus.setUi({ error: t("sendFailKept") });
3562
- });
3563
- }
3564
- } catch {
3565
- bus.setUi({ error: t("sendFailKept") });
3566
- }
3567
- };
3568
- cancelPendingSubmit();
3569
- doSubmit();
3570
- let retryCount = 0;
3571
- submitTimerRef.current = setInterval(() => {
3572
- retryCount++;
3573
- const phase = phaseRef.current;
3574
- if (retryCount > 3 || phase === "submitting" || phase === "adjudicating" || draftRef.current.trim() !== trimmed) {
3575
- cancelPendingSubmit();
3576
- return;
3577
- }
3578
- doSubmit();
3579
- }, 500);
3605
+ if (meta?.force) {
3606
+ cancelAutoSend();
3607
+ submitDraftNow(nextDraft);
3608
+ return;
3609
+ }
3610
+ if (bootNow().mode === "hold") return;
3611
+ if (bus.ui.playing) return;
3612
+ if (bootNow().autoSend === false) return;
3613
+ scheduleAutoSend();
3580
3614
  });
3581
3615
  bus.setUi({ state: "idle", partial: "", levels: [], error: null, model: null, ttsNotice: null });
3582
3616
  if (!mountedRef.current) {
@@ -3650,6 +3684,7 @@ function MicButton({
3650
3684
  mountedRef.current = false;
3651
3685
  clearIdle();
3652
3686
  cancelPendingSubmit();
3687
+ cancelAutoSend();
3653
3688
  isSpeechTrueCount = 0;
3654
3689
  const sid = sidRef.current;
3655
3690
  if ((localRef.current === "on" || localRef.current === "pending") && sid) {
@@ -4040,7 +4075,7 @@ function VoiceStatusBar({ bus, sessionId }) {
4040
4075
  if (b.ui.interruptConfirmMs !== void 0) {
4041
4076
  telParts.push(`${t("interruptConfirm")} ${fmt(b.ui.interruptConfirmMs)}`);
4042
4077
  }
4043
- if (b.ui.echoLevels) {
4078
+ if (telemetryEnabled && b.ui.echoLevels) {
4044
4079
  const el = b.ui.echoLevels;
4045
4080
  telParts.push(
4046
4081
  `AEC delay=${Math.round(b.ui.echoDelayMs ?? 0)}ms floor=${el.floorRms.toFixed(4)} resid=${el.residualRms.toFixed(4)}`
package/lib/index.js CHANGED
@@ -392,7 +392,7 @@ function rmsOf(samples) {
392
392
  return Math.sqrt(sum / samples.length);
393
393
  }
394
394
  function createAsrRuntime(options) {
395
- const { cacheDir, modelHost, broadcast, senseVoice, allowCustomHost } = options;
395
+ const { cacheDir, modelHost, broadcast, senseVoice, silenceMs, allowCustomHost } = options;
396
396
  let lastProgress = null;
397
397
  const localBroadcast = (event, payload) => {
398
398
  if (event === "asr-progress") lastProgress = payload;
@@ -473,11 +473,11 @@ function createAsrRuntime(options) {
473
473
  }
474
474
  return vadLoading;
475
475
  };
476
- const newVad = (vadPath, threshold = 0.5) => createVad({
476
+ const newVad = (vadPath, threshold = 0.5, minSilenceDuration = 0.5) => createVad({
477
477
  sileroVad: {
478
478
  model: vadPath,
479
479
  threshold,
480
- minSilenceDuration: 0.5,
480
+ minSilenceDuration,
481
481
  minSpeechDuration: 0.25,
482
482
  maxSpeechDuration: 20,
483
483
  windowSize: 512
@@ -492,7 +492,7 @@ function createAsrRuntime(options) {
492
492
  if (seg.vad) return seg.vad;
493
493
  const vadPath = await ensureVadModel();
494
494
  if (!vadPath) return null;
495
- seg.vad = newVad(vadPath);
495
+ seg.vad = newVad(vadPath, 0.5, silenceMs() / 1e3);
496
496
  return seg.vad;
497
497
  };
498
498
  const detectVads = /* @__PURE__ */ new Map();
@@ -1184,44 +1184,54 @@ var TtsQueue = class {
1184
1184
  try {
1185
1185
  while (q.pending.length > 0) {
1186
1186
  const item = q.pending.shift();
1187
- try {
1188
- const buf = await this.engine.synthesize(item.text);
1189
- if (item.epoch !== q.epoch) continue;
1190
- q.errorNotified = false;
1191
- q.backoff = 0;
1192
- const sentenceId = q.seq++;
1193
- const mime = this.engine.mime;
1194
- const dataFrame = {
1195
- sessionId,
1196
- sentenceId,
1197
- chunkId: 0,
1198
- final: false,
1199
- audio: buf.toString("base64"),
1200
- mime
1201
- };
1202
- for (const fn of this.listeners) {
1203
- try {
1204
- fn(dataFrame);
1205
- } catch {
1187
+ const MAX_SYNTH_ATTEMPTS = 3;
1188
+ let buf = null;
1189
+ for (let attempt = 0; attempt < MAX_SYNTH_ATTEMPTS; attempt++) {
1190
+ if (item.epoch !== q.epoch) break;
1191
+ try {
1192
+ buf = await this.engine.synthesize(item.text);
1193
+ break;
1194
+ } catch (e) {
1195
+ console.warn(`[dsh-voice-mode] synthesis failed (${attempt + 1}/${MAX_SYNTH_ATTEMPTS}): ${String(e)}`);
1196
+ if (attempt < MAX_SYNTH_ATTEMPTS - 1) {
1197
+ await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
1206
1198
  }
1207
1199
  }
1208
- const finalFrame = {
1209
- sessionId,
1210
- sentenceId,
1211
- chunkId: 1,
1212
- final: true,
1213
- text: item.text,
1214
- audio: "",
1215
- mime
1216
- };
1217
- for (const fn of this.listeners) {
1218
- try {
1219
- fn(finalFrame);
1220
- } catch {
1221
- }
1200
+ }
1201
+ if (item.epoch !== q.epoch) continue;
1202
+ if (buf === null) continue;
1203
+ q.errorNotified = false;
1204
+ q.backoff = 0;
1205
+ const sentenceId = q.seq++;
1206
+ const mime = this.engine.mime;
1207
+ const dataFrame = {
1208
+ sessionId,
1209
+ sentenceId,
1210
+ chunkId: 0,
1211
+ final: false,
1212
+ audio: buf.toString("base64"),
1213
+ mime
1214
+ };
1215
+ for (const fn of this.listeners) {
1216
+ try {
1217
+ fn(dataFrame);
1218
+ } catch {
1219
+ }
1220
+ }
1221
+ const finalFrame = {
1222
+ sessionId,
1223
+ sentenceId,
1224
+ chunkId: 1,
1225
+ final: true,
1226
+ text: item.text,
1227
+ audio: "",
1228
+ mime
1229
+ };
1230
+ for (const fn of this.listeners) {
1231
+ try {
1232
+ fn(finalFrame);
1233
+ } catch {
1222
1234
  }
1223
- } catch (e) {
1224
- console.warn(`[dsh-voice-mode] synthesis failed: ${String(e)}`);
1225
1235
  }
1226
1236
  }
1227
1237
  } catch (e) {
@@ -1773,7 +1783,7 @@ var VOICE_SETTINGS_DEFAULTS = {
1773
1783
  voice: "zh-CN-XiaoxiaoNeural",
1774
1784
  rate: 1,
1775
1785
  interruptLevel: 0,
1776
- silenceMs: 700,
1786
+ silenceMs: 1500,
1777
1787
  idleTimeoutMinutes: 10,
1778
1788
  modelHost: "",
1779
1789
  autoSend: true,
@@ -1801,10 +1811,10 @@ function createVoiceSettingsSchema(defs) {
1801
1811
  ),
1802
1812
  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"),
1803
1813
  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"),
1804
- 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 700 \u6BEB\u79D2\uFF1B\u81F3\u5C11 250ms \u8BED\u97F3\u624D\u5224\u53E5\uFF0C\u9632\u77ED\u4FC3\u566A\u58F0\u8BEF\u89E6\u53D1\uFF09"),
1814
+ 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 1500 \u6BEB\u79D2\uFF0C\u7ED9\u601D\u8003\u505C\u987F\u7559\u7A7A\u95F4\uFF1B\u81F3\u5C11 250ms \u8BED\u97F3\u624D\u5224\u53E5\uFF0C\u9632\u77ED\u4FC3\u566A\u58F0\u8BEF\u89E6\u53D1\uFF09"),
1805
1815
  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"),
1806
1816
  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"),
1807
- 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"),
1817
+ autoSend: z.boolean().default(d.autoSend).description("\u9759\u97F3\u5230\u70B9\u81EA\u52A8\u53D1\u9001\uFF08\u8FDE\u7EED\u591A\u6BB5\u62FC\u6210\u4E00\u6761\u6D88\u606F\uFF1B\u5173\u95ED\u5219\u53EA\u8FDB\u8349\u7A3F\u4F9B\u7F16\u8F91\uFF1B\u6309\u4F4F Ctrl / hold \u677E\u624B\u4ECD\u4F1A\u53D1\u9001\uFF09"),
1808
1818
  autoResume: z.boolean().default(d.autoResume).description("\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\u5173\u95ED\u5219\u6BCF\u6B21\u5207\u6362\u4F1A\u8BDD\u540E\u9700\u91CD\u65B0\u70B9\u9EA6\u514B\u98CE\uFF09"),
1809
1819
  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"),
1810
1820
  bargeInMode: z.union([z.const("auto"), z.const("manual")]).default(d.bargeInMode).description("\u6253\u65AD\u65B9\u5F0F\uFF1Aauto \u81EA\u52A8\u6253\u65AD\uFF08\u5F00\u53E3\u5373\u6253\u65AD\uFF0C\u8033\u673A/\u5B89\u9759\u73AF\u5883\u63A8\u8350\uFF09\uFF1Bmanual \u624B\u52A8\u6253\u65AD\uFF08\u5916\u653E\u63A8\u8350\u2014\u2014\u5916\u653E\u56DE\u58F0\u4F1A\u8BEF\u89E6\u53D1\u81EA\u52A8\u6253\u65AD\uFF0C\u6539\u6309\u4F4F\u9EA6\u514B\u98CE/Ctrl \u663E\u5F0F\u6253\u65AD\uFF0C\u6C38\u4E0D\u81EA\u6253\u65AD\uFF09"),
@@ -1828,7 +1838,7 @@ var Config = z.object({
1828
1838
  voice: z.string().default("zh-CN-XiaoxiaoNeural"),
1829
1839
  rate: z.number().default(1),
1830
1840
  interruptLevel: z.union([z.const(0), z.const(1), z.const(2)]).default(0),
1831
- silenceMs: z.number().default(700),
1841
+ silenceMs: z.number().default(1500),
1832
1842
  idleTimeoutMinutes: z.number().default(10)
1833
1843
  });
1834
1844
  function apply(ctx, config) {
@@ -1896,6 +1906,8 @@ function apply(ctx, config) {
1896
1906
  modelHost: () => vset.modelHost,
1897
1907
  // P4:SenseVoice 定稿重译开关(实时读取,关闭则不下载/不创建模型)。
1898
1908
  senseVoice: () => vset.senseVoice,
1909
+ // 断句静音阈值(实时读取):端点 VAD minSilenceDuration 跟随设置。
1910
+ silenceMs: () => vset.silenceMs,
1899
1911
  allowCustomHost: config.allowCustomModelHost,
1900
1912
  broadcast
1901
1913
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-voice-mode",
3
3
  "description": "Full-duplex voice plugin for DeepSeek Harness: local zipformer2 streaming ASR (no API key) → editable draft; Edge TTS or local VITS / Kokoro read-aloud with live captions; true barge-in; hardened HTTP surface + model SHA256 pinning; compatible with dsh 0.1.1 → 0.1.2. · DSH 语音双工插件:流式识别入草稿、按句朗读+实时字幕、开口即打断;免 API Key、安全加固、新旧版本兼容。",
4
- "version": "0.7.1",
4
+ "version": "0.7.3",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "repository": {