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 +6 -5
- package/README.md +5 -4
- package/lib/client.js +84 -40
- package/lib/index.js +104 -33
- package/package.json +1 -1
- package/scripts/prefetch.mjs +22 -6
package/README.en.md
CHANGED
|
@@ -11,11 +11,11 @@
|
|
|
11
11
|
>
|
|
12
12
|
> 中文说明见 [README.md](./README.md)。
|
|
13
13
|
|
|
14
|
-
> **Version note (0.
|
|
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
|
|
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
|
|
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
|

|
|
15
15
|
|
|
16
|
-
> **版本说明(0.
|
|
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
|
|
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` | `
|
|
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\
|
|
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
|
|
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
|
-
|
|
1449
|
-
|
|
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:
|
|
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 [
|
|
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 && !
|
|
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("
|
|
1853
|
-
statusColor =
|
|
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:
|
|
1858
|
-
tts.loading &&
|
|
1859
|
-
|
|
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
|
-
|
|
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: () =>
|
|
1869
|
-
disabled:
|
|
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("
|
|
1882
|
-
children:
|
|
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
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
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 = "
|
|
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
|
|
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
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
}
|
|
1478
|
-
|
|
1479
|
-
|
|
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
|
-
|
|
1484
|
-
const
|
|
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}`,
|
|
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" ?
|
|
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.
|
|
4
|
+
"version": "0.6.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"repository": {
|
package/scripts/prefetch.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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()
|