dsh-voice-mode 0.5.0 → 0.6.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.en.md CHANGED
@@ -11,11 +11,11 @@
11
11
  >
12
12
  > 中文说明见 [README.md](./README.md)。
13
13
 
14
- > **Version note (0.5.0)**: Edge cloud TTS by default (fast & natural); local TTS (VITS / Kokoro int8, privacy-first) optional + HTTP hardening + model SHA256 pinning form the merged core; `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 700 ms.
15
15
 
16
16
  ## Fork enhancements (this repo)
17
17
 
18
- - **Edge cloud TTS by default; local TTS optional (privacy-first)**: local VITS (Chinese) and local Kokoro int8 (zh+en, native `sherpa-onnx-node` addon, no WASM memory limits) run in an isolated child process;
18
+ - **Edge cloud TTS by default; local TTS optional (privacy-first)**: local VITS (Chinese) and local Kokoro (zh+en, 103 voices; `int8` default ~109 MB / `fp32` ~311 MB for better quality; native `sherpa-onnx-node` addon, no WASM memory limits) run in an isolated child process;
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;
@@ -34,7 +34,7 @@
34
34
  - `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
35
35
  - `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
36
36
  - **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
37
- - **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 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
37
+ - **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
38
38
  - **Barge-in**: three sensitivity levels of voice-onset detection → local mute + host synth queue invalidation (epoch) + running turn cancellation (the half-finished part is kept and naturally flows into your new message)
39
39
  - **Lazy model download with progress**: the zipformer2 Chinese streaming model (~160 MB, `.part` resumable) is downloaded on first use with live progress in the status bar; `npm run prefetch` can pre-download it
40
40
  - **Resilience**: mic-denied red hint, visible model-download failure, TTS unreachable status hint (auto retry), failed submit keeps the text in the draft, SSE auto-reconnect
@@ -100,7 +100,8 @@ If a wake word is configured, you land in standby first (the status bar prompts
100
100
 
101
101
  | Key | Default | Description |
102
102
  | --- | --- | --- |
103
- | `ttsEngine` | `edge` | Read-aloud engine: `edge` Microsoft cloud (default, fast) / `vits` local Chinese / `kokoro` local zh+en (int8); **applies live** |
103
+ | `ttsEngine` | `edge` | Read-aloud engine: `edge` Microsoft cloud (default, fast) / `vits` local Chinese / `kokoro` local zh+en; **applies live** |
104
+ | `kokoroModel` | `int8` | Kokoro precision: `int8` (default, 109 MB, CPU/low-bandwidth) / `fp32` (311 MB, better quality, GPU/large memory); same 103 voices; **applies live** |
104
105
  | `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 |
105
106
  | `rate` | `1.0` | Reading speed multiplier (0.5 slow ~ 2.0 fast), **applies live** |
106
107
  | `interruptLevel` | `0` | Barge-in sensitivity (host-side VAD frame detection + echo gate): 0 high threshold / 1 medium / 2 low |
@@ -111,7 +112,7 @@ If a wake word is configured, you land in standby first (the status bar prompts
111
112
  | `mode` | `toggle` | Interaction mode: `toggle` continuous listening + 700 ms silence split; `hold` push-to-talk, release to send (short tap exits) |
112
113
  | `wakeWord` | empty (off) | Wake word (e.g. `你好小D`): speak it after entering to activate, avoiding accidental triggers; empty = off |
113
114
 
114
- Effect timing: `voice`/`rate` 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.
115
+ 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.
115
116
 
116
117
  ### Common voices (full list: `node scripts/list-voices.mjs`)
117
118
 
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.4.0)**:朗读默认 **Edge 云端**(快速自然),本地 TTS(VITS/Kokoro)可选(隐私优先)+ HTTP 安全加固 + 模型 SHA256 固定为合入核心;`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。静音断句默认 700 毫秒。
17
17
 
18
18
  ## Fork 增强(本仓库新增)
19
19
 
@@ -21,7 +21,7 @@ DeepSeek Harness 语音双工对话模式:会话内一键进入 → 边说边
21
21
 
22
22
  - **朗读默认 Edge 云端;本地 TTS 可选(隐私优先)**:选本地则回复文本不出本机——
23
23
  - 本地 VITS(`sherpa-onnx-vits-zh-ll`,纯中文,5 说话人);
24
- - 本地 Kokoro(`kokoro-int8-multi-lang-v1_1`,**中英混读**,int8 109MB),经 `sherpa-onnx-node` **原生 addon** 运行(无 WASM 内存上限,连续合成不崩);
24
+ - 本地 Kokoro(**中英混读**,103 音色;`int8` 默认约 109MB / 可选 `fp32` 约 311MB 音质更好),经 `sherpa-onnx-node` **原生 addon** 运行(无 WASM 内存上限,连续合成不崩);
25
25
  - Edge 云端朗读保留为可选(设置 `ttsEngine: edge`);设置面板「朗读引擎」热切换。
26
26
  - **Kokoro 音色全量 103 个**(F0 实测标定性别),四个常用男声置顶带编号;音色面板用 **下拉列表 + ◀▶ 步进**切换;
27
27
  - **增量传输**:partial 只传新增 0.9 秒,长段按住说话松手**秒出定稿**(不再整段重传重解码);
@@ -73,6 +73,7 @@ bundle 插件安装后需重启 dsh 生效(Linux:`systemctl restart dsh`;
73
73
  | 键 | 默认 | 说明 |
74
74
  | --- | --- | --- |
75
75
  | `ttsEngine` | `edge` | 朗读引擎:`edge` 微软云端(默认,快)/ `vits` 本地中文 / `kokoro` 本地中英;**即时生效** |
76
+ | `kokoroModel` | `int8` | Kokoro 模型精度:`int8`(默认,109MB,纯 CPU/低带宽推荐)/ `fp32`(311MB,音质更好,独显/大内存推荐);两档共用 103 音色,**即时生效** |
76
77
  | `voice` | 按引擎 | 音色:VITS 五说话人;Kokoro 103 个(下拉+◀▶,62 深沉/68 浑厚/75 清亮/76 磁性置顶);Edge 进入时自动加载全量 322 个。行内「试听」可即时预览 |
77
78
  | `rate` | `1.0` | 朗读语速倍率(0.5 慢速 ~ 2.0 快速),**即时生效** |
78
79
  | `interruptLevel` | `0` | 发声打断灵敏度(服务端 VAD 帧级检测 + 回声门控):0 高门槛 / 1 中 / 2 低 |
@@ -82,9 +83,9 @@ bundle 插件安装后需重启 dsh 生效(Linux:`systemctl restart dsh`;
82
83
  | `autoSend` | `true` | 识别定稿后自动发送;关闭则只进草稿(按住 `Ctrl` / hold 松手仍会发送) |
83
84
  | `mode` | `toggle` | 交互模式:`toggle` 持续聆听 + 700ms 静音断句;`hold` 按住说话、松手发送(短按退出) |
84
85
  | `wakeWord` | 空(关) | 唤醒词(如「你好小D」):进入后先说唤醒词激活,避免误触;空 = 关闭 |
85
- | `spokenFormat` | `false` | 语音会话注入口语化提示词:开启后**仅当前语音会话**的回复被注入「口语化短句、不用 Markdown 排版符号」提示词(朗读更顺),**即时生效** |
86
+ | `spokenFormat` | `true` | 语音会话注入口语化提示词:开启后**仅当前语音会话**的回复被注入「口语化短句、不用 Markdown 排版符号」提示词(朗读更顺),**即时生效** |
86
87
 
87
- 生效范围:`voice`/`rate`/`ttsEngine`/`spokenFormat` **立即生效**;其余设置下次进入语音模式时生效。设置项默认值由插件配置(`base` 层)提供。
88
+ 生效范围:`voice`/`rate`/`ttsEngine`/`kokoroModel`/`spokenFormat` **立即生效**;其余设置下次进入语音模式时生效。设置项默认值由插件配置(`base` 层)提供。
88
89
 
89
90
  ### 本地音色(VITS / Kokoro)
90
91
 
package/lib/client.js CHANGED
@@ -984,7 +984,7 @@ var zh = {
984
984
  modeBtnToggle: "\u6301\u7EED\u8046\u542C",
985
985
  modeBtnHold: "\u6309\u4F4F\u8BF4",
986
986
  modeBtnTitle: "\u70B9\u51FB\u5207\u6362\u4EA4\u4E92\u6A21\u5F0F\uFF08\u4FDD\u5B58\u5230\u8BBE\u7F6E\uFF09",
987
- descVoice: "Edge \u4E91\u7AEF\u97F3\u8272\uFF08\u8FDB\u5165\u65F6\u81EA\u52A8\u52A0\u8F7D\u5FAE\u8F6F\u5168\u90E8\u97F3\u8272\uFF0C\u4E0B\u62C9\u6216 \u25C0\u25B6 \u9009\uFF1B\u7F51\u7EDC\u5F02\u5E38\u56DE\u9000\u5E38\u7528 14 \u4E2A\uFF0C\u4E5F\u53EF\u300C\u81EA\u5B9A\u4E49\u300D\u586B ShortName\uFF09",
987
+ descVoice: "Edge \u4E91\u7AEF\u97F3\u8272\uFF08\u8FDB\u5165\u65F6\u81EA\u52A8\u52A0\u8F7D\u5FAE\u8F6F\u5168\u90E8\u97F3\u8272\uFF0C\u5E38\u7528\u4E2D\u6587\u97F3\u8272\u7F6E\u9876\uFF0C\u4E0B\u62C9\u6216 \u25C0\u25B6 \u9009\uFF1B\u4E5F\u53EF\u300C\u81EA\u5B9A\u4E49\u300D\u586B ShortName\uFF09",
988
988
  descVoiceLocal: "\u672C\u5730\u97F3\u8272\uFF08vits \u4E94\u4E2A\u8BF4\u8BDD\u4EBA\u5168\u90E8\u5217\u51FA\uFF0C\u4E0B\u62C9\u9009\u6216 \u25C0\u25B6 \u5207\u6362\uFF1B\u65E0\u9700\u81EA\u5B9A\u4E49\uFF09",
989
989
  ttsEngine: "\u6717\u8BFB\u5F15\u64CE",
990
990
  descTtsEngine: "\u672C\u5730 VITS\uFF08\u7EAF\u4E2D\u6587\uFF09/ \u672C\u5730 Kokoro\uFF08\u4E2D\u82F1\u5747\u53EF\uFF09/ Edge \u4E91\u7AEF\uFF08\u97F3\u8D28\u6700\u81EA\u7136\uFF0C\u6587\u672C\u4E0A\u5FAE\u8F6F\uFF09",
@@ -1020,7 +1020,7 @@ var zh = {
1020
1020
  descWakeWord: "\u5524\u9192\u8BCD\uFF08\u9ED8\u8BA4\u5173\uFF1B\u5982\u300C\u4F60\u597D\u5C0FD\u300D\uFF0C\u8BF4\u51FA\u540E\u5F00\u59CB\u8BC6\u522B\uFF09",
1021
1021
  wakePlaceholder: "\u5982\uFF1A\u4F60\u597D\u5C0FD",
1022
1022
  settingsCardDesc: "\u6717\u8BFB\u5F15\u64CE / \u97F3\u8272 / \u8BED\u901F / \u6253\u65AD\u7075\u654F\u5EA6 / \u6253\u65AD\u65B9\u5F0F / \u9759\u97F3\u505C\u987F / \u7A7A\u95F2\u8D85\u65F6 / \u6A21\u578B\u955C\u50CF / \u81EA\u52A8\u53D1\u9001 / \u4EA4\u4E92\u6A21\u5F0F / \u5524\u9192\u8BCD / \u53E3\u8BED\u5316\u63D0\u793A\u8BCD",
1023
- settingsEffectiveNote: "\u6717\u8BFB\u5F15\u64CE / \u97F3\u8272 / \u8BED\u901F / \u53E3\u8BED\u5316\u63D0\u793A\u8BCD / \u91CD\u8BD1 \u5373\u65F6\u751F\u6548\uFF1B\u5176\u4F59\uFF08\u6253\u65AD\u7075\u654F\u5EA6 / \u6253\u65AD\u65B9\u5F0F / \u56DE\u58F0\u95E8\u63A7 / \u5FEB\u6377\u952E / \u9759\u97F3 / \u7A7A\u95F2 / \u955C\u50CF / \u81EA\u52A8\u53D1\u9001 / \u81EA\u52A8\u6062\u590D / \u4EA4\u4E92\u6A21\u5F0F / \u5524\u9192\u8BCD / \u5DE5\u5177\u63D0\u793A\u97F3\uFF09\u4E0B\u6B21\u8FDB\u5165\u8BED\u97F3\u6A21\u5F0F\u65F6\u751F\u6548\u3002",
1023
+ settingsEffectiveNote: "\u6717\u8BFB\u5F15\u64CE / \u97F3\u8272 / \u8BED\u901F / \u6A21\u578B\u7CBE\u5EA6 / \u53E3\u8BED\u5316\u63D0\u793A\u8BCD / \u91CD\u8BD1 \u5373\u65F6\u751F\u6548\uFF1B\u5176\u4F59\uFF08\u6253\u65AD\u7075\u654F\u5EA6 / \u6253\u65AD\u65B9\u5F0F / \u56DE\u58F0\u95E8\u63A7 / \u5FEB\u6377\u952E / \u9759\u97F3 / \u7A7A\u95F2 / \u955C\u50CF / \u81EA\u52A8\u53D1\u9001 / \u81EA\u52A8\u6062\u590D / \u4EA4\u4E92\u6A21\u5F0F / \u5524\u9192\u8BCD / \u5DE5\u5177\u63D0\u793A\u97F3\uFF09\u4E0B\u6B21\u8FDB\u5165\u8BED\u97F3\u6A21\u5F0F\u65F6\u751F\u6548\u3002",
1024
1024
  configUnavailable: "\u914D\u7F6E\u6682\u4E0D\u53EF\u7528",
1025
1025
  // telemetry(P1-5 开发模式延迟埋点状态条:各段耗时标签)
1026
1026
  telUtteranceEnd: "\u8BF4\u5B8C",
@@ -1053,6 +1053,16 @@ var zh = {
1053
1053
  ttsRedownload: "\u91CD\u65B0\u4E0B\u8F7D",
1054
1054
  ttsRedownloadHint: "\u6E05\u7406\u8BE5\u5F15\u64CE\u6A21\u578B\u7F13\u5B58\u5E76\u91CD\u65B0\u4E0B\u8F7D",
1055
1055
  ttsCleaning: "\u6E05\u7406\u4E2D\u2026",
1056
+ ttsDownload: "\u4E0B\u8F7D",
1057
+ ttsDelete: "\u5220\u9664",
1058
+ ttsDownloading: "\u4E0B\u8F7D\u4E2D\u2026",
1059
+ ttsDeleting: "\u5220\u9664\u4E2D\u2026",
1060
+ ttsDownloadHint: "\u4E0B\u8F7D\u8BE5\u5F15\u64CE\u7684\u672C\u5730\u6A21\u578B\uFF1B\u4E0B\u8F7D\u5B8C\u6210\u540E\u7ACB\u5373\u5C31\u7EEA\uFF0C\u53EF\u8BD5\u542C/\u6717\u8BFB",
1061
+ ttsDeleteHint: "\u5220\u9664\u672C\u5730\u6A21\u578B\uFF08\u91CA\u653E\u7A7A\u95F4\uFF1B\u4E0B\u6B21\u4F7F\u7528\u4F1A\u81EA\u52A8\u91CD\u65B0\u4E0B\u8F7D\uFF09",
1062
+ kokoroModel: "Kokoro \u6A21\u578B\u7CBE\u5EA6",
1063
+ kokoroModelInt8: "int8\uFF08\u9ED8\u8BA4\uFF09",
1064
+ kokoroModelFp32: "fp32\uFF08\u97F3\u8D28\u66F4\u597D\uFF09",
1065
+ descKokoroModel: "Kokoro \u6A21\u578B\u7CBE\u5EA6\uFF1Aint8 \u4F53\u79EF\u5C0F/\u52A0\u8F7D\u5FEB\uFF08\u7EAF CPU \u670D\u52A1\u5668\u6216\u4F4E\u5E26\u5BBD\u63A8\u8350\uFF0C\u9ED8\u8BA4\uFF09\uFF1Bfp32 \u97F3\u8D28\u66F4\u597D\u4F46\u7EA6 311MB\u3001\u66F4\u6162\uFF08\u6709\u72EC\u7ACB\u663E\u5361\u6216\u5927\u5185\u5B58\u673A\u5668\u63A8\u8350\uFF09\u3002\u4E24\u6863\u5171\u7528\u540C\u4E00\u5957 103 \u97F3\u8272\uFF0C\u5207\u6362\u5373\u65F6\u751F\u6548\u3002",
1056
1066
  // 设置分组标题
1057
1067
  secRead: "\u6717\u8BFB\u4E0E\u97F3\u8272",
1058
1068
  secInterrupt: "\u6253\u65AD\u4E0E\u9759\u97F3",
@@ -1115,7 +1125,7 @@ var en = {
1115
1125
  modeBtnToggle: "Continuous",
1116
1126
  modeBtnHold: "Hold to talk",
1117
1127
  modeBtnTitle: "Click to switch interaction mode (saved to settings)",
1118
- descVoice: "Edge cloud voices (auto-loads all Microsoft voices on entry; \u25C0\u25B6 or dropdown; falls back to 14 common on network failure; custom ShortName allowed)",
1128
+ descVoice: "Edge cloud voices (auto-loads all Microsoft voices; common Chinese voices pinned on top; \u25C0\u25B6 or dropdown; custom ShortName allowed)",
1119
1129
  descVoiceLocal: "Local voice (vits, all 5 speakers listed; dropdown or \u25C0\u25B6; no custom needed)",
1120
1130
  ttsEngine: "Read-aloud engine",
1121
1131
  descTtsEngine: "Local VITS (Chinese only) / Local Kokoro (Chinese + English) / Edge cloud (most natural, text sent to Microsoft)",
@@ -1151,7 +1161,7 @@ var en = {
1151
1161
  descWakeWord: "Wake word (default off; e.g. Hey D)",
1152
1162
  wakePlaceholder: "e.g. Hey D",
1153
1163
  settingsCardDesc: "Engine / voice / rate / interrupt / barge-in / silence / idle / model host / auto-send / mode / wake word / spoken format",
1154
- settingsEffectiveNote: "Engine / voice / rate / spoken format / re-transcribe apply immediately; the rest (interrupt / barge-in / echo gate / shortcut / silence / idle / mirror / auto-send / auto-resume / mode / wake word / tool beep) apply next time you enter voice mode.",
1164
+ settingsEffectiveNote: "Engine / voice / rate / model precision / spoken format / re-transcribe apply immediately; the rest (interrupt / barge-in / echo gate / shortcut / silence / idle / mirror / auto-send / auto-resume / mode / wake word / tool beep) apply next time you enter voice mode.",
1155
1165
  configUnavailable: "Configuration unavailable",
1156
1166
  telUtteranceEnd: "end",
1157
1167
  telEndpoint: "endpoint",
@@ -1181,6 +1191,16 @@ var en = {
1181
1191
  ttsRedownload: "Re-download",
1182
1192
  ttsRedownloadHint: "Clear this engine model cache and re-download",
1183
1193
  ttsCleaning: "Clearing\u2026",
1194
+ ttsDownload: "Download",
1195
+ ttsDelete: "Delete",
1196
+ ttsDownloading: "Downloading\u2026",
1197
+ ttsDeleting: "Deleting\u2026",
1198
+ ttsDownloadHint: "Download this engine's local model; becomes ready immediately after",
1199
+ ttsDeleteHint: "Delete local models (frees space; auto re-downloads on next use)",
1200
+ kokoroModel: "Kokoro model precision",
1201
+ kokoroModelInt8: "int8 (default)",
1202
+ kokoroModelFp32: "fp32 (better quality)",
1203
+ descKokoroModel: "Kokoro model precision: int8 is smaller/faster (CPU server or low bandwidth; default); fp32 sounds better but ~311MB and slower (GPU or large memory). Both share the same 103 voices; switches live.",
1184
1204
  secRead: "Reading & voice",
1185
1205
  secInterrupt: "Interrupt & silence",
1186
1206
  secInteraction: "Interaction",
@@ -1213,6 +1233,7 @@ var cardStyle = {
1213
1233
  };
1214
1234
  var FIELD_LABELS = {
1215
1235
  ttsEngine: "\u6717\u8BFB\u5F15\u64CE",
1236
+ kokoroModel: "Kokoro \u6A21\u578B\u7CBE\u5EA6",
1216
1237
  voice: "\u97F3\u8272",
1217
1238
  rate: "\u8BED\u901F",
1218
1239
  interruptLevel: "\u6253\u65AD\u7075\u654F\u5EA6",
@@ -1445,8 +1466,10 @@ var VOICE_OPTIONS_KOKORO = [
1445
1466
  ...KOKORO_F0.map((_, sid) => kokoroOption(sid)).filter((o) => !KOKORO_PINNED.includes(Number(o.v)))
1446
1467
  ];
1447
1468
  var ENGINE_DEFAULT_VOICE = {
1448
- vits: "fushiyu",
1449
- kokoro: "zf_xiaoni",
1469
+ // 与 host 侧引擎 defaultVoice 对齐(VITS suyingxue / Kokoro zf_xiaobei),
1470
+ // 避免「config 直连」与「面板切引擎」落到不同默认音色。
1471
+ vits: "suyingxue",
1472
+ kokoro: "zf_xiaobei",
1450
1473
  edge: "zh-CN-XiaoxiaoNeural"
1451
1474
  };
1452
1475
  var HOST_OPTIONS = [
@@ -1619,6 +1642,10 @@ function VoiceSelect({
1619
1642
  };
1620
1643
  const selectStyle = {
1621
1644
  ...inputStyle,
1645
+ // 下拉主控件占满剩余宽度(覆盖 inputStyle 固定 280,避免与 ‹› 并排时被压窄导致长名截断)。
1646
+ width: "auto",
1647
+ minWidth: 0,
1648
+ flex: "1 1 auto",
1622
1649
  appearance: "none",
1623
1650
  cursor: "pointer",
1624
1651
  backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")`,
@@ -1629,7 +1656,7 @@ function VoiceSelect({
1629
1656
  };
1630
1657
  const label = inOptions ? options[idx].label : custom || placeholder || "";
1631
1658
  const selectValue = inOptions ? cur : showCustom ? "__custom__" : options[0]?.v ?? "";
1632
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: { display: "flex", flexDirection: "column", gap: 6, width: 320, maxWidth: "100%", alignItems: "stretch" }, children: [
1659
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: { display: "flex", flexDirection: "column", gap: 6, width: "100%", maxWidth: "100%", alignItems: "stretch" }, children: [
1633
1660
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: { display: "flex", gap: 6, alignItems: "center" }, children: [
1634
1661
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", "aria-label": t("voicePrev"), onClick: () => move(-1), style: stepBtn, children: "\u2039" }),
1635
1662
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
@@ -1805,7 +1832,7 @@ function SegGroup({
1805
1832
  var fmtMB = (b) => b >= 1048576 ? `${(b / 1048576).toFixed(0)}MB` : b > 0 ? `${Math.round(b / 1024)}KB` : "\u2013";
1806
1833
  function EngineStatusInline() {
1807
1834
  const [st, setSt] = (0, import_react.useState)(null);
1808
- const [cleaning, setCleaning] = (0, import_react.useState)(false);
1835
+ const [acting, setActing] = (0, import_react.useState)(null);
1809
1836
  (0, import_react.useEffect)(() => {
1810
1837
  let alive = true;
1811
1838
  const poll = async () => {
@@ -1822,51 +1849,50 @@ function EngineStatusInline() {
1822
1849
  clearInterval(timer);
1823
1850
  };
1824
1851
  }, []);
1825
- const clean = (engine) => {
1826
- setCleaning(true);
1827
- void fetch(location.origin + BASE_PATH + "/models/clean", {
1828
- method: "POST",
1829
- headers: { "content-type": "application/json" },
1830
- body: JSON.stringify({ engine })
1831
- }).catch(() => void 0).finally(() => setTimeout(() => setCleaning(false), 1500));
1832
- };
1833
1852
  const tts = st?.tts;
1834
1853
  if (!tts) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_jsx_runtime.Fragment, {});
1835
1854
  const engineName = tts.engine === "vits" ? t("engineVits") : tts.engine === "kokoro" ? t("engineKokoro") : t("engineEdge");
1836
1855
  const isLocal = !!tts.local;
1856
+ const localReady = isLocal ? !!tts.local?.ready : false;
1837
1857
  let statusText;
1838
1858
  let statusColor;
1839
1859
  if (tts.loading) {
1840
1860
  statusText = t("engineLoading");
1841
1861
  statusColor = t2.term;
1842
- } else if (tts.ready) {
1843
- statusText = t("engineReady");
1844
- statusColor = "var(--dsw-alias-state-success-primary)";
1845
1862
  } else if (tts.error) {
1846
1863
  statusText = t("engineError");
1847
1864
  statusColor = "var(--dsw-alias-state-error-primary)";
1848
- } else if (isLocal && !tts.local.ready) {
1865
+ } else if (isLocal && !localReady) {
1849
1866
  statusText = t("ttsModelsMissing");
1850
1867
  statusColor = "var(--dsw-alias-state-error-primary)";
1851
1868
  } else {
1852
- statusText = t("engineIdle");
1853
- statusColor = t2.term;
1869
+ statusText = t("engineReady");
1870
+ statusColor = "var(--dsw-alias-state-success-primary)";
1854
1871
  }
1872
+ const act = (kind) => {
1873
+ setActing(kind);
1874
+ void fetch(location.origin + BASE_PATH + (kind === "clean" ? "/models/clean" : "/models/download"), {
1875
+ method: "POST",
1876
+ headers: { "content-type": "application/json" },
1877
+ body: JSON.stringify({ engine: tts.engine })
1878
+ }).catch(() => void 0).finally(() => setTimeout(() => setActing(null), 1500));
1879
+ };
1880
+ const action = localReady ? "clean" : "download";
1855
1881
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "flex", alignItems: "center", flexWrap: "wrap", gap: 8, padding: "2px 0 10px" }, children: [
1856
1882
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { fontSize: 12, color: t2.term }, children: engineName }),
1857
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { fontSize: 12, fontWeight: tts.ready || tts.error ? 600 : 400, color: statusColor }, children: statusText }),
1858
- tts.loading && st?.progress?.file && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: { fontSize: 12, color: t2.term }, children: [
1859
- st.progress.file,
1883
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { fontSize: 12, fontWeight: statusText === t("engineReady") || statusText === t("engineError") ? 600 : 400, color: statusColor }, children: statusText }),
1884
+ tts.loading && tts.progress?.file && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: { fontSize: 12, color: t2.term }, children: [
1885
+ tts.progress.file,
1860
1886
  " ",
1861
- st.progress.percent,
1887
+ tts.progress.percent,
1862
1888
  "%"
1863
1889
  ] }),
1864
1890
  isLocal && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1865
1891
  "button",
1866
1892
  {
1867
1893
  type: "button",
1868
- onClick: () => clean(tts.engine),
1869
- disabled: cleaning || tts.loading,
1894
+ onClick: () => act(action),
1895
+ disabled: !!acting || tts.loading,
1870
1896
  style: {
1871
1897
  font: "inherit",
1872
1898
  fontSize: 12,
@@ -1878,8 +1904,8 @@ function EngineStatusInline() {
1878
1904
  padding: "3px 10px",
1879
1905
  flexShrink: 0
1880
1906
  },
1881
- title: t("ttsRedownloadHint"),
1882
- children: cleaning ? t("ttsCleaning") : t("ttsRedownload")
1907
+ title: action === "clean" ? t("ttsDeleteHint") : t("ttsDownloadHint"),
1908
+ children: acting ? acting === "clean" ? t("ttsDeleting") : t("ttsDownloading") : action === "clean" ? t("ttsDelete") : t("ttsDownload")
1883
1909
  }
1884
1910
  ),
1885
1911
  tts.error && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { fontSize: 11, color: "var(--dsw-alias-state-error-primary)", flexBasis: "100%" }, children: tts.error })
@@ -1996,17 +2022,16 @@ function VoiceSettingsCard({ scope }) {
1996
2022
  let alive = true;
1997
2023
  void fetch(location.origin + BASE_PATH + "/voices").then((res) => res.ok ? res.json() : null).then((data) => {
1998
2024
  if (!alive || !data?.voices) return;
1999
- const localeName = (lk) => {
2000
- const m = /^([a-z]{2,3})(?:-[A-Z]{2})?/.exec(lk);
2001
- return m ? m[1].toUpperCase() : lk;
2002
- };
2003
2025
  const genderName = (g) => g === "Female" ? "\u5973" : g === "Male" ? "\u7537" : g === "Neutral" ? "\u4E2D\u6027" : g;
2004
- setEdgeVoices(
2005
- data.voices.map((v) => ({
2006
- v: v.ShortName,
2007
- label: v.FriendlyName.replace(/ Microsoft.*/, "") + " \xB7 " + genderName(v.Gender) + " \xB7 " + localeName(v.Locale) + " (" + v.ShortName + ")"
2008
- })).sort((a, b) => a.label.localeCompare(b.label))
2026
+ const voiceName = (fn) => fn.replace(/^Microsoft\s+/, "").replace(/\s+Online\s+\(Natural\)/, "").split(/\s*-\s*/)[0].trim();
2027
+ const mkLabel = (v) => (
2028
+ // 精简标签:只留说话人名 + 性别(去掉尾部区域与 ShortName 冗余),避免 Edge 长名被截断。
2029
+ (voiceName(v.FriendlyName) || v.ShortName) + " \xB7 " + genderName(v.Gender)
2009
2030
  );
2031
+ const all = data.voices.map((v) => ({ v: v.ShortName, label: mkLabel(v) })).sort((a, b) => a.label.localeCompare(b.label));
2032
+ const commonKeys = new Set(VOICE_OPTIONS.map((o) => o.v));
2033
+ const pinned = VOICE_OPTIONS.filter((o) => all.some((a) => a.v === o.v));
2034
+ setEdgeVoices([...pinned, ...all.filter((a) => !commonKeys.has(a.v))]);
2010
2035
  }).catch(() => void 0);
2011
2036
  return () => {
2012
2037
  alive = false;
@@ -2049,6 +2074,18 @@ function VoiceSettingsCard({ scope }) {
2049
2074
  }
2050
2075
  ) }),
2051
2076
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(EngineStatusInline, {}),
2077
+ engine === "kokoro" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "kokoroModel", desc: t("descKokoroModel"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
2078
+ SegGroup,
2079
+ {
2080
+ score: scope,
2081
+ field: "kokoroModel",
2082
+ value: value.kokoroModel,
2083
+ options: [
2084
+ { v: "int8", label: t("kokoroModelInt8") },
2085
+ { v: "fp32", label: t("kokoroModelFp32") }
2086
+ ]
2087
+ }
2088
+ ) }),
2052
2089
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
2053
2090
  Row,
2054
2091
  {
@@ -2147,7 +2184,7 @@ var TELEMETRY_VIEW = [
2147
2184
  { stage: "first-tts-chunk", key: "telFirstChunk" },
2148
2185
  { stage: "first-audio-played", key: "telFirstPlayed" }
2149
2186
  ];
2150
- var BUILD_TAG = "a76aad8";
2187
+ var BUILD_TAG = "d4c61da";
2151
2188
  var TELEMETRY_FLAG = "dsh-voice-mode.telemetry";
2152
2189
  var telemetryEnabled = typeof localStorage !== "undefined" && localStorage.getItem(TELEMETRY_FLAG) === "1";
2153
2190
  console.log("[dsh-voice] build=" + BUILD_TAG);
@@ -3245,7 +3282,11 @@ function MicButton({
3245
3282
  if (sid2) void bus.exit(sid2);
3246
3283
  }
3247
3284
  };
3285
+ const toggleGuardRef = (0, import_react2.useRef)(0);
3248
3286
  const toggle = () => {
3287
+ const now = Date.now();
3288
+ if (now - toggleGuardRef.current < 2e3) return;
3289
+ toggleGuardRef.current = now;
3249
3290
  if (localRef.current === "on") void exitModeRef.current("manual");
3250
3291
  else if (localRef.current === "off") void enterMode();
3251
3292
  };
@@ -3520,6 +3561,9 @@ function MicButton({
3520
3561
  return;
3521
3562
  }
3522
3563
  if (ms < 250) {
3564
+ const now = Date.now();
3565
+ if (now - toggleGuardRef.current < 2e3) return;
3566
+ toggleGuardRef.current = now;
3523
3567
  if (localRef.current === "on") {
3524
3568
  engineRef.current?.endHeld(true);
3525
3569
  void exitModeRef.current("manual");
package/lib/index.js CHANGED
@@ -1129,6 +1129,10 @@ var TtsQueue = class {
1129
1129
  if (s) return s;
1130
1130
  return { engine: "edge", ready: true, loading: false };
1131
1131
  }
1132
+ /** 触发当前引擎预热/下载模型并初始化(设置面板「下载」按钮;Edge 为无操作)。 */
1133
+ async prepare() {
1134
+ await this.engine.prepare?.();
1135
+ }
1132
1136
  /**
1133
1137
  * 一次性合成(设置卡「试听」用):委托当前引擎;不干扰朗读队列的在途合成。
1134
1138
  * 失败(含非法音色)抛错。
@@ -1248,7 +1252,9 @@ import { fileURLToPath as fileURLToPath2 } from "node:url";
1248
1252
  import { join as join3 } from "node:path";
1249
1253
  import { statSync as statSync2 } from "node:fs";
1250
1254
  var TTS_MODEL_REPO = "csukuangfj/sherpa-onnx-vits-zh-ll";
1251
- var KOKORO_MODEL_DIR = "csukuangfj/kokoro-int8-multi-lang-v1_1";
1255
+ var KOKORO_MODEL_DIR_INT8 = "csukuangfj/kokoro-int8-multi-lang-v1_1";
1256
+ var KOKORO_MODEL_DIR_FP32 = "csukuangfj/kokoro-multi-lang-v1_1";
1257
+ var kokoroModelDir = (m) => m === "fp32" ? KOKORO_MODEL_DIR_FP32 : KOKORO_MODEL_DIR_INT8;
1252
1258
  var TTS_MODEL_FILES = [
1253
1259
  { file: "model.onnx", sha256: "6c349bdd73dc928234dd7bc86929748bba32cd5264d32d915bf7b7aa0595965b" },
1254
1260
  { file: "lexicon.txt", sha256: "b3a82f16b286c424953dea3686039e7ab465fa8e15d87ef8abd0ec69175beb21" },
@@ -1452,36 +1458,51 @@ var VITS_SPEC = {
1452
1458
  defaultVoice: "suyingxue",
1453
1459
  toSid: voiceToSid
1454
1460
  };
1455
- var KOKORO_SPEC = {
1456
- files: [
1457
- { file: "model.int8.onnx", sha256: "bda15858163726a492d02a9a727bc263551b86ac77f90812c4b30ff41d380e26" },
1458
- { file: "voices.bin", sha256: "e64a5a581d8c2a350d848f51c3121657cd83aa07ed6109172177345874a7244c" },
1459
- { file: "tokens.txt", sha256: "931ab2df2400cd65d580a22402024c2347ced8ae9ea300e545144b1aacc48e14" },
1460
- { file: "lexicon-us-en.txt", sha256: "7daaab53a181be9885b853a8582bf1838186317e5dadacbcef9c426d6fa0da14" },
1461
- { file: "lexicon-zh.txt", sha256: "11111d8cd695fba2ace1367a1d0a708b586e6ef5c1f9be91da5d7eef129b651c" },
1462
- { file: "espeak-ng-data/phontab", sha256: "886f3fa402cb0ba73d483aa8ad000af47a6b7cc06293c75a97913fba68a530f6" },
1463
- { file: "date-zh.fst", sha256: "eb8aa079ae3cb81d8f4404992f39d61a0cb990947512b5b8d1e54d1f6980e718" },
1464
- { file: "number-zh.fst", sha256: "743f402181fcfebf76cc2f0546b71fa26476e626fbe4e460fb7b4c3a7a8bd5bd" },
1465
- { file: "phone-zh.fst", sha256: "1ac2b6fa56b1442320c4de7db08353bab8963a2b57f365eebcdd3a2d3562f8d7" }
1466
- ],
1467
- workerPaths: (dir) => ({
1468
- model: join3(dir, "model.int8.onnx"),
1469
- voices: join3(dir, "voices.bin"),
1470
- tokens: join3(dir, "tokens.txt"),
1471
- dataDir: join3(dir, "espeak-ng-data"),
1472
- lexicon: [join3(dir, "lexicon-us-en.txt"), join3(dir, "lexicon-zh.txt")].join(","),
1473
- date: join3(dir, "date-zh.fst"),
1474
- phone: join3(dir, "phone-zh.fst"),
1475
- number: join3(dir, "number-zh.fst"),
1476
- lang: ""
1477
- }),
1478
- defaultVoice: "zf_xiaobei",
1479
- toSid: kokoroVoiceToSid
1480
- };
1461
+ var KOKORO_SHARED_FILES = [
1462
+ { file: "voices.bin", sha256: "e64a5a581d8c2a350d848f51c3121657cd83aa07ed6109172177345874a7244c" },
1463
+ { file: "tokens.txt", sha256: "931ab2df2400cd65d580a22402024c2347ced8ae9ea300e545144b1aacc48e14" },
1464
+ { file: "lexicon-us-en.txt", sha256: "7daaab53a181be9885b853a8582bf1838186317e5dadacbcef9c426d6fa0da14" },
1465
+ { file: "lexicon-zh.txt", sha256: "11111d8cd695fba2ace1367a1d0a708b586e6ef5c1f9be91da5d7eef129b651c" },
1466
+ { file: "espeak-ng-data/phontab", sha256: "886f3fa402cb0ba73d483aa8ad000af47a6b7cc06293c75a97913fba68a530f6" },
1467
+ { file: "date-zh.fst", sha256: "eb8aa079ae3cb81d8f4404992f39d61a0cb990947512b5b8d1e54d1f6980e718" },
1468
+ { file: "number-zh.fst", sha256: "743f402181fcfebf76cc2f0546b71fa26476e626fbe4e460fb7b4c3a7a8bd5bd" },
1469
+ { file: "phone-zh.fst", sha256: "1ac2b6fa56b1442320c4de7db08353bab8963a2b57f365eebcdd3a2d3562f8d7" }
1470
+ ];
1471
+ var kokoroWorkerPaths = (dir, modelFile) => ({
1472
+ model: join3(dir, modelFile),
1473
+ voices: join3(dir, "voices.bin"),
1474
+ tokens: join3(dir, "tokens.txt"),
1475
+ dataDir: join3(dir, "espeak-ng-data"),
1476
+ lexicon: [join3(dir, "lexicon-us-en.txt"), join3(dir, "lexicon-zh.txt")].join(","),
1477
+ date: join3(dir, "date-zh.fst"),
1478
+ phone: join3(dir, "phone-zh.fst"),
1479
+ number: join3(dir, "number-zh.fst"),
1480
+ lang: ""
1481
+ });
1482
+ function kokoroSpec(model) {
1483
+ const main = model === "fp32" ? { file: "model.onnx", sha256: "acc4adc175b9d9986106cd20060329673ad5a2e12ef3c557d2d3745b694f8b38" } : { file: "model.int8.onnx", sha256: "bda15858163726a492d02a9a727bc263551b86ac77f90812c4b30ff41d380e26" };
1484
+ return {
1485
+ files: [main, ...KOKORO_SHARED_FILES],
1486
+ workerPaths: (dir) => kokoroWorkerPaths(dir, main.file),
1487
+ defaultVoice: "zf_xiaobei",
1488
+ toSid: kokoroVoiceToSid
1489
+ };
1490
+ }
1481
1491
  function createSherpaLocalEngine(options) {
1482
1492
  const { cacheDir, modelHost, allowCustomHost, broadcast } = options;
1483
- const spec = options.kind === "kokoro" ? KOKORO_SPEC : VITS_SPEC;
1484
- const repoName = options.kind === "kokoro" ? KOKORO_MODEL_DIR : TTS_MODEL_REPO;
1493
+ let downloadProgress = null;
1494
+ const trackedBroadcast = (event, payload) => {
1495
+ if (event === "asr-progress" && payload && typeof payload === "object") {
1496
+ const pr = payload;
1497
+ if (typeof pr.file === "string" && typeof pr.percent === "number") {
1498
+ downloadProgress = { file: pr.file, percent: Math.min(100, Math.max(0, pr.percent)) };
1499
+ }
1500
+ }
1501
+ broadcast(event, payload);
1502
+ };
1503
+ const kokoroModel = options.model ?? "int8";
1504
+ const spec = options.kind === "kokoro" ? kokoroSpec(kokoroModel) : VITS_SPEC;
1505
+ const repoName = options.kind === "kokoro" ? kokoroModelDir(kokoroModel) : TTS_MODEL_REPO;
1485
1506
  const repoDir = join3(cacheDir, repoName);
1486
1507
  const workerPath = fileURLToPath2(new URL("./tts-vits-worker.cjs", import.meta.url));
1487
1508
  let child = null;
@@ -1520,6 +1541,7 @@ function createSherpaLocalEngine(options) {
1520
1541
  ready = (async () => {
1521
1542
  engineLoading = true;
1522
1543
  engineError = void 0;
1544
+ downloadProgress = null;
1523
1545
  try {
1524
1546
  if (!child || !childInit) {
1525
1547
  for (const f of spec.files) {
@@ -1529,7 +1551,7 @@ function createSherpaLocalEngine(options) {
1529
1551
  spec: f,
1530
1552
  primaryHost: modelHost(),
1531
1553
  allowCustomHost,
1532
- broadcast
1554
+ broadcast: trackedBroadcast
1533
1555
  });
1534
1556
  if (!ok) throw new Error("local TTS model download/verify failed: " + f.file);
1535
1557
  }
@@ -1540,7 +1562,7 @@ function createSherpaLocalEngine(options) {
1540
1562
  subdir: "espeak-ng-data",
1541
1563
  primaryHost: modelHost(),
1542
1564
  allowCustomHost,
1543
- broadcast
1565
+ broadcast: trackedBroadcast
1544
1566
  });
1545
1567
  if (!treeOk) throw new Error("local TTS model download failed: espeak-ng-data");
1546
1568
  }
@@ -1623,6 +1645,7 @@ function createSherpaLocalEngine(options) {
1623
1645
  ready: childInit === true,
1624
1646
  loading: engineLoading,
1625
1647
  error: engineError,
1648
+ progress: downloadProgress ?? void 0,
1626
1649
  local: {
1627
1650
  repo: repoName,
1628
1651
  ready: files.every((f) => f.exists),
@@ -1632,6 +1655,8 @@ function createSherpaLocalEngine(options) {
1632
1655
  }
1633
1656
  };
1634
1657
  },
1658
+ // 设置面板「下载」按钮:无文本也触发模型下载 + 子进程初始化(与首次合成路径一致)。
1659
+ prepare: () => ensureReady(),
1635
1660
  async synthesize(text, opts = {}) {
1636
1661
  await ensureReady();
1637
1662
  const sid = spec.toSid(opts.voice ?? voice);
@@ -1744,6 +1769,7 @@ var inject = ["webServer", "settings", "sessions"];
1744
1769
  var defaultModelCacheDir = () => process.platform === "win32" ? join4(process.env.LOCALAPPDATA ?? join4(homedir(), "AppData", "Local"), "dsh-voice-mode", "models") : join4(homedir(), ".cache", "dsh-voice-mode", "models");
1745
1770
  var VOICE_SETTINGS_DEFAULTS = {
1746
1771
  ttsEngine: "edge",
1772
+ kokoroModel: "int8",
1747
1773
  voice: "zh-CN-XiaoxiaoNeural",
1748
1774
  rate: 1,
1749
1775
  interruptLevel: 0,
@@ -1767,6 +1793,9 @@ function createVoiceSettingsSchema(defs) {
1767
1793
  ttsEngine: z.union([z.const("vits"), z.const("kokoro"), z.const("edge")]).default(d.ttsEngine).description(
1768
1794
  "\u6717\u8BFB\u5F15\u64CE\uFF1Aedge \u5FAE\u8F6F\u4E91\u7AEF\uFF08\u9ED8\u8BA4\uFF0C\u5FEB\u3001\u97F3\u8D28\u81EA\u7136\uFF0C\u88AB\u6717\u8BFB\u6587\u672C\u4F1A\u53D1\u9001\u5230\u5FAE\u8F6F\uFF09/ vits \u672C\u5730\u4E2D\u6587 / kokoro \u672C\u5730\u4E2D\u82F1\uFF08\u56DE\u590D\u6587\u672C\u4E0D\u51FA\u672C\u673A\uFF09\uFF1B\u5207\u6362\u5373\u65F6\u751F\u6548"
1769
1795
  ),
1796
+ kokoroModel: z.union([z.const("int8"), z.const("fp32")]).default(d.kokoroModel).description(
1797
+ "Kokoro \u6A21\u578B\u7CBE\u5EA6\uFF1Aint8\uFF08\u9ED8\u8BA4\uFF0C\u4F53\u79EF\u5C0F/\u52A0\u8F7D\u5FEB\uFF0CCPU \u53CB\u597D\uFF09/ fp32\uFF08\u97F3\u8D28\u66F4\u597D\u3001\u4F53\u79EF\u5927\uFF0CGPU \u6216\u5927\u5185\u5B58\u673A\u5668\u63A8\u8350\uFF09\uFF1B\u4E24\u6863\u5171\u7528\u540C\u4E00\u5957 103 \u97F3\u8272\uFF0C\u5207\u6362\u5373\u65F6\u751F\u6548"
1798
+ ),
1770
1799
  voice: z.string().default(d.voice).description(
1771
1800
  "\u6717\u8BFB\u97F3\u8272\uFF08\u6309 ttsEngine \u53D6\u503C\uFF1Avits \u7528\u8BF4\u8BDD\u4EBA\u540D suyingxue/gunian/fushiyu/bingjiao/bazong\uFF1Bkokoro \u7528 0-102 \u7F16\u53F7\u6216\u4E2D\u6587\u540D zf_xiaobei/zf_xiaoni/zf_xiaoxiao/zf_xiaoyi\uFF1Bedge \u7528 Edge ShortName \u5982 zh-CN-XiaoxiaoNeural \u6653\u6653\xB7\u5973\uFF0C\u5B8C\u6574\u6E05\u5355\u89C1 scripts/list-voices.mjs\uFF09"
1772
1801
  ),
@@ -1793,6 +1822,7 @@ var Config = z.object({
1793
1822
  cacheDir: z.string().default(defaultModelCacheDir()),
1794
1823
  modelHost: z.string().default("https://huggingface.co"),
1795
1824
  ttsEngine: z.union([z.const("edge"), z.const("vits"), z.const("kokoro")]).default("edge"),
1825
+ kokoroModel: z.union([z.const("int8"), z.const("fp32")]).default("int8"),
1796
1826
  allowLan: z.boolean().default(false),
1797
1827
  allowCustomModelHost: z.boolean().default(false),
1798
1828
  voice: z.string().default("zh-CN-XiaoxiaoNeural"),
@@ -1878,6 +1908,7 @@ function apply(ctx, config) {
1878
1908
  cacheDir: config.cacheDir,
1879
1909
  modelHost: normalizedModelHost,
1880
1910
  allowCustomHost: config.allowCustomModelHost,
1911
+ model: vset.kokoroModel,
1881
1912
  broadcast
1882
1913
  });
1883
1914
  }
@@ -1889,6 +1920,7 @@ function apply(ctx, config) {
1889
1920
  });
1890
1921
  };
1891
1922
  let engineKind = vset.ttsEngine ?? config.ttsEngine;
1923
+ let activeKokoroModel = vset.kokoroModel;
1892
1924
  const queue = new TtsQueue({
1893
1925
  engine: makeEngine(engineKind),
1894
1926
  onError: (sessionId) => broadcast("tts-error", { sessionId })
@@ -1903,6 +1935,9 @@ function apply(ctx, config) {
1903
1935
  if (next.ttsEngine !== engineKind) {
1904
1936
  engineKind = next.ttsEngine;
1905
1937
  queue.setEngine(makeEngine(engineKind));
1938
+ } else if (engineKind === "kokoro" && next.kokoroModel !== activeKokoroModel) {
1939
+ activeKokoroModel = next.kokoroModel;
1940
+ queue.setEngine(makeEngine("kokoro"));
1906
1941
  }
1907
1942
  queue.updateVoice(next.voice, next.rate);
1908
1943
  })
@@ -2072,7 +2107,7 @@ function apply(ctx, config) {
2072
2107
  respondJson2(res, 400, { error: "invalid on" });
2073
2108
  return;
2074
2109
  }
2075
- if (!limiter.hit(`toggle:${sessionId}`, 1, 2e3)) {
2110
+ if (!limiter.hit(`toggle:${sessionId}`, 2, 2e3)) {
2076
2111
  res.statusCode = 429;
2077
2112
  res.setHeader("content-type", "application/json");
2078
2113
  res.end(JSON.stringify({ error: "rate limited" }));
@@ -2189,7 +2224,7 @@ function apply(ctx, config) {
2189
2224
  respondJson2(res, 400, { error: "invalid json" });
2190
2225
  return;
2191
2226
  }
2192
- const dir = join4(config.cacheDir, engine === "kokoro" ? KOKORO_MODEL_DIR : TTS_MODEL_REPO);
2227
+ const dir = join4(config.cacheDir, engine === "kokoro" ? kokoroModelDir(vset.kokoroModel) : TTS_MODEL_REPO);
2193
2228
  void rm(dir, { recursive: true, force: true }).then(() => {
2194
2229
  if (engineKind === engine) {
2195
2230
  queue.setEngine(makeEngine(engine));
@@ -2201,6 +2236,42 @@ function apply(ctx, config) {
2201
2236
  }
2202
2237
  })
2203
2238
  );
2239
+ ctx.effect(
2240
+ () => ctx.webServer.register({
2241
+ kind: "exact",
2242
+ path: `${base}/models/download`,
2243
+ handler: (req, res) => {
2244
+ if (denyNonLoopback(req, res)) return;
2245
+ if (denyCrossOrigin(req, res)) return;
2246
+ if (!config.enabled) {
2247
+ respondJson2(res, 403, { error: "voice mode disabled" });
2248
+ return;
2249
+ }
2250
+ collectBody(req, res, MAX_JSON_BODY, (body) => {
2251
+ let engine = "vits";
2252
+ try {
2253
+ const p = JSON.parse(body || "{}");
2254
+ if (p.engine === "kokoro" || p.engine === "vits") engine = p.engine;
2255
+ else {
2256
+ respondJson2(res, 400, { error: "invalid engine" });
2257
+ return;
2258
+ }
2259
+ } catch {
2260
+ respondJson2(res, 400, { error: "invalid json" });
2261
+ return;
2262
+ }
2263
+ if (engineKind !== engine) {
2264
+ respondJson2(res, 400, { error: "engine not active" });
2265
+ return;
2266
+ }
2267
+ void queue.prepare().then(() => respondJson2(res, 200, { ok: true, engine })).catch((e) => {
2268
+ console.warn(`[dsh-voice-mode] model download failed: ${String(e)}`);
2269
+ respondJson2(res, 502, { error: "\u6A21\u578B\u4E0B\u8F7D\u5931\u8D25\uFF1A\u8BF7\u68C0\u67E5\u7F51\u7EDC" });
2270
+ });
2271
+ });
2272
+ }
2273
+ })
2274
+ );
2204
2275
  ctx.effect(
2205
2276
  () => ctx.webServer.register({
2206
2277
  kind: "exact",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-voice-mode",
3
3
  "description": "Full-duplex voice mode for DeepSeek Harness: zipformer2 streaming ASR → editable draft, Edge TTS (default, fast) or local (VITS / Kokoro int8, privacy-first) read-aloud with live captions, true barge-in; hardened HTTP surface (loopback/origin/rate-limit) + model SHA256 pinning. · DSH 语音双工对话:流式识别入草稿、Edge 默认朗读或本地(VITS / Kokoro int8)朗读+实时字幕、开口即打断;HTTP 面安全加固。",
4
- "version": "0.5.0",
4
+ "version": "0.6.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "repository": {
@@ -51,6 +51,18 @@ const MODELS = {
51
51
  'number-zh.fst': '743f402181fcfebf76cc2f0546b71fa26476e626fbe4e460fb7b4c3a7a8bd5bd',
52
52
  'phone-zh.fst': '1ac2b6fa56b1442320c4de7db08353bab8963a2b57f365eebcdd3a2d3562f8d7',
53
53
  },
54
+ // 本地 TTS:Kokoro fp32(音质更好,约 311MB;支持文件与 int8 同源)
55
+ 'csukuangfj/kokoro-multi-lang-v1_1': {
56
+ 'model.onnx': 'acc4adc175b9d9986106cd20060329673ad5a2e12ef3c557d2d3745b694f8b38',
57
+ 'voices.bin': 'e64a5a581d8c2a350d848f51c3121657cd83aa07ed6109172177345874a7244c',
58
+ 'tokens.txt': '931ab2df2400cd65d580a22402024c2347ced8ae9ea300e545144b1aacc48e14',
59
+ 'lexicon-us-en.txt': '7daaab53a181be9885b853a8582bf1838186317e5dadacbcef9c426d6fa0da14',
60
+ 'lexicon-zh.txt': '11111d8cd695fba2ace1367a1d0a708b586e6ef5c1f9be91da5d7eef129b651c',
61
+ 'espeak-ng-data/phontab': '886f3fa402cb0ba73d483aa8ad000af47a6b7cc06293c75a97913fba68a530f6',
62
+ 'date-zh.fst': 'eb8aa079ae3cb81d8f4404992f39d61a0cb990947512b5b8d1e54d1f6980e718',
63
+ 'number-zh.fst': '743f402181fcfebf76cc2f0546b71fa26476e626fbe4e460fb7b4c3a7a8bd5bd',
64
+ 'phone-zh.fst': '1ac2b6fa56b1442320c4de7db08353bab8963a2b57f365eebcdd3a2d3562f8d7',
65
+ },
54
66
  }
55
67
  const HOST_PRIMARY = 'https://huggingface.co'
56
68
  const HOST_FALLBACK = 'https://hf-mirror.com'
@@ -150,21 +162,25 @@ for (const [repo, files] of Object.entries(MODELS)) {
150
162
  }
151
163
  }
152
164
 
153
- // --- Kokoro 中英朗读模型:文件多(含 espeak-ng-data 全目录),走 HF 树枚举批量下载 ---
154
- {
155
- const repo = 'csukuangfj/kokoro-int8-multi-lang-v1_1'
165
+ // --- Kokoro 中英朗读模型:文件多(含 espeak-ng-data 全目录),走 HF 树枚举批量下载。
166
+ // int8(默认,CPU 友好)与 fp32(音质更好)两档,主模型文件不同、支持文件同源。 ---
167
+ for (const kokoro of [
168
+ { repo: 'csukuangfj/kokoro-int8-multi-lang-v1_1', main: 'model.int8.onnx', sha: 'bda15858163726a492d02a9a727bc263551b86ac77f90812c4b30ff41d380e26' },
169
+ { repo: 'csukuangfj/kokoro-multi-lang-v1_1', main: 'model.onnx', sha: 'acc4adc175b9d9986106cd20060329673ad5a2e12ef3c557d2d3745b694f8b38' },
170
+ ]) {
171
+ const { repo, main, sha } = kokoro
156
172
  const repoDir = join(cacheDir, repo)
157
173
  const SENTINELS = {
158
- 'model.int8.onnx': 'bda15858163726a492d02a9a727bc263551b86ac77f90812c4b30ff41d380e26',
174
+ [main]: sha,
159
175
  'voices.bin': 'e64a5a581d8c2a350d848f51c3121657cd83aa07ed6109172177345874a7244c',
160
176
  'tokens.txt': '931ab2df2400cd65d580a22402024c2347ced8ae9ea300e545144b1aacc48e14',
161
177
  'lexicon-us-en.txt': '7daaab53a181be9885b853a8582bf1838186317e5dadacbcef9c426d6fa0da14',
162
178
  'lexicon-zh.txt': '11111d8cd695fba2ace1367a1d0a708b586e6ef5c1f9be91da5d7eef129b651c',
163
179
  'espeak-ng-data/phontab': '886f3fa402cb0ba73d483aa8ad000af47a6b7cc06293c75a97913fba68a530f6',
164
180
  }
165
- const sentinelOk = await Promise.all(Object.entries(SENTINELS).map(async ([f, sha]) => (await sha256OfFile(join(repoDir, f)).catch(() => '')) === sha))
181
+ const sentinelOk = await Promise.all(Object.entries(SENTINELS).map(async ([f, sha2]) => (await sha256OfFile(join(repoDir, f)).catch(() => '')) === sha2))
166
182
  if (!sentinelOk.every(Boolean)) {
167
- console.log(`Kokoro 模型(${repo}):文件不全,走 HF 树枚举下载(约 400 个文件)`)
183
+ console.log(`Kokoro 模型(${repo}):文件不全,走 HF 树枚举下载`)
168
184
  const treeRes = await fetch(`${HOST_FALLBACK}/api/models/${repo}/tree/main?recursive=true`, { headers: { 'user-agent': 'dsh-voice-mode/prefetch' } })
169
185
  if (treeRes.ok) {
170
186
  const tree = await treeRes.json()