dsh-voice-mode 0.7.8 → 0.7.9

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
@@ -14,7 +14,7 @@ interrupts playback and the running turn. No API key.
14
14
 
15
15
  ![dsh-voice-mode full-duplex voice conversation](https://raw.githubusercontent.com/qishuilalala/dsh-voice-mode/HEAD/assets/hero-banner.png)
16
16
 
17
- > **Version note (v0.7.8, 2026-09-18)**: **Wake-word three-part UX fix** (Issue #10) — ① fault-tolerant matching (homophone substitutions / wrong first char / leading fillers are absorbed; a 3-4 char wake word is recommended); standby now live-shows "what it heard", making wake success/failure self-diagnosable; post-barge-in state consistency fixed (host-reset race resolved no more getting stuck after an interrupt). Otherwise as v0.7.7: Edge cloud TTS by default, local TTS (VITS / Kokoro) optional; `wakeWord` / `toolBeep` fully wired; silence split defaults to 1500 ms.
17
+ > **Version note (v0.7.9, 2026-09-18)**: **Wake-word pipeline overhaul** (Issue #10 + real-machine retest) — a harness driving the real engine pinpointed and fixed five flow defects: TTS echo polluting the standby segment while the agent reads (could not wake); only above-threshold frames uploaded, so trailing characters never flushed (wake word truncated); discarding the whole segment on a wake hit (saying "wake word + command" in one breath sent only the tail); the command hanging when the hit arrived after you stopped speaking; pausing after the wake word closed the command window (command lost). **The wake word may now be said together with your command — it is stripped and never sent**, standby live-shows what it heard, and "wake word … pause … command" works. Otherwise as v0.7.7: Edge cloud TTS by default, local TTS (VITS / Kokoro) optional; silence split defaults to 1500 ms.
18
18
 
19
19
  ---
20
20
 
@@ -37,7 +37,7 @@ interrupts playback and the running turn. No API key.
37
37
  - **Two interaction modes (switchable in settings, plus a mode-switch button beside the mic)**:
38
38
  - `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
39
39
  - `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
40
- - **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. Matching is fault-tolerant (homophone substitutions, a wrong first char, and leading fillers are absorbed); in standby the status bar live-shows "say '<word>' · what it heard", so wake success/failure is self-diagnosable. Note: the wake word only gates "start of recognition" **after each utterance split or a barge-in the engine returns to standby and the wake word must be repeated**; barge-in itself is VAD-based (any speech interrupts) and unrelated to the wake word
40
+ - **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`). **You may say it together with your command** ("wake word, check the weather") the wake word is stripped from the transcript/caption and never sent; matching is fault-tolerant (homophone substitutions, a wrong first char, leading fillers); 3-4 characters recommended; in standby the status bar live-shows "say '<word>' · what it heard". Boundaries (important): **toggle mode only** (inactive with `hold` / manual barge-in); **it must be repeated after each completed utterance or barge-in** (if you only said the wake word, the engine keeps listening so you may pause before the command); **saying it while the agent is reading does not trigger** (barge-in is VAD-based and unrelated to the wake word)
41
41
  - **Caption tiers (batch 3)**: `captionFontSize` 4 levels (0=12px / 1=14px / 2=18px / 3=24px) + `captionMaxWidth` 3 levels (0=50vw / 1=70vw / 2=90vw); adapts to narrow viewports
42
42
  - **Yield semantics (batch 5 / ADR-0008)**: `backchannelYield` on by default (I10-exempt) — saying `嗯 / 对 / right` while the AI is reading auto-pauses for 1.5 s; genuine speech still triggers hard barge-in; lets the LLM yield the turn
43
43
  - **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
@@ -143,7 +143,7 @@ If a wake word is configured, you land in standby first (the status bar prompts
143
143
  | `modelHost` | default | Model download host (use `https://hf-mirror.com` on mainland networks) |
144
144
  | `autoSend` | `true` | Auto-send once quiet (consecutive segments join into one message); when off, text only goes to the draft (hold `Ctrl` / release in hold mode still sends) |
145
145
  | `mode` | `toggle` | Interaction mode: `toggle` continuous listening + 1500 ms silence split; `hold` push-to-talk, release to send (short tap exits) |
146
- | `wakeWord` | empty (off) | Wake word (e.g. `你好小D`): speak it after entering to activate, avoiding accidental triggers; empty = off. Fault-tolerant matching (edit distance ≤1 + a 3-char leading window absorbs homophones/fillers); **use at least 2 characters** (single-char words get no tolerance); must be repeated after each utterance split or barge-in; toggle mode only |
146
+ | `wakeWord` | empty (off) | Wake word (e.g. `你好小D`): speak it after entering to activate; empty = off. **May be said together with your command** — the wake word is stripped and never sent; fault-tolerant matching (edit distance ≤1 + a 3-char leading window absorbs homophones/fillers); **3-4 characters recommended** (single-char words get no tolerance; a 2-char word also absorbs any same-first-char 2-char word); must be repeated after each utterance split or barge-in; **toggle mode only**; not triggered while the agent is reading |
147
147
  | `spokenFormat` | `true` | Spoken-format system prompt: inject "short natural sentences, no Markdown decoration" into voice-mode replies only, **applies live** |
148
148
  | `senseITN` | `true` | Batch 2 P0: SenseVoice inverse text normalization (number / date / currency; on by default) |
149
149
  | `captionFontSize` | `0` | Batch 3 P0: caption font tier 0=12px / 1=14px / 2=18px / 3=24px (default 0 is byte-equivalent to legacy) |
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
- > **版本说明(v0.7.8,2026-09-18)**:**唤醒词三项体验修复**(Issue #10)——① 匹配带容错(同音字替换 / 首字错 / 前置语气词自动吸收,建议唤醒词 3-4 字);② 待机态实时显示「它听到了什么」,唤醒成败可自查;③ 打断后状态一致性根治(host 清场竞态修复,打断后不再卡住)。其余同 v0.7.7:朗读默认 Edge 云端,本地 TTS(VITS / Kokoro)可选;`wakeWord` / `toolBeep` 完整接入;静音断句默认 1500 毫秒。
16
+ > **版本说明(v0.7.9,2026-09-18)**:**唤醒词链路全面修复**(Issue #10 + 真机复测)——台架驱动真引擎逐项定位并修掉五处流程缺陷:① 朗读期 TTS 回声污染待机段(喊不醒);② 只上传超门限帧导致尾字不 flush(唤醒词只剩半截);③ 唤醒命中丢整段(连说「唤醒词+命令」只发出去尾部几个字);④ 命中晚于停口时命令悬挂不定稿;⑤ 只喊唤醒词后停顿会关掉命令窗口(命令丢失)。**现在唤醒词可与命令连说、词头自动剥离不进消息**,待机态实时显示「它听到了什么」,支持「唤醒词…停顿…命令」。其余同 v0.7.7:朗读默认 Edge 云端,本地 TTS(VITS / Kokoro)可选;静音断句默认 1500 毫秒。
17
17
 
18
18
  ---
19
19
 
@@ -42,7 +42,7 @@ DeepSeek Harness 语音双工对话模式:会话内一键进入 → 边说边
42
42
  - **两种交互模式(输入框旁按钮或设置可切换,切换即持久化)**:
43
43
  - `toggle`(默认)持续聆听:RMS VAD 分段 → zipformer2 流式识别(边说边出字,实时字幕预览)→ 静音约 1500 毫秒断句进草稿,连续多段拼成一条消息,再静音约 1500 毫秒(合计约 3 秒)自动发送;按住 `Ctrl` 强制立即发送
44
44
  - `hold` 按住说话:短按进入/退出,**按住麦克风按钮说话、松手即发**(滑出取消、`Esc`/失焦放弃本段);按住期间停顿不断句(上限 10 分钟);`Ctrl` 按住即录、松开即发
45
- - **唤醒词(可选,默认关)**:设置 `wakeWord` 后进入待机态,说出唤醒词才开始识别(如「你好小D」)。匹配带容错——同音字替换(小莫→小墨)、首字错、前置语气词(呃/喂/我说)均在吸收范围;待机态状态条实时显示「说『x』开始 · 它听到的转写」,唤醒成败可自查。注意:唤醒词只管「开始识别」的门,**每句断句或打断后回到待机态需重说唤醒词**;打断(barge-in)由 VAD 开口即触发,与唤醒词无关
45
+ - **唤醒词(可选,默认关)**:设置 `wakeWord` 后进入待机态,说出唤醒词才开始识别(如「你好小D」)。**可直接与命令连说**(「你好小D,帮我查天气」)——唤醒词会在定稿/字幕里自动剥掉、不进消息;匹配带容错(同音字替换「小莫→小墨」、首字错、前置语气词「呃/喂/我说」),建议 3-4 字;待机态状态条实时显示「说『x』开始 · 它听到的转写」,唤醒成败可自查。边界(重要):**仅 `toggle` 模式生效**(`hold` / 手动打断下唤醒词静默失效);**说完整一句后回到待机态需重说唤醒词**(只喊唤醒词、还没说命令时保持聆听,可停顿想好再说);**朗读期说唤醒词不触发**(打断由 VAD 开口即触发,与唤醒词无关)
46
46
  - **字幕档位**(批 3):`captionFontSize` 4 档(0=12px / 1=14px / 2=18px / 3=24px)+ `captionMaxWidth` 3 档(0=50vw / 1=70vw / 2=90vw),窄屏自适应
47
47
  - **让位语义**(批 5 / ADR-0008):`backchannelYield` 默认开(I10 豁免)—— 朗读期用户插话「嗯/对」自动让位 1.5s,真要说走硬打断;让 LLM 主动让出话轮(人格层让位);关掉恢复改造前行为
48
48
  - **输出链路**:只朗读最终答复的 `text-delta`(reasoning/工具调用不读),按句流式朗读(默认 Edge 云端;可切本地 VITS/Kokoro,中英混读选 Kokoro)+ 右下角实时字幕浮层;工具调用触发提示音;全文照常写入聊天记录;口语化提示词(设置 `spokenFormat`,默认开)让回复为自然短句、不带 Markdown 排版符号
@@ -100,7 +100,7 @@ bundle 插件安装后需重启 dsh 生效(Linux:`systemctl restart dsh`;
100
100
  | `modelHost` | 默认源 | 模型下载源(国内网络填 `https://hf-mirror.com`) |
101
101
  | `autoSend` | `true` | 静音到点自动发送(连续多段拼成一条消息);关闭则只进草稿(按住 `Ctrl` / hold 松手仍会发送) |
102
102
  | `mode` | `toggle` | 交互模式:`toggle` 持续聆听 + 1500ms 静音断句;`hold` 按住说话、松手发送(短按退出) |
103
- | `wakeWord` | 空(关) | 唤醒词(如「你好小D」):进入后先说唤醒词激活,避免误触;空 = 关闭。匹配带容错(编辑距离 ≤1 + 前 3 字符前导窗口,吸收同音字/语气词);**建议 3-4 字**——单字词无容错、2 字词的容错会连带吸收所有同首字的 2 字词(如「小莫」也会唤醒「小张」),介意误唤醒用 3 字以上;每句断句或打断后回待机需重说;仅 toggle 模式生效 |
103
+ | `wakeWord` | 空(关) | 唤醒词(如「你好小D」):进入后先说唤醒词激活,避免误触;空 = 关闭。**可与命令连说**,词头自动剥掉不进消息;匹配带容错(编辑距离 ≤1 + 前 3 字符前导窗口,吸收同音字/语气词);**建议 3-4 字**——单字词无容错、2 字词的容错会连带吸收所有同首字的 2 字词(如「小莫」也会唤醒「小张」),介意误唤醒用 3 字以上;每句断句或打断后回待机需重说;**仅 toggle 模式生效**(hold / 手动打断下不生效);朗读期说唤醒词不触发 |
104
104
  | `spokenFormat` | `true` | 语音会话注入口语化提示词:开启后**仅当前语音会话**的回复被注入「口语化短句、不用 Markdown 排版符号」提示词(朗读更顺),**即时生效** |
105
105
  | `senseITN` | `true` | 批 2 P0:SenseVoice 逆文本归一化(数字/日期/货币规范化;默认开) |
106
106
  | `captionFontSize` | `0` | 批 3 P0:字幕字号档位 0=12px / 1=14px / 2=18px / 3=24px(默认 0 与现状字节等价) |
@@ -225,6 +225,7 @@ flowchart LR
225
225
  | 识别到但不是我要说的 | 环境噪声或唤醒词误判:降低音量、提高 `interruptLevel`(高门槛)或启用 `wakeWord` |
226
226
  | 唤醒词唤不醒 | 先看待机态状态条的实时转写(它听到了什么):同音字 1 字内已自动容错;停顿约 1.5 秒(= 静音断句时长)再说,清掉待机段残留语音、从段首重新匹配;换一个转写稳定的词(建议 3-4 字) |
227
227
  | 打断后说话没反应(开了唤醒词) | 唤醒词只管「开始识别」的门:打断/每句断句后回到待机态,**需重说唤醒词**再继续说;待机态状态条会显示「说『x』开始」提示 |
228
+ | 唤醒词完全没反应 | ① 确认交互模式是 `toggle`(`hold` 与手动打断下唤醒词不生效);② 确认 `bargeInMode`:`manual`(或 `detect` 在本机无原生回声消除时自动落 manual)会关闭常驻聆听 → 唤醒词失效,改回 `auto` 或按住麦克风/Ctrl;③ 朗读期说唤醒词不触发(先等 AI 说完);④ 看待机态实时转写,确认它听到了什么(同音字 1 字内已容错,建议 3-4 字词) |
228
229
  | 按住说话松手后没反应 | 确认交互模式为「按住说」且按住期间按钮高亮;松手后识别定稿约 1 秒内进入草稿 |
229
230
  | 打不断(朗读中开口无反应) | 调高 `interruptLevel`(更敏感档 = 0 或 1)或检查麦克风权限;若 VAD 持续不触发可临时切到「手动打断」(`mode: hold` / 唤醒词定时延后);**不要**调 `echoGateDb`——真机 3.1 分钟朗读期里 Silero 0/777 帧把回声判成语音,原生 AEC 生效时此阈值从未被执行(详见 ADR-0006) |
230
231
  | 字幕被输入框挡住 | 默认 `captionMaxWidth=1`(70vw)+ `captionFontSize=0`(12px)在窄屏可能与底部输入框重叠;调高档位或点字幕浮层「×」收起 |
package/lib/client.js CHANGED
@@ -87,6 +87,24 @@ function editDistanceWithin(a, b, max) {
87
87
  }
88
88
  return prev[lb];
89
89
  }
90
+ function wakePrefixLength(text, wakeWord) {
91
+ const w = normalizeWake(wakeWord);
92
+ if (!w || w.length < 2) return 0;
93
+ const raw = String(text ?? "");
94
+ for (let i = 1; i <= raw.length; i++) {
95
+ const p = normalizeWake(raw.slice(0, i));
96
+ if (p.length < w.length) continue;
97
+ if (p.length > w.length + WAKE_MAX_EDITS) break;
98
+ if (editDistanceWithin(p, w, WAKE_MAX_EDITS) <= WAKE_MAX_EDITS) return i;
99
+ }
100
+ return 0;
101
+ }
102
+ function stripWakePrefix(text, wakeWord) {
103
+ const n = wakePrefixLength(text, wakeWord);
104
+ const raw = String(text ?? "");
105
+ if (n <= 0) return raw;
106
+ return raw.slice(n).replace(/^[\s\u3000,。!?!?;;、,.]+/, "");
107
+ }
90
108
  function matchWakeWord(partial, wakeWord) {
91
109
  const w = normalizeWake(wakeWord);
92
110
  if (!w) return false;
@@ -477,6 +495,7 @@ function createAsrEngine(config, sessionId) {
477
495
  let resetGate = Promise.resolve();
478
496
  let segmentEpoch = 0;
479
497
  let wakeSilenceMs = 0;
498
+ let wakeConsumed = false;
480
499
  let forcePending = false;
481
500
  let uploadedSamples = 0;
482
501
  let detectChunks = [];
@@ -583,15 +602,9 @@ function createAsrEngine(config, sessionId) {
583
602
  uploadedSamples = Math.max(uploadedSamples, from + samples.length);
584
603
  emit(partialListeners, out.text ?? "");
585
604
  if (matchWakeWord(out.text ?? "", wakeWord)) {
586
- segmentEpoch++;
587
- segment = [];
588
- segmentMs = 0;
589
- speechMs = 0;
590
- silenceMs = 0;
591
- prePad = [];
592
- uploadedSamples = 0;
593
- utteranceEndAt = null;
594
- await resetHostStream();
605
+ wakeConsumed = true;
606
+ speechActive = true;
607
+ silenceMs = wakeSilenceMs;
595
608
  if (active) setState("listening");
596
609
  }
597
610
  return;
@@ -599,7 +612,7 @@ function createAsrEngine(config, sessionId) {
599
612
  if (out.isSpeech !== void 0) config.onIsSpeech?.(out.isSpeech);
600
613
  if (state === "loading-model") setState("speech");
601
614
  uploadedSamples = Math.max(uploadedSamples, from + samples.length);
602
- emit(partialListeners, out.text ?? "");
615
+ emit(partialListeners, wakeConsumed ? stripWakePrefix(out.text ?? "", wakeWord) : out.text ?? "");
603
616
  if (config.backchannelYield !== false && // 默认 true(I10 豁免);显式 false 时跳过
604
617
  speechActive && (config.isPlaying?.() ?? false) && matchBackchannel(out.text ?? "")) {
605
618
  config.onBackchannel?.();
@@ -671,6 +684,30 @@ function createAsrEngine(config, sessionId) {
671
684
  release();
672
685
  })();
673
686
  };
687
+ const trimPrePad = () => {
688
+ let total = 0;
689
+ let cut = 0;
690
+ for (let i = prePad.length - 1; i >= 0; i--) {
691
+ total += prePad[i].length / SAMPLE_RATE2 * 1e3;
692
+ if (total > PRE_PAD_MS) {
693
+ cut = i + 1;
694
+ break;
695
+ }
696
+ }
697
+ if (cut > 0) prePad = prePad.slice(cut);
698
+ };
699
+ const clearWakeSegment = () => {
700
+ segmentEpoch++;
701
+ segment = [];
702
+ segmentMs = 0;
703
+ speechMs = 0;
704
+ silenceMs = 0;
705
+ wakeSilenceMs = 0;
706
+ wakeConsumed = false;
707
+ prePad = [];
708
+ uploadedSamples = 0;
709
+ void resetHostStream();
710
+ };
674
711
  const finalizeSegment = (force = false) => {
675
712
  if (segment.length === 0) return;
676
713
  if (config.isPlaying?.() && !forcePending && !force) return;
@@ -685,6 +722,8 @@ function createAsrEngine(config, sessionId) {
685
722
  const epochSnapshot = segmentEpoch;
686
723
  segmentEpoch++;
687
724
  const meta = { force: forcePending };
725
+ const consumedWake = wakeConsumed;
726
+ wakeConsumed = false;
688
727
  forcePending = false;
689
728
  speechMs = 0;
690
729
  uploadedSamples = 0;
@@ -769,7 +808,13 @@ function createAsrEngine(config, sessionId) {
769
808
  continue;
770
809
  }
771
810
  if (segmentEpoch !== epochSnapshot + 1) return;
772
- if (out.text) emit(transcriptListeners, out.text, meta);
811
+ const rawText = out.text ?? "";
812
+ const finalText = consumedWake ? stripWakePrefix(rawText, wakeWord) : rawText;
813
+ if (finalText) {
814
+ emit(transcriptListeners, finalText, meta);
815
+ } else if (consumedWake && active && !speechActive && !holdActive) {
816
+ setState("listening");
817
+ }
773
818
  return;
774
819
  }
775
820
  emitError("recognitionFail");
@@ -838,30 +883,27 @@ function createAsrEngine(config, sessionId) {
838
883
  if (segmentMs > MAX_SEGMENT_MS) finalizeSegment();
839
884
  } else if (config.mode === "hold") {
840
885
  } else if (state === "wake") {
841
- if (rms > SPEECH_RMS) {
886
+ if (config.isPlaying?.()) {
887
+ if (segment.length > 0) clearWakeSegment();
888
+ prePad = [];
889
+ } else if (rms > SPEECH_RMS) {
890
+ if (segment.length === 0) {
891
+ for (const p of prePad) segment.push(p);
892
+ }
893
+ prePad = [];
842
894
  segmentMs += durationMs;
895
+ speechMs += durationMs;
843
896
  segment.push(data);
844
897
  wakeSilenceMs = 0;
845
- if (segmentMs > MAX_SEGMENT_MS) {
846
- segmentEpoch++;
847
- segment = [];
848
- segmentMs = 0;
849
- silenceMs = 0;
850
- wakeSilenceMs = 0;
851
- uploadedSamples = 0;
852
- void resetHostStream();
853
- }
898
+ if (segmentMs > MAX_SEGMENT_MS) clearWakeSegment();
854
899
  } else if (segment.length > 0) {
900
+ segmentMs += durationMs;
901
+ segment.push(data);
855
902
  wakeSilenceMs += durationMs;
856
- if (wakeSilenceMs >= config.silenceMs) {
857
- segmentEpoch++;
858
- segment = [];
859
- segmentMs = 0;
860
- silenceMs = 0;
861
- wakeSilenceMs = 0;
862
- uploadedSamples = 0;
863
- void resetHostStream();
864
- }
903
+ if (wakeSilenceMs >= config.silenceMs) clearWakeSegment();
904
+ } else {
905
+ prePad.push(data);
906
+ trimPrePad();
865
907
  }
866
908
  } else if (rms > SPEECH_RMS) {
867
909
  if (config.isPlaying?.()) {
@@ -910,16 +952,7 @@ function createAsrEngine(config, sessionId) {
910
952
  }
911
953
  } else {
912
954
  prePad.push(data);
913
- let total = 0;
914
- let cut = 0;
915
- for (let i = prePad.length - 1; i >= 0; i--) {
916
- total += prePad[i].length / SAMPLE_RATE2 * 1e3;
917
- if (total > PRE_PAD_MS) {
918
- cut = i + 1;
919
- break;
920
- }
921
- }
922
- if (cut > 0) prePad = prePad.slice(cut);
955
+ trimPrePad();
923
956
  }
924
957
  const nowMs = Date.now();
925
958
  if (nowMs - lastPollAt >= PARTIAL_INTERVAL_MS) {
@@ -1013,6 +1046,7 @@ function createAsrEngine(config, sessionId) {
1013
1046
  segmentMs = 0;
1014
1047
  speechMs = 0;
1015
1048
  wakeSilenceMs = 0;
1049
+ wakeConsumed = false;
1016
1050
  uploadedSamples = 0;
1017
1051
  prePad = [];
1018
1052
  detectChunks = [];
@@ -1098,6 +1132,7 @@ function createAsrEngine(config, sessionId) {
1098
1132
  if (!active || holdActive) return;
1099
1133
  holdActive = true;
1100
1134
  segmentEpoch++;
1135
+ wakeConsumed = false;
1101
1136
  utteranceEndAt = null;
1102
1137
  segment = [];
1103
1138
  segmentMs = 0;
@@ -1119,6 +1154,7 @@ function createAsrEngine(config, sessionId) {
1119
1154
  speechMs = 0;
1120
1155
  silenceMs = 0;
1121
1156
  wakeSilenceMs = 0;
1157
+ wakeConsumed = false;
1122
1158
  speechActive = false;
1123
1159
  prePad = [];
1124
1160
  uploadedSamples = 0;
@@ -1444,7 +1480,7 @@ var zh = {
1444
1480
  descMode: "\u4EA4\u4E92\u6A21\u5F0F\uFF08toggle \u6301\u7EED\u8046\u542C+\u9759\u97F3\u65AD\u53E5 / hold \u6309\u4F4F\u8BF4\u8BDD\uFF09",
1445
1481
  modeToggle: "\u6301\u7EED\u8046\u542C",
1446
1482
  modeHold: "\u6309\u4F4F\u8BF4\u8BDD",
1447
- descWakeWord: "\u5524\u9192\u8BCD\uFF08\u9ED8\u8BA4\u5173\uFF1B\u5982\u300C\u4F60\u597D\u5C0FD\u300D\uFF0C\u8BF4\u51FA\u540E\u5F00\u59CB\u8BC6\u522B\uFF1B\u4EC5\u6D41\u5F0F partial \u6587\u672C\u524D\u7F00\u5339\u914D\uFF0C\u975E\u4E13\u7528 KWS \u5F15\u64CE\uFF0C\u5608\u6742\u73AF\u5883\u53EF\u80FD\u5EF6\u8FDF\u6216\u8BEF\u6FC0\u6D3B\uFF09",
1483
+ descWakeWord: "\u5524\u9192\u8BCD\uFF08\u9ED8\u8BA4\u5173\uFF1B\u5982\u300C\u4F60\u597D\u5C0FD\u300D\uFF09\uFF1A\u8BF4\u51FA\u8BCD\u5934\u624D\u8FDB\u5165\u8BC6\u522B\uFF0C\u907F\u514D\u8BEF\u89E6\u3002\u53EF\u4E0E\u547D\u4EE4\u8FDE\u8BF4\uFF08\u300C\u4F60\u597D\u5C0FD\uFF0C\u5E2E\u6211\u67E5\u5929\u6C14\u300D\u2014\u2014\u8BCD\u5934\u4F1A\u81EA\u52A8\u5265\u6389\u3001\u4E0D\u8FDB\u6D88\u606F\uFF09\uFF1B\u6BCF\u53E5\u65AD\u53E5\u6216\u6253\u65AD\u540E\u9700\u91CD\u8BF4\uFF1B\u4EC5 toggle \u6A21\u5F0F\u751F\u6548\uFF08hold / \u624B\u52A8\u6253\u65AD\u4E0B\u4E0D\u751F\u6548\uFF09\uFF1B\u6717\u8BFB\u671F\u8BF4\u5524\u9192\u8BCD\u4E0D\u89E6\u53D1\uFF08\u6253\u65AD\u4ECD\u6309 VAD \u5F00\u53E3\u5373\u6253\u65AD\uFF09\u3002\u5339\u914D\u5E26\u5BB9\u9519\uFF08\u540C\u97F3\u5B57 / \u524D\u5BFC\u8BED\u6C14\u8BCD\uFF09\uFF0C\u5EFA\u8BAE 3-4 \u5B57\uFF1B\u975E\u4E13\u7528 KWS \u5F15\u64CE\uFF0C\u5608\u6742\u73AF\u5883\u53EF\u80FD\u5EF6\u8FDF\u6216\u8BEF\u6FC0\u6D3B",
1448
1484
  wakePlaceholder: "\u5982\uFF1A\u4F60\u597D\u5C0FD",
1449
1485
  settingsCardDesc: "\u6717\u8BFB\u5F15\u64CE / \u97F3\u8272 / \u8BED\u901F / \u6253\u65AD\u7075\u654F\u5EA6 / \u6253\u65AD\u65B9\u5F0F / \u56DE\u58F0\u95E8\u63A7 / \u9759\u97F3\u505C\u987F / \u7A7A\u95F2\u8D85\u65F6 / \u6A21\u578B\u955C\u50CF / \u81EA\u52A8\u53D1\u9001 / \u81EA\u52A8\u6062\u590D / \u4EA4\u4E92\u6A21\u5F0F / \u5524\u9192\u8BCD / \u5DE5\u5177\u63D0\u793A\u97F3 / \u9006\u6587\u672C\u5F52\u4E00\u5316 / \u5B57\u5E55\u5B57\u53F7 / \u5B57\u5E55\u5BBD\u5EA6 / \u77ED\u5E94\u7B54\u8BA9\u4F4D / \u8BA9\u4F4D\u7A97\u53E3",
1450
1486
  settingsEffectiveNote: "\u6717\u8BFB\u5F15\u64CE / \u97F3\u8272 / \u8BED\u901F / \u6A21\u578B\u7CBE\u5EA6 / \u53E3\u8BED\u5316\u63D0\u793A\u8BCD / \u91CD\u8BD1 / \u5B57\u5E55\u5B57\u53F7 / \u5B57\u5E55\u5BBD\u5EA6 / \u77ED\u5E94\u7B54\u8BA9\u4F4D / \u8BA9\u4F4D\u7A97\u53E3 \u5373\u65F6\u751F\u6548\uFF1B\u9006\u6587\u672C\u5F52\u4E00\u5316 \u5373\u65F6\u751F\u6548\uFF08\u4E0B\u6B21\u8FDB\u5165\u8BED\u97F3\u6A21\u5F0F\u91CD\u5EFA\u6D41\u5F0F\u8BC6\u522B\u5668\uFF09\uFF1B\u5176\u4F59\uFF08\u6253\u65AD\u7075\u654F\u5EA6 / \u6253\u65AD\u65B9\u5F0F / \u56DE\u58F0\u95E8\u63A7 / \u5FEB\u6377\u952E / \u9759\u97F3 / \u7A7A\u95F2 / \u955C\u50CF / \u81EA\u52A8\u53D1\u9001 / \u81EA\u52A8\u6062\u590D / \u4EA4\u4E92\u6A21\u5F0F / \u5524\u9192\u8BCD / \u5DE5\u5177\u63D0\u793A\u97F3\uFF09\u4E0B\u6B21\u8FDB\u5165\u8BED\u97F3\u6A21\u5F0F\u65F6\u751F\u6548\u3002",
@@ -1621,7 +1657,7 @@ var en = {
1621
1657
  descMode: "Interaction mode (toggle: continuous listen + auto-send / hold: press to talk)",
1622
1658
  modeToggle: "Continue listen",
1623
1659
  modeHold: "Hold to talk",
1624
- descWakeWord: "Wake word (default off; e.g. Hey D; streamed partial text prefix match only, not a dedicated KWS engine \u2014 noisy environments may delay or falsely trigger)",
1660
+ descWakeWord: 'Wake word (default off; e.g. "Hey D"): recognition starts only after you say it, to avoid accidental triggers. You may say it together with your command ("Hey D, check the weather" \u2014 the wake word is stripped and never sent); it must be repeated after each utterance split or barge-in; toggle mode only (inactive in hold / manual barge-in); saying it while the agent is reading does not trigger (barge-in stays VAD-based). Fault-tolerant matching (homophones / leading fillers); 3-4 characters recommended; not a dedicated KWS engine \u2014 noisy environments may delay or falsely trigger',
1625
1661
  wakePlaceholder: "e.g. Hey D",
1626
1662
  settingsCardDesc: "Engine / voice / rate / interrupt / barge-in / echo gate / silence / idle / model host / auto-send / auto-resume / mode / wake word / tool beep / ITN / caption font / caption width / yielding / yield window",
1627
1663
  settingsEffectiveNote: "Engine / voice / rate / model precision / spoken format / re-transcribe / caption font / caption width / yielding / yield window apply immediately; ITN applies immediately (next time you enter voice mode the streaming recognizer is rebuilt); the rest (interrupt / barge-in / echo gate / shortcut / silence / idle / mirror / auto-send / auto-resume / mode / wake word / tool beep) apply next time you enter voice mode.",
@@ -2787,7 +2823,7 @@ var TELEMETRY_VIEW = [
2787
2823
  { stage: "first-tts-chunk", key: "telFirstChunk" },
2788
2824
  { stage: "first-audio-played", key: "telFirstPlayed" }
2789
2825
  ];
2790
- var BUILD_TAG = "46e0988";
2826
+ var BUILD_TAG = "3a98e88";
2791
2827
  var TELEMETRY_FLAG = "dsh-voice-mode.telemetry";
2792
2828
  var telemetryEnabled = typeof localStorage !== "undefined" && localStorage.getItem(TELEMETRY_FLAG) === "1";
2793
2829
  console.log("[dsh-voice] build=" + BUILD_TAG);
package/lib/index.js CHANGED
@@ -1974,7 +1974,7 @@ function createVoiceSettingsSchema(defs) {
1974
1974
  shortcut: z.string().default(d.shortcut).description("\u8FDB\u5165/\u9000\u51FA\u8BED\u97F3\u6A21\u5F0F\u7684\u5FEB\u6377\u952E\uFF08\u5F62\u5982 Ctrl+Shift+V\uFF0C\u4FEE\u9970\u952E Ctrl/Shift/Alt/Meta + \u4E00\u4E2A\u5B57\u6BCD\u952E\uFF1B\u7559\u7A7A\u7981\u7528\u5FEB\u6377\u952E\uFF0C\u7528\u9EA6\u514B\u98CE\u6309\u94AE\uFF09"),
1975
1975
  spokenFormat: z.boolean().default(d.spokenFormat).description("\u8BED\u97F3\u4F1A\u8BDD\u6CE8\u5165\u53E3\u8BED\u5316\u63D0\u793A\u8BCD\uFF08\u53E3\u8BED\u5316\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"),
1976
1976
  senseVoice: z.boolean().default(d.senseVoice).description("\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"),
1977
- wakeWord: z.string().default(d.wakeWord).description("\u5524\u9192\u8BCD\uFF1A\u5728\u5F85\u673A\u6001\u8BF4\u51FA\u540E\u5F00\u59CB\u8BC6\u522B\uFF08\u9ED8\u8BA4\u5173\uFF1B\u5982\u300C\u4F60\u597D\u5C0FD\u300D\uFF1B\u4EC5\u6D41\u5F0F partial \u6587\u672C\u524D\u7F00\u5339\u914D\uFF0C\u975E\u4E13\u7528 KWS \u5F15\u64CE\uFF0C\u5608\u6742\u73AF\u5883\u53EF\u80FD\u5EF6\u8FDF\u6216\u8BEF\u6FC0\u6D3B\uFF09"),
1977
+ wakeWord: z.string().default(d.wakeWord).description("\u5524\u9192\u8BCD\uFF1A\u5728\u5F85\u673A\u6001\u8BF4\u51FA\u540E\u5F00\u59CB\u8BC6\u522B\uFF08\u9ED8\u8BA4\u5173\uFF1B\u5982\u300C\u4F60\u597D\u5C0FD\u300D\uFF09\u3002\u53EF\u4E0E\u547D\u4EE4\u8FDE\u8BF4\u2014\u2014\u8BCD\u5934\u81EA\u52A8\u5265\u6389\u4E0D\u8FDB\u6D88\u606F\uFF1B\u6BCF\u53E5\u65AD\u53E5/\u6253\u65AD\u540E\u9700\u91CD\u8BF4\uFF1B\u4EC5 toggle \u6A21\u5F0F\u751F\u6548\uFF08hold/\u624B\u52A8\u6253\u65AD\u4E0B\u4E0D\u751F\u6548\uFF09\uFF1B\u6717\u8BFB\u671F\u8BF4\u5524\u9192\u8BCD\u4E0D\u89E6\u53D1\uFF08\u6253\u65AD\u6309 VAD\uFF09\uFF1B\u5339\u914D\u5E26\u5BB9\u9519\uFF08\u540C\u97F3\u5B57/\u524D\u5BFC\u8BED\u6C14\u8BCD\uFF09\uFF0C\u5EFA\u8BAE 3-4 \u5B57\uFF1B\u975E\u4E13\u7528 KWS \u5F15\u64CE\uFF0C\u5608\u6742\u73AF\u5883\u53EF\u80FD\u5EF6\u8FDF\u6216\u8BEF\u6FC0\u6D3B"),
1978
1978
  toolBeep: z.boolean().default(d.toolBeep).description('\u5DE5\u5177\u8C03\u7528\u63D0\u793A\u97F3\uFF08\u9ED8\u8BA4\u5173\uFF09\uFF1A\u5F00\u542F\u540E AI \u8C03\u7528\u5DE5\u5177\u65F6"\u6EF4"\u4E00\u58F0\uFF0C\u5173\u95ED\u5219\u5168\u7A0B\u9759\u9ED8'),
1979
1979
  senseITN: z.boolean().default(d.senseITN).description("SenseVoice \u9006\u6587\u672C\u5F52\u4E00\u5316\uFF08\u6570\u5B57/\u65E5\u671F\u89C4\u8303\u5316\uFF0C\u9ED8\u8BA4\u5F00\uFF1B\u5173\u95ED\u540E\u8F93\u51FA\u66F4\u63A5\u8FD1\u53E3\u8BED\u539F\u6587\uFF09"),
1980
1980
  captionFontSize: z.union([z.const(0), z.const(1), z.const(2), z.const(3)]).default(d.captionFontSize).description(
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 all dsh releases since 0.1.1-rc.2 (verified 0.1.1 → 0.1.5-rc.2). · DSH 语音双工插件:流式识别入草稿、按句朗读+实时字幕、开口即打断;免 API Key、安全加固、全版本兼容(0.1.1-rc.2 起,含 0.1.5-rc.2 端到端验证)。",
4
- "version": "0.7.8",
4
+ "version": "0.7.9",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "repository": {
@@ -42,7 +42,7 @@
42
42
  "scripts": {
43
43
  "build": "node build.mjs",
44
44
  "prepack": "node build.mjs",
45
- "test": "node test/segmenter.test.mjs && node test/aec.test.mjs && node test/download.test.mjs && node test/endpoint.test.mjs && node test/endpoint-short.test.mjs && node test/resample.test.mjs && node test/sense-worker.test.mjs && node test/detect-route.test.mjs && node test/verify-client.mjs && node test/asr-host-rebuild.test.mjs && node test/barge-in-manual.test.mjs && node test/sense-lang.test.mjs && node test/caption-a11y.test.mjs && node test/emotion.test.mjs && node test/emotion-tts-local.test.mjs && node test/emotion-integration.test.mjs && node test/backchannel.test.mjs && node test/strings-coverage.test.mjs && node test/yield-ms-wiring.test.mjs && node test/matchBackchannel.test.mjs && node test/settings-load.test.mjs && node test/preview-error.test.mjs && node test/hold-clear.test.mjs && node test/barge-in-detect.test.mjs && node test/wakeword.test.mjs && node test/wake-standby.test.mjs",
45
+ "test": "node test/segmenter.test.mjs && node test/aec.test.mjs && node test/download.test.mjs && node test/endpoint.test.mjs && node test/endpoint-short.test.mjs && node test/resample.test.mjs && node test/sense-worker.test.mjs && node test/detect-route.test.mjs && node test/verify-client.mjs && node test/asr-host-rebuild.test.mjs && node test/barge-in-manual.test.mjs && node test/sense-lang.test.mjs && node test/caption-a11y.test.mjs && node test/emotion.test.mjs && node test/emotion-tts-local.test.mjs && node test/emotion-integration.test.mjs && node test/backchannel.test.mjs && node test/strings-coverage.test.mjs && node test/yield-ms-wiring.test.mjs && node test/matchBackchannel.test.mjs && node test/settings-load.test.mjs && node test/preview-error.test.mjs && node test/hold-clear.test.mjs && node test/barge-in-detect.test.mjs && node test/wakeword.test.mjs && node test/wake-standby.test.mjs && node test/wake-flow.test.mjs",
46
46
  "verify:client": "node test/verify-client.mjs",
47
47
  "prefetch": "node scripts/prefetch.mjs",
48
48
  "list-voices": "node scripts/list-voices.mjs",