dsh-voice-mode 0.1.5 → 0.2.1
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 +1 -1
- package/README.md +60 -190
- package/lib/client.js +232 -74
- package/lib/index.js +7 -2
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -263,7 +263,7 @@ test/hold-e2e.js hold-mode end-to-end acceptance (standalone browser)
|
|
|
263
263
|
scripts/list-voices.mjs print all Edge TTS voices (source of the voice table)
|
|
264
264
|
```
|
|
265
265
|
|
|
266
|
-
|
|
266
|
+
Integration probes (`hold-e2e.js`, `spoken-prompt-rpc.sh`, `spoken-toggle-ui-check.js`) live in the repo root `test/`, outside this npm package.
|
|
267
267
|
|
|
268
268
|
## License
|
|
269
269
|
|
package/README.md
CHANGED
|
@@ -1,97 +1,55 @@
|
|
|
1
1
|
# dsh-voice-mode
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/dsh-voice-mode)
|
|
4
|
-
[](
|
|
4
|
+
[](LICENSE)
|
|
5
5
|
[](https://github.com/topics/dsh-plugin)
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
> spoken answer. Streamed zipformer2 ASR → editable draft → auto send → the
|
|
9
|
-
> final reply is read out sentence-by-sentence via Edge TTS, and your voice
|
|
10
|
-
> interrupts playback and the running turn. No API key.
|
|
7
|
+
DeepSeek Harness 语音双工对话模式:会话内一键进入 → 边说边出字的流式识别 → 停顿自动发送 → 最终答复按句流式朗读 + 实时字幕,开口即可打断(真 barge-in)。无需 API Key,识别模型在本地宿主端推理。
|
|
11
8
|
|
|
12
|
-
DeepSeek Harness
|
|
13
|
-
|
|
14
|
-
[English](./README.en.md)
|
|
15
|
-
|
|
16
|
-

|
|
17
|
-
|
|
18
|
-
## 特性
|
|
9
|
+
> **Full-duplex voice mode for DeepSeek Harness** — streamed ASR to an editable draft, sentence-by-sentence read-aloud with live captions, and speaking interrupts playback and the running turn.
|
|
19
10
|
|
|
20
11
|

|
|
21
12
|
|
|
13
|
+
## 功能
|
|
22
14
|
|
|
23
15
|
- **语音模式**:输入框工具排麦克风按钮或全局快捷键 `Ctrl+Shift+V` 进入/退出;全局单活(同一时刻仅一个会话处于语音模式,切换会话自动让出)
|
|
24
16
|
- **两种交互模式(设置可切换)**:
|
|
25
17
|
- `toggle`(默认)持续聆听:RMS VAD 分段 → zipformer2 流式识别(边说边出字,实时字幕预览)→ 静音 2 秒自动断句进草稿并自动发送;按住 `Ctrl` 强制立即发送
|
|
26
18
|
- `hold` 按住说话:短按进入/退出,**按住麦克风按钮说话、松手即发**(滑出取消、`Esc`/失焦放弃本段);`Ctrl` 按住即录、松开即发
|
|
27
|
-
- **唤醒词(可选,默认关)**:设置 `wakeWord`
|
|
28
|
-
- **输出链路**:只朗读最终答复的 `text-delta`(reasoning/工具调用不读),按句流式 Edge TTS 朗读 +
|
|
29
|
-
- **开口打断(barge-in
|
|
30
|
-
- **模型懒加载与进度**:首次使用自动下载 zipformer2 中文流式模型(约 160MB,`.part`
|
|
31
|
-
-
|
|
32
|
-
-
|
|
19
|
+
- **唤醒词(可选,默认关)**:设置 `wakeWord` 后进入待机态,说出唤醒词才开始识别(如「你好小D」)
|
|
20
|
+
- **输出链路**:只朗读最终答复的 `text-delta`(reasoning/工具调用不读),按句流式 Edge TTS 朗读 + 右下角实时字幕浮层;工具调用触发提示音;全文照常写入聊天记录;可选口语化提示词(设置 `spokenFormat`,默认关)让回复为自然短句、不带 Markdown 排版符号,朗读侧再做一轮标记剥离
|
|
21
|
+
- **开口打断(barge-in)**:三档灵敏度发声前沿检测 → 本地静音 + host 合成队列作废 + 正在运行的回合取消(保留半截并自然续入新消息)
|
|
22
|
+
- **模型懒加载与进度**:首次使用自动下载 zipformer2 中文流式模型(约 160MB,`.part` 断点续传),状态条实时显示进度;可用 `npm run prefetch` 预下载
|
|
23
|
+
- **设置**:设置 → Plugins → 插件配置 → 语音模式(voice-mode),可调音色/语速/打断灵敏度/静音停顿/空闲超时/模型镜像/自动发送/交互模式/唤醒词/口语化提示词;**音色可试听**(按当前音色+语速即时合成预览,自定义 ShortName 亦可)
|
|
24
|
+
- **界面语言**:跟随浏览器语言(中文 / English;切换后刷新页面生效)
|
|
25
|
+
- **容错**:麦克风被拒红点提示、模型下载失败可见提示、TTS 连接失败状态条提示(自动退避重试)、提交失败文字留在草稿、SSE 断线自动重连
|
|
33
26
|
- **空闲退出**:10 分钟无活动自动退出并释放麦克风
|
|
34
27
|
|
|
35
|
-
## 操作手势
|
|
36
|
-
|
|
37
|
-
| 手势 | 行为 |
|
|
38
|
-
| --- | --- |
|
|
39
|
-
| 点按麦克风按钮 / `Ctrl+Shift+V` | 进入 / 退出语音模式 |
|
|
40
|
-
| 直接说话,停顿 2 秒(toggle) | 自动断句并发送 |
|
|
41
|
-
| 按住 `Ctrl`(toggle,≥250ms 语音) | 强制立即发送当前段 |
|
|
42
|
-
| **按住麦克风按钮(hold)** | 按住说话,松手发送;向上滑出 / `Esc` / 失去焦点放弃本段;<250ms 短按退出模式 |
|
|
43
|
-
| 按住 `Ctrl`(hold,≥600ms) | 键盘按住说话,松开即发 |
|
|
44
|
-
| 先喊一声唤醒词(已配置) | 从待机激活为聆听(其后才识别与发送) |
|
|
45
|
-
| AI 朗读时开口说话 | 打断朗读并取消当前回合 |
|
|
46
|
-
| 在输入框打字 | 自动退出语音模式(草稿保留) |
|
|
47
|
-
|
|
48
28
|
## 安装
|
|
49
29
|
|
|
50
|
-
**要求**:dsh web(Node ≥ 18),现代浏览器(Chrome / Edge / Firefox,需支持 `getUserMedia` 与 Web Audio)。
|
|
51
|
-
|
|
52
30
|
```sh
|
|
53
|
-
# 方式一:从 npm 安装(推荐)
|
|
54
31
|
dsh plugin --profile web add dsh-voice-mode
|
|
55
|
-
# 等价形式(本机未装 dsh CLI 时由 npx 临时拉起):
|
|
56
|
-
npx -y @deepseek-ai/dsh plugin --profile web add dsh-voice-mode
|
|
57
|
-
|
|
58
|
-
# 方式二:本地 tarball
|
|
59
|
-
dsh plugin --profile web add ./dsh-voice-mode-0.1.0.tgz
|
|
60
|
-
|
|
61
|
-
# 方式三:从源码安装
|
|
62
|
-
git clone https://github.com/qishuilalala/dsh-voice-mode.git
|
|
63
|
-
cd dsh-voice-mode/plugin/dsh-voice-mode && pnpm install && pnpm build
|
|
64
|
-
dsh plugin --profile web add .
|
|
65
32
|
```
|
|
66
33
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
- **Linux(systemd)**:`systemctl restart dsh`
|
|
70
|
-
- **Windows / macOS / 手动托管**:重启你的 dsh 进程(结束进程后重新 `dsh web`,或在其服务管理器中重启)
|
|
71
|
-
|
|
72
|
-
**可选**:预下载 ASR 模型,减少首次进入语音模式的下载等待:
|
|
73
|
-
|
|
74
|
-
```sh
|
|
75
|
-
npm run prefetch # 插件目录内执行;默认写到平台缓存目录
|
|
76
|
-
# 或指定缓存位置:node scripts/prefetch.mjs --cache-dir /where/ever/models
|
|
77
|
-
```
|
|
34
|
+
bundle 插件安装后需重启 dsh 生效(Linux:`systemctl restart dsh`;其他平台重启 dsh 进程)。
|
|
78
35
|
|
|
79
|
-
##
|
|
80
|
-
|
|
81
|
-
1. 点击输入框工具排的麦克风按钮(或按 `Ctrl+Shift+V`)进入语音模式,输入框上方出现状态条
|
|
82
|
-
2. 说话方式二选一:直接说、停顿 2 秒自动发送(toggle);或按住麦克风按钮、松手发送(hold)
|
|
83
|
-
3. AI 回复逐句朗读,右下角浮层显示字幕;点「跳过」或直接开口打断
|
|
84
|
-
4. 点状态条「退出」(或再按 `Ctrl+Shift+V`)退出语音模式
|
|
85
|
-
|
|
86
|
-
首次进入会下载识别模型,状态条显示 `正在加载模型… <文件> <百分比>%`。
|
|
36
|
+
## 操作手势
|
|
87
37
|
|
|
88
|
-
|
|
38
|
+
| 手势 | 作用 |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| `Ctrl+Shift+V` | 进入 / 退出语音模式 |
|
|
41
|
+
| 直接说话 | `toggle`:边说边出字,停顿 2 秒自动发送;按住 `Ctrl` 强制立即发送 |
|
|
42
|
+
| 按住麦克风按钮 | `hold`:松手发送;短按退出;滑出 / `Esc` / 失焦放弃本段 |
|
|
43
|
+
| 说唤醒词 | 待机态激活识别(配置后) |
|
|
44
|
+
| AI 朗读时开口说话 | 打断朗读并取消当前回合 |
|
|
45
|
+
| 点状态条「退出」 | 退出语音模式 |
|
|
46
|
+
| 点字幕浮层「跳过」 | 跳过当前句朗读 |
|
|
89
47
|
|
|
90
48
|
## 设置(设置 → Plugins → 插件配置 → 语音模式)
|
|
91
49
|
|
|
92
50
|
| 键 | 默认 | 说明 |
|
|
93
51
|
| --- | --- | --- |
|
|
94
|
-
| `voice` | `zh-CN-XiaoxiaoNeural` | Edge TTS
|
|
52
|
+
| `voice` | `zh-CN-XiaoxiaoNeural` | Edge TTS 音色(见下方常用音色表),**即时生效**;行内「试听」按钮可即时预览 |
|
|
95
53
|
| `rate` | `1.0` | 朗读语速倍率(0.5 慢速 ~ 2.0 快速),**即时生效** |
|
|
96
54
|
| `interruptLevel` | `0` | 发声打断灵敏度:0 高门槛 / 1 中 / 2 低 |
|
|
97
55
|
| `silenceMs` | `2000` | 说完整一句的静音停顿毫秒数 |
|
|
@@ -100,9 +58,9 @@ npm run prefetch # 插件目录内执行;默认写到平台缓存目
|
|
|
100
58
|
| `autoSend` | `true` | 识别定稿后自动发送;关闭则只进草稿(按住 `Ctrl` / hold 松手仍会发送) |
|
|
101
59
|
| `mode` | `toggle` | 交互模式:`toggle` 持续聆听 + 2s 静音断句;`hold` 按住说话、松手发送(短按退出) |
|
|
102
60
|
| `wakeWord` | 空(关) | 唤醒词(如「你好小D」):进入后先说唤醒词激活,避免误触;空 = 关闭 |
|
|
103
|
-
| `spokenFormat` | `false` | 语音会话注入口语化提示词:开启后**仅当前语音会话**的回复被注入「口语化短句、不用 Markdown
|
|
61
|
+
| `spokenFormat` | `false` | 语音会话注入口语化提示词:开启后**仅当前语音会话**的回复被注入「口语化短句、不用 Markdown 排版符号」提示词(朗读更顺),**即时生效** |
|
|
104
62
|
|
|
105
|
-
生效范围:`voice`/`rate`/`spokenFormat`
|
|
63
|
+
生效范围:`voice`/`rate`/`spokenFormat` **立即生效**;其余设置下次进入语音模式时生效。设置项默认值由插件配置(`base` 层)提供。
|
|
106
64
|
|
|
107
65
|
### 常用音色(完整清单见 `node scripts/list-voices.mjs`)
|
|
108
66
|
|
|
@@ -115,162 +73,74 @@ npm run prefetch # 插件目录内执行;默认写到平台缓存目
|
|
|
115
73
|
| `zh-CN-YunyangNeural` | 云扬 · 男声 |
|
|
116
74
|
| `zh-CN-YunxiaNeural` | 云夏 · 男声 |
|
|
117
75
|
| `zh-CN-liaoning-XiaobeiNeural` | 小北 · 东北话 · 女声 |
|
|
118
|
-
| `zh-CN-shaanxi-XiaoniNeural` | 小妮 · 陕西话 · 女声 |
|
|
119
76
|
| `zh-HK-HiuMaanNeural` | 晓曼 · 粤语 · 女声 |
|
|
120
|
-
| `zh-HK-WanLungNeural` | 云龙 · 粤语 · 男声 |
|
|
121
77
|
| `zh-TW-HsiaoYuNeural` | 小雨 · 台湾腔 · 女声 |
|
|
122
|
-
| `
|
|
123
|
-
| `en-US-AriaNeural` | Aria · 英语 · 女声 |
|
|
124
|
-
| `en-US-GuyNeural` | Guy · 英语 · 男声 |
|
|
125
|
-
|
|
126
|
-
## 配置(bundle patch / settings.yaml)
|
|
127
|
-
|
|
128
|
-
也可直接编辑 `~/.dsh/settings.yaml` 的 `voice-mode:` 段(GUI 卡片与 RPC 写入同一文档层):
|
|
129
|
-
|
|
130
|
-
```yaml
|
|
131
|
-
- id: voice-mode
|
|
132
|
-
name: dsh-voice-mode
|
|
133
|
-
config:
|
|
134
|
-
enabled: true # false = 关闭整个语音模式(无法进入,toggle 拒绝)
|
|
135
|
-
cacheDir: ~/.cache/dsh-voice-mode/models # 可覆盖;默认按平台
|
|
136
|
-
# 以下为设置项的默认播种值(设置面板可覆盖;最终生效值以设置面板为准):
|
|
137
|
-
voice: zh-CN-XiaoxiaoNeural
|
|
138
|
-
rate: 1.0
|
|
139
|
-
interruptLevel: 0
|
|
140
|
-
silenceMs: 2000
|
|
141
|
-
idleTimeoutMinutes: 10
|
|
142
|
-
modelHost: https://huggingface.co
|
|
143
|
-
```
|
|
78
|
+
| `en-US-AriaNeural` | Aria · English · 女声 |
|
|
144
79
|
|
|
145
|
-
|
|
146
|
-
> 的最终生效值以**设置面板**为准;bundle 配置仅为这些键提供默认播种值
|
|
147
|
-
> (`enabled/cacheDir` 仍只由 bundle 配置控制)。
|
|
148
|
-
> 插件 HTTP 命名空间固定为 `/voice-mode`(与客户端 bundle 契约一致,不可配置)。
|
|
80
|
+
### 配置(bundle config / settings.yaml)
|
|
149
81
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
| 路由 | 说明 |
|
|
153
|
-
| --- | --- |
|
|
154
|
-
| `GET /voice-mode/stream` | SSE:`event: audio`(`{sessionId, seq, text, audio(base64 MP3)}`)、`event: mode`(全局单活归属)、`event: tool`(提示音)、`event: asr-progress / asr-ready / asr-error / tts-error` |
|
|
155
|
-
| `POST /voice-mode/toggle` | `{sessionId, on}` 进入/退出语音模式(全局单活) |
|
|
156
|
-
| `POST /voice-mode/asr` | 原始 f32 LE 16k PCM 载荷 → `{text}`(流式 zipformer2);模型未就绪返回 `202 {loading}`;`?reset=1` 丢弃进行中识别段(唤醒词命中清场用) |
|
|
157
|
-
| `POST /voice-mode/cancel` | `{sessionId}` 作废 TTS 队列并丢弃在途 ASR 段 |
|
|
158
|
-
| `POST /voice-mode/preview` | `{voice, rate?}` 一次性合成试听 → `audio/mpeg`(400 缺 voice / voice 过长;502 合成失败,含非法 ShortName;插件 `enabled=false` 时 403)。不要求语音模式激活,使用独立合成连接,不影响朗读队列 |
|
|
159
|
-
| `GET /voice-mode/config` | 客户端引导参数(静音阈值 / 灵敏度 / 音色语速等) |
|
|
160
|
-
| `GET /voice-mode` | 健康检查 `{ok, name, enabled, active}` |
|
|
161
|
-
|
|
162
|
-
## 模型与缓存
|
|
163
|
-
|
|
164
|
-
- 识别模型:`csukuangfj/sherpa-onnx-streaming-zipformer-zh-int8-2025-06-30`(encoder ≈154MB / decoder / joiner / tokens,共约 160MB),宿主端 sherpa-onnx(Node WASM,Apache-2.0,天然跨平台)
|
|
165
|
-
- 缓存目录默认值按平台:
|
|
166
|
-
- **Windows**:`%LOCALAPPDATA%\dsh-voice-mode\models`
|
|
167
|
-
- **macOS / Linux**:`~/.cache/dsh-voice-mode/models`
|
|
168
|
-
- 均可通过 `cacheDir` 配置覆盖
|
|
169
|
-
- 下载走 `.part` 断点续传,`huggingface.co` 失败自动回退 `hf-mirror.com`(可配置 `modelHost`)
|
|
82
|
+
`voice-mode` 命名空间配置可直接写入 `~/.dsh/settings.yaml`;插件总开关 `enabled`(默认 `true`)与模型缓存目录 `cacheDir`(默认 `~/.cache/dsh-voice-mode/models/`)在安装配置中设置。
|
|
170
83
|
|
|
171
84
|
## 工作原理
|
|
172
85
|
|
|
173
|
-

|
|
174
|
-
|
|
175
86
|
```
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
composer draft ──autoSend──▶ model stream ──llm/stream tap(仅活跃语音会话)
|
|
180
|
-
│ text-delta 过滤 → 句子切分
|
|
181
|
-
▼
|
|
182
|
-
browser ◀── SSE /voice-mode/stream ◀── TtsQueue(msedge-tts 逐句合成)
|
|
87
|
+
麦克风(16kHz, AEC) ─▶ 浏览器 VAD 分段 ─▶ HOST zipformer2 流式识别(本地 WASM)
|
|
88
|
+
│
|
|
89
|
+
用户说话 ◀── 打断 ◀── 音箱 ◀── Edge TTS 逐句合成 ◀── 分句(text-delta 过滤)
|
|
183
90
|
```
|
|
184
91
|
|
|
185
|
-
-
|
|
186
|
-
-
|
|
187
|
-
-
|
|
188
|
-
- TTS 队列按会话隔离 + epoch 版本号:打断后旧帧全部作废,真正静音
|
|
92
|
+
- 识别在 **host 端本地运行**(zipformer2 int8 WASM,模型懒下载),音频不上传第三方;
|
|
93
|
+
- 朗读由 **Edge TTS**(微软语音服务,无需 API Key)逐句合成,流式播放;
|
|
94
|
+
- 同一时间仅一个会话处于语音模式(全局单活);LLM 流被无损观察(不阻塞)。
|
|
189
95
|
|
|
190
96
|
## 已知限制
|
|
191
97
|
|
|
192
|
-
- 发声打断依赖浏览器回声消除(`echoCancellation
|
|
98
|
+
- 发声打断依赖浏览器回声消除(`echoCancellation`);扬声器音量过大时可能漏声到麦克风
|
|
193
99
|
- `Ctrl+Shift+V` 会覆盖浏览器「粘贴纯文本」快捷键(普通粘贴仍可用 `Ctrl+V`)
|
|
194
100
|
- 识别模型为简体中文优先;识别质量受环境噪声影响
|
|
195
|
-
-
|
|
196
|
-
-
|
|
197
|
-
- hold
|
|
198
|
-
- hero
|
|
199
|
-
-
|
|
200
|
-
-
|
|
101
|
+
- 浏览器自动播放策略:朗读需要页面已有用户交互(点击麦克风即满足);「试听」依赖 `AbortSignal.timeout`(Safari 16+ / Chrome 103+ / Firefox 100+;老浏览器点击试听会立即提示失败,属预期降级)
|
|
102
|
+
- **唤醒词为轻量实现**(流式文本匹配,非专用 KWS 引擎):嘈杂环境可能延迟或误激活;唤醒词本身不会进入聊天
|
|
103
|
+
- hold 模式按住时切换窗口/标签页会**放弃本段**(防持续收音)
|
|
104
|
+
- hero(新会话空态)无语音入口:请先进入会话使用麦克风按钮
|
|
105
|
+
- `spokenFormat` 提示词经官方 `system-prompt/assemble` 瀑布注入;若当前会话使用**完整提示词**配置(persona `complete: true` 的 agent preset),提示词不注入(官方 complete 契约优先)
|
|
106
|
+
- **苹果 Safari / iOS**:
|
|
107
|
+
- 需 **HTTPS 或 localhost**(iOS/macOS Safari 强制安全上下文;`http://` 局域网 IP 下麦克风不可用)
|
|
108
|
+
- 首次进入需授权麦克风;被拒后到「设置 → Safari → 麦克风」开启(iOS)
|
|
109
|
+
- iOS 后台/锁屏时识别与朗读暂停,回前台自动恢复(可能丢句);建议语音模式期间保持前台
|
|
110
|
+
- **安全说明**:插件 HTTP 面(`/voice-mode/*`)遵循宿主安全模型——请勿将 dsh 端口直接暴露公网;经反向代理发布时由代理层(如 basic auth)鉴权;插件侧对敏感操作保留会话归属校验
|
|
201
111
|
|
|
202
112
|
## 故障排查
|
|
203
113
|
|
|
204
114
|
| 现象 | 处理 |
|
|
205
115
|
| --- | --- |
|
|
206
|
-
|
|
|
207
|
-
|
|
|
208
|
-
|
|
|
209
|
-
|
|
|
210
|
-
|
|
|
211
|
-
| 识别不准 | 靠近麦克风、降低环境噪声;还有回声时把「打断灵敏度」调高一档 |
|
|
212
|
-
| hold 模式按住没反应 | 确认切换到了 hold 模式并处于语音模式中(按钮显示「按住说话」);浏览器窗口需在前台 |
|
|
213
|
-
| 「试听」按钮提示合成失败 | Edge TTS 服务不可达(境外服务)或音色名(ShortName)不存在:核对音色名(`node scripts/list-voices.mjs` 可查全部),稍后重试 |
|
|
116
|
+
| 点麦克风无反应,状态条红字 | 浏览器拒绝麦克风:地址栏(iOS 为 设置 → Safari → 麦克风)开启后重试 |
|
|
117
|
+
| 状态条「正在加载模型… x%」卡住 | 检查网络;模型大(160MB)可先 `npm run prefetch`;国内网络 `modelHost` 配 `https://hf-mirror.com` |
|
|
118
|
+
| 朗读无声音/无字幕 | 查看状态条「朗读连接失败:正在重试…」(Edge TTS 网络问题,自动退避重试);确认页面前台且未静音 |
|
|
119
|
+
| 语音模式进不去 | 检查插件 `enabled`;多标签页时确认当前会话为活动会话 |
|
|
120
|
+
| 识别到但不是我要说的 | 环境噪声或唤醒词误判:降低音量、提高 `interruptLevel`(高门槛)或启用 `wakeWord` |
|
|
214
121
|
|
|
215
122
|
## 开发
|
|
216
123
|
|
|
217
|
-
### 依赖纪律(重要)
|
|
218
|
-
|
|
219
|
-
分三类,各有归属:
|
|
220
|
-
|
|
221
|
-
- **第三方运行依赖**(`msedge-tts` / `sherpa-onnx`)与 **registry 可解析的框架包**
|
|
222
|
-
(`@deepseek-ai/schemastery`)→ `dependencies`。schemastery 是公开 npm 包且
|
|
223
|
-
dsh 宿主平台内部不遮蔽它,装进 profile 不会引发版本冲突。
|
|
224
|
-
- **宿主框架包**(`@deepseek-ai/cordis` / `@deepseek-ai/dsh-web` / `react`)→
|
|
225
|
-
`peerDependencies`。宿主包由 dsh 运行时提供;若进 dependencies 会被 dshmarket
|
|
226
|
-
判定「遮蔽宿主版本」而拦截插件市场升级。peer 版本须与当前 dsh 运行时一致
|
|
227
|
-
(本机:cordis `^4.0.1`、dsh-web `^0.1.0-rc.6 || ^0.1.1-rc.0`、react `^18.2.0`),
|
|
228
|
-
升级 dsh 时同步更新。
|
|
229
|
-
- **仅类型引用**(`@deepseek-ai/dsh-settings` / `dsh-host-webserver` / `dsh-llm` /
|
|
230
|
-
`dsh-system-prompt`)→ 实例之间没有任何运行时 import(`import type` + esbuild 剔除),
|
|
231
|
-
无需声明;开发期类型直接**实体化复制**自本机 dsh 发行版 node_modules(勿用指向宿主
|
|
232
|
-
node_modules 的绝对路径 **symlink**:其内部 `cordis` 解析到宿主实例,与插件 `.pnpm`
|
|
233
|
-
的 cordis 模块身份分裂,`declare module '@deepseek-ai/cordis'` 类型增强全部失效;
|
|
234
|
-
registry 的 rc.1 类型快照落后于发行版,发行版类型才是运行时真值)。
|
|
235
|
-
|
|
236
|
-
`dependencies` 只保留真正的第三方运行依赖,禁止把宿主共享包写进去;改依赖后
|
|
237
|
-
跑 `npm pack --dry-run` 与 `pnpm test` 回归。
|
|
238
|
-
|
|
239
|
-
### 构建与测试
|
|
240
|
-
|
|
241
124
|
```sh
|
|
242
125
|
pnpm install && pnpm build # esbuild:lib/index.js(host)+ lib/client.js(browser)
|
|
243
|
-
pnpm test # segmenter/wakeword 单测 +
|
|
244
|
-
|
|
245
|
-
bash test/spoken-prompt-rpc.sh # 口语化提示词验证(RPC 直发,无需浏览器;需在线 TTS)
|
|
246
|
-
# 注:hold-e2e/spoken-prompt-rpc/spoken-toggle-ui-check 等集成探测脚本位于仓库根 test/
|
|
247
|
-
#(不在 npm 包内);npm 包内 test/ 仅含无需网络的离线单测。
|
|
248
|
-
systemctl restart dsh # Linux;其他平台重启 dsh 进程
|
|
126
|
+
pnpm test # segmenter/wakeword 单测 + 发布前自检(无需网络)
|
|
127
|
+
systemctl restart dsh # 本机加载新 host 代码;其他平台重启 dsh 进程
|
|
249
128
|
```
|
|
250
129
|
|
|
251
|
-
> 注意:dsh 安装的是 pnpm `file:` 链接(目录拷贝),改完 `node build.mjs` 后需把
|
|
252
|
-
> `lib/client.js` 同步到 `<profile>/node_modules/dsh-voice-mode/lib/` 再重启 dsh,
|
|
253
|
-
> 浏览器才能拿到新 bundle。
|
|
254
|
-
|
|
255
|
-
### 结构
|
|
130
|
+
> 注意:dsh 安装的是 pnpm `file:` 链接(目录拷贝),改完 `node build.mjs` 后需把 `lib/client.js` 同步到 `<profile>/node_modules/dsh-voice-mode/lib/` 再刷新页面(`lib/index.js` 与工作区为同一文件自动同步)。集成探测脚本(`test/hold-e2e.js`、`test/spoken-prompt-rpc.sh`、`test/spoken-toggle-ui-check.js`)位于仓库根 `test/`,不在 npm 包内。
|
|
256
131
|
|
|
257
132
|
```
|
|
258
|
-
src/index.ts host:单活指针、llm/stream tap、SSE、settings
|
|
133
|
+
src/index.ts host:单活指针、llm/stream tap、SSE、settings 注册、口语化提示词注入
|
|
259
134
|
src/asr-host.ts host:zipformer2 流式识别 + 模型懒下载(.part 断点续传)
|
|
135
|
+
src/asr.ts client:音频采集、VAD 分段、增量识别、唤醒词
|
|
136
|
+
src/client.tsx client:麦克风按钮 + 状态条 + 字幕浮层 + 打断
|
|
260
137
|
src/tts-queue.ts host:逐会话 TTS 队列 + epoch 打断机制
|
|
261
138
|
src/segmenter.ts host:句子切分(markdown 剥离 + 终止标点)
|
|
262
|
-
src/
|
|
263
|
-
src/asr.ts client:getUserMedia + RMS VAD + partial 轮询
|
|
264
|
-
scripts/prefetch.mjs 模型预下载(跨平台缓存目录 + 断点续传)
|
|
265
|
-
test/segmenter.test.mjs 句子切分单元测试
|
|
266
|
-
test/wakeword.test.mjs 唤醒词匹配单元测试
|
|
267
|
-
test/verify-client.mjs 发布前自检(bundle 清单/导出/形状)
|
|
268
|
-
test/hold-e2e.js hold 模式端到端验收(独立浏览器)
|
|
269
|
-
scripts/list-voices.mjs 打印 Edge TTS 全部音色(音色表来源)
|
|
139
|
+
src/strings.ts client:中英文案字典(navigator.language)
|
|
270
140
|
```
|
|
271
141
|
|
|
272
|
-
|
|
142
|
+
## License
|
|
273
143
|
|
|
274
|
-
|
|
144
|
+
[MIT](LICENSE)
|
|
275
145
|
|
|
276
|
-
|
|
146
|
+
> 部分实现借鉴 [haoku123/dsh-voice](https://github.com/haoku123/dsh-voice)(派生声明见子包 LICENSE)。
|
package/lib/client.js
CHANGED
|
@@ -73,6 +73,15 @@ var INTERRUPT_LEVELS = {
|
|
|
73
73
|
function createAsrEngine(config, sessionId) {
|
|
74
74
|
let state = "idle";
|
|
75
75
|
const stateListeners = /* @__PURE__ */ new Set();
|
|
76
|
+
const errorListeners = /* @__PURE__ */ new Set();
|
|
77
|
+
const emitError = (msg) => {
|
|
78
|
+
for (const fn of errorListeners) {
|
|
79
|
+
try {
|
|
80
|
+
fn(msg);
|
|
81
|
+
} catch {
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
};
|
|
76
85
|
const transcriptListeners = /* @__PURE__ */ new Set();
|
|
77
86
|
const partialListeners = /* @__PURE__ */ new Set();
|
|
78
87
|
const speechStartListeners = /* @__PURE__ */ new Set();
|
|
@@ -108,11 +117,11 @@ function createAsrEngine(config, sessionId) {
|
|
|
108
117
|
}
|
|
109
118
|
};
|
|
110
119
|
const emit = (listeners, text, meta) => {
|
|
111
|
-
const
|
|
112
|
-
if (!
|
|
120
|
+
const t3 = text.trim();
|
|
121
|
+
if (!t3) return;
|
|
113
122
|
for (const fn of listeners) {
|
|
114
123
|
try {
|
|
115
|
-
fn(
|
|
124
|
+
fn(t3, meta);
|
|
116
125
|
} catch {
|
|
117
126
|
}
|
|
118
127
|
}
|
|
@@ -138,7 +147,7 @@ function createAsrEngine(config, sessionId) {
|
|
|
138
147
|
let res = await fetch(asrUrl(false), {
|
|
139
148
|
method: "POST",
|
|
140
149
|
headers: { "content-type": "application/octet-stream" },
|
|
141
|
-
body: samples.
|
|
150
|
+
body: samples.buffer
|
|
142
151
|
});
|
|
143
152
|
if (res.status === 202) {
|
|
144
153
|
setState("loading-model");
|
|
@@ -148,7 +157,7 @@ function createAsrEngine(config, sessionId) {
|
|
|
148
157
|
const r2 = await fetch(asrUrl(false), {
|
|
149
158
|
method: "POST",
|
|
150
159
|
headers: { "content-type": "application/octet-stream" },
|
|
151
|
-
body: samples.
|
|
160
|
+
body: samples.buffer
|
|
152
161
|
});
|
|
153
162
|
resolve(r2);
|
|
154
163
|
} catch {
|
|
@@ -205,7 +214,7 @@ function createAsrEngine(config, sessionId) {
|
|
|
205
214
|
let res = await fetch(asrUrl(true), {
|
|
206
215
|
method: "POST",
|
|
207
216
|
headers: { "content-type": "application/octet-stream" },
|
|
208
|
-
body: samples.
|
|
217
|
+
body: samples.buffer
|
|
209
218
|
});
|
|
210
219
|
if (res.status === 202) {
|
|
211
220
|
setState("loading-model");
|
|
@@ -216,7 +225,7 @@ function createAsrEngine(config, sessionId) {
|
|
|
216
225
|
await fetch(asrUrl(true), {
|
|
217
226
|
method: "POST",
|
|
218
227
|
headers: { "content-type": "application/octet-stream" },
|
|
219
|
-
body: samples.
|
|
228
|
+
body: samples.buffer
|
|
220
229
|
})
|
|
221
230
|
);
|
|
222
231
|
} catch {
|
|
@@ -233,6 +242,7 @@ function createAsrEngine(config, sessionId) {
|
|
|
233
242
|
if (out.text) emit(transcriptListeners, out.text, meta);
|
|
234
243
|
} catch {
|
|
235
244
|
setState(active ? speechActive ? "speech" : "listening" : "idle");
|
|
245
|
+
emitError("recognitionFail");
|
|
236
246
|
}
|
|
237
247
|
})();
|
|
238
248
|
};
|
|
@@ -391,7 +401,7 @@ function createAsrEngine(config, sessionId) {
|
|
|
391
401
|
}
|
|
392
402
|
processor = null;
|
|
393
403
|
try {
|
|
394
|
-
stream?.getTracks().forEach((
|
|
404
|
+
stream?.getTracks().forEach((t3) => t3.stop());
|
|
395
405
|
} catch {
|
|
396
406
|
}
|
|
397
407
|
stream = null;
|
|
@@ -474,6 +484,12 @@ function createAsrEngine(config, sessionId) {
|
|
|
474
484
|
transcriptListeners.delete(fn);
|
|
475
485
|
};
|
|
476
486
|
},
|
|
487
|
+
onError(fn) {
|
|
488
|
+
errorListeners.add(fn);
|
|
489
|
+
return () => {
|
|
490
|
+
errorListeners.delete(fn);
|
|
491
|
+
};
|
|
492
|
+
},
|
|
477
493
|
onPartial(fn) {
|
|
478
494
|
partialListeners.add(fn);
|
|
479
495
|
return () => {
|
|
@@ -502,10 +518,138 @@ function createAsrEngine(config, sessionId) {
|
|
|
502
518
|
};
|
|
503
519
|
}
|
|
504
520
|
|
|
521
|
+
// src/strings.ts
|
|
522
|
+
var zh = {
|
|
523
|
+
stateVoiceMode: "\u8BED\u97F3\u6A21\u5F0F",
|
|
524
|
+
ttsNoticeFail: "\u6717\u8BFB\u8FDE\u63A5\u5931\u8D25\uFF1A\u6B63\u5728\u91CD\u8BD5\u2026",
|
|
525
|
+
enterFail: "\u8FDB\u5165\u8BED\u97F3\u6A21\u5F0F\u5931\u8D25",
|
|
526
|
+
disabled: "\u8BED\u97F3\u6A21\u5F0F\u5DF2\u7981\u7528\uFF08\u63D2\u4EF6 enabled=false\uFF09",
|
|
527
|
+
sendFailKept: "\u53D1\u9001\u5931\u8D25\uFF0C\u5DF2\u4FDD\u7559\u5728\u8349\u7A3F",
|
|
528
|
+
micDenied: "\u9EA6\u514B\u98CE\u88AB\u62D2\u7EDD\uFF1A\u8BF7\u5728\u6D4F\u89C8\u5668\u5730\u5740\u680F\u5141\u8BB8\u9EA6\u514B\u98CE\u6743\u9650",
|
|
529
|
+
micUnavailable: "\u9EA6\u514B\u98CE\u4E0D\u53EF\u7528",
|
|
530
|
+
hold: "\u6309\u4F4F",
|
|
531
|
+
recognizing: "\u8BC6\u522B\u4E2D\u2026",
|
|
532
|
+
holdToTalk: "\u6309\u4F4F\u8BF4\u8BDD",
|
|
533
|
+
voiceDetected: "\u8BED\u97F3\u4E2D",
|
|
534
|
+
entering: "\u8FDB\u5165\u4E2D\u2026",
|
|
535
|
+
voiceBtn: "\u8BED\u97F3",
|
|
536
|
+
ariaActive: "\u8BED\u97F3\u6A21\u5F0F\u8FDB\u884C\u4E2D",
|
|
537
|
+
ariaEnter: "\u8FDB\u5165\u8BED\u97F3\u5BF9\u8BDD\u6A21\u5F0F",
|
|
538
|
+
titleHold: "\u8BED\u97F3\u6A21\u5F0F\u8FDB\u884C\u4E2D \xB7 \u6309\u4F4F\u8BF4\u8BDD\u3001\u677E\u624B\u53D1\u9001\uFF1B\u77ED\u6309\u9000\u51FA\uFF1BEsc/\u5931\u53BB\u7126\u70B9\u653E\u5F03\uFF1BCtrl+Shift+V \u9000\u51FA",
|
|
539
|
+
titleToggle: "\u8BED\u97F3\u6A21\u5F0F\u8FDB\u884C\u4E2D \xB7 \u70B9\u51FB\u9000\u51FA\uFF08Ctrl+Shift+V\uFF09\xB7 \u6309\u4F4F Ctrl \u7ACB\u5373\u53D1\u9001",
|
|
540
|
+
titleEnter: "\u8FDB\u5165\u8BED\u97F3\u5BF9\u8BDD\u6A21\u5F0F\uFF08Ctrl+Shift+V\uFF09",
|
|
541
|
+
loadingModel: "\u6B63\u5728\u52A0\u8F7D\u6A21\u578B\u2026",
|
|
542
|
+
listening: "\u8046\u542C\u4E2D\u2026",
|
|
543
|
+
wakeWord: "\u5524\u9192\u8BCD",
|
|
544
|
+
barHold: "\u8BED\u97F3\u6A21\u5F0F \xB7 \u6309\u4F4F\u8BF4\u8BDD\uFF08\u77ED\u6309\u9000\u51FA\uFF09",
|
|
545
|
+
barListening: "\u8BED\u97F3\u6A21\u5F0F \xB7 \u8046\u542C\u4E2D\u2026",
|
|
546
|
+
reading: "\u6717\u8BFB\u4E2D\u2026",
|
|
547
|
+
recognitionFail: "\u8BC6\u522B\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5",
|
|
548
|
+
modelDownloadFail: "\u8BED\u97F3\u6A21\u578B\u4E0B\u8F7D\u5931\u8D25\uFF08{file}\uFF09\uFF1A\u8BF7\u68C0\u67E5\u7F51\u7EDC\u540E\u91CD\u65B0\u8FDB\u5165\u8BED\u97F3\u6A21\u5F0F\u91CD\u8BD5",
|
|
549
|
+
startFail: "\u8BED\u97F3\u6A21\u5F0F\u542F\u52A8\u5931\u8D25\uFF1A{err}",
|
|
550
|
+
holdDots: "\u6309\u4F4F\u8BF4\u8BDD\u2026",
|
|
551
|
+
sayWake: "\u8BF4\u300C{wake}\u300D\u5F00\u59CB",
|
|
552
|
+
exit: "\u9000\u51FA",
|
|
553
|
+
skip: "\u8DF3\u8FC7",
|
|
554
|
+
configUnavailableNote: "\uFF08\u8BBE\u7F6E\u6587\u6863\u672A\u5C31\u7EEA\uFF0C\u9762\u677F\u5C31\u7EEA\u540E\u4F1A\u81EA\u52A8\u51FA\u73B0\uFF09\u3002",
|
|
555
|
+
// settings-form
|
|
556
|
+
previewNameFirst: "\u8BF7\u5148\u586B\u5199\u97F3\u8272\u540D\uFF08ShortName\uFF09",
|
|
557
|
+
previewDisabled: "\u8BED\u97F3\u6A21\u5F0F\u5DF2\u7981\u7528\uFF08\u63D2\u4EF6 enabled=false\uFF09\uFF0C\u65E0\u6CD5\u8BD5\u542C",
|
|
558
|
+
previewPlayFail: "\u8BD5\u542C\u5931\u8D25\uFF1A\u65E0\u6CD5\u64AD\u653E\u8BE5\u97F3\u8272",
|
|
559
|
+
previewAutoplay: "\u6D4F\u89C8\u5668\u62E6\u622A\u4E86\u81EA\u52A8\u64AD\u653E\uFF0C\u8BF7\u518D\u70B9\u4E00\u6B21\u8BD5\u542C",
|
|
560
|
+
previewCheck: "\u8BD5\u542C\u5931\u8D25\uFF1A\u8BF7\u68C0\u67E5\u7F51\u7EDC\u6216\u97F3\u8272\u540D\uFF08ShortName\uFF09\u662F\u5426\u6B63\u786E",
|
|
561
|
+
previewBtnTitle: "\u8BD5\u542C\u5F53\u524D\u97F3\u8272\uFF08\u5F53\u524D\u8BED\u901F\uFF09",
|
|
562
|
+
synthesizing: "\u5408\u6210\u4E2D\u2026",
|
|
563
|
+
preview: "\u8BD5\u542C",
|
|
564
|
+
custom: "\u81EA\u5B9A\u4E49",
|
|
565
|
+
// settings rows
|
|
566
|
+
descVoice: "Edge TTS \u97F3\u8272\uFF08\u4E0B\u62C9\u5E38\u7528\uFF0C\u5176\u4F59\u9009\u300C\u81EA\u5B9A\u4E49\u300D\u624B\u52A8\u586B ShortName\uFF09",
|
|
567
|
+
descRate: "\u6717\u8BFB\u8BED\u901F\u500D\u7387\uFF080.5 \u6162\u901F \uFF5E 2.0 \u5FEB\u901F\uFF0C1.0 \u6B63\u5E38\uFF09",
|
|
568
|
+
descInterrupt: "\u53D1\u58F0\u6253\u65AD\u7075\u654F\u5EA6\uFF080 \u9AD8\u95E8\u69DB / 1 \u4E2D / 2 \u4F4E\uFF09",
|
|
569
|
+
sev0: "0 \u9AD8\u95E8\u69DB",
|
|
570
|
+
sev1: "1 \u4E2D",
|
|
571
|
+
sev2: "2 \u4F4E",
|
|
572
|
+
descSilence: "\u8BF4\u5B8C\u6574\u4E00\u53E5\u7684\u9759\u97F3\u505C\u987F\u6BEB\u79D2\u6570\uFF08\u9ED8\u8BA4 2000 = 2 \u79D2\uFF09",
|
|
573
|
+
descIdle: "\u65E0\u6D3B\u52A8\u81EA\u52A8\u9000\u51FA\u8BED\u97F3\u6A21\u5F0F\u7684\u5206\u949F\u6570\uFF08\u9ED8\u8BA4 10\uFF09",
|
|
574
|
+
descModelHost: "ASR \u6A21\u578B\u4E0B\u8F7D\u6E90\uFF08\u5B98\u65B9\u6E90 / \u56FD\u5185\u955C\u50CF\uFF0C\u6216\u9009\u300C\u81EA\u5B9A\u4E49\u300D\u586B\u4EFB\u610F\u955C\u50CF\uFF09",
|
|
575
|
+
descAutoSend: "\u8BC6\u522B\u5B9A\u7A3F\u540E\u81EA\u52A8\u53D1\u9001\uFF08\u5173=\u53EA\u8FDB\u8349\u7A3F\uFF1B\u6309\u4F4F Ctrl / hold \u677E\u624B\u4ECD\u53D1\u9001\uFF09",
|
|
576
|
+
descSpokenFormat: "\u8BED\u97F3\u4F1A\u8BDD\u6CE8\u5165\u53E3\u8BED\u5316\u63D0\u793A\u8BCD\uFF08\u56DE\u590D\u53E3\u8BED\u5316\u3001\u4E0D\u7528 Markdown \u6392\u7248\u7B26\u53F7\uFF0C\u6717\u8BFB\u66F4\u987A\uFF1B\u9ED8\u8BA4\u5173\uFF0C\u6539\u52A8\u5373\u65F6\u751F\u6548\uFF09",
|
|
577
|
+
descMode: "\u4EA4\u4E92\u6A21\u5F0F\uFF08toggle \u6301\u7EED\u8046\u542C+\u9759\u97F3\u65AD\u53E5 / hold \u6309\u4F4F\u8BF4\u8BDD\uFF09",
|
|
578
|
+
modeToggle: "\u6301\u7EED\u8046\u542C",
|
|
579
|
+
modeHold: "\u6309\u4F4F\u8BF4\u8BDD",
|
|
580
|
+
descWakeWord: "\u5524\u9192\u8BCD\uFF08\u9ED8\u8BA4\u5173\uFF1B\u5982\u300C\u4F60\u597D\u5C0FD\u300D\uFF0C\u8BF4\u51FA\u540E\u5F00\u59CB\u8BC6\u522B\uFF09",
|
|
581
|
+
wakePlaceholder: "\u5982\uFF1A\u4F60\u597D\u5C0FD",
|
|
582
|
+
settingsCardDesc: "\u97F3\u8272 / \u8BED\u901F / \u6253\u65AD\u7075\u654F\u5EA6 / \u9759\u97F3\u505C\u987F / \u7A7A\u95F2\u8D85\u65F6 / \u6A21\u578B\u955C\u50CF / \u81EA\u52A8\u53D1\u9001 / \u4EA4\u4E92\u6A21\u5F0F / \u5524\u9192\u8BCD / \u53E3\u8BED\u5316\u63D0\u793A\u8BCD",
|
|
583
|
+
configUnavailable: "\u914D\u7F6E\u6682\u4E0D\u53EF\u7528"
|
|
584
|
+
};
|
|
585
|
+
var en = {
|
|
586
|
+
stateVoiceMode: "Voice Mode",
|
|
587
|
+
ttsNoticeFail: "Read-aloud connection lost: retrying\u2026",
|
|
588
|
+
enterFail: "Failed to enter voice mode",
|
|
589
|
+
disabled: "Voice mode disabled (plugin enabled=false)",
|
|
590
|
+
sendFailKept: "Send failed; text kept in draft",
|
|
591
|
+
micDenied: "Microphone denied: allow mic access for this site",
|
|
592
|
+
micUnavailable: "Microphone unavailable",
|
|
593
|
+
hold: "Hold",
|
|
594
|
+
recognizing: "Recognizing\u2026",
|
|
595
|
+
holdToTalk: "Hold to talk",
|
|
596
|
+
voiceDetected: "Voice active",
|
|
597
|
+
entering: "Entering\u2026",
|
|
598
|
+
voiceBtn: "Voice",
|
|
599
|
+
ariaActive: "Voice mode active",
|
|
600
|
+
ariaEnter: "Enter voice mode",
|
|
601
|
+
titleHold: "Voice mode \xB7 hold to talk, release to send; tap to exit; Esc/blur cancels; Ctrl+Shift+V exits",
|
|
602
|
+
titleToggle: "Voice mode \xB7 click to exit (Ctrl+Shift+V) \xB7 hold Ctrl to send now",
|
|
603
|
+
titleEnter: "Enter voice mode (Ctrl+Shift+V)",
|
|
604
|
+
loadingModel: "Loading model\u2026",
|
|
605
|
+
listening: "Listening\u2026",
|
|
606
|
+
wakeWord: "Wake word",
|
|
607
|
+
barHold: "Voice mode \xB7 hold to talk (tap to exit)",
|
|
608
|
+
barListening: "Voice mode \xB7 listening\u2026",
|
|
609
|
+
reading: "Reading\u2026",
|
|
610
|
+
recognitionFail: "Recognition failed, try again",
|
|
611
|
+
modelDownloadFail: "Model download failed ({file}): check network and re-enter voice mode",
|
|
612
|
+
startFail: "Voice mode failed to start: {err}",
|
|
613
|
+
holdDots: "Hold to talk\u2026",
|
|
614
|
+
sayWake: 'Say "{wake}" to start',
|
|
615
|
+
exit: "Exit",
|
|
616
|
+
skip: "Skip",
|
|
617
|
+
configUnavailableNote: " (settings document not ready; the panel will appear when it is).",
|
|
618
|
+
previewNameFirst: "Enter a voice ShortName first",
|
|
619
|
+
previewDisabled: "Voice mode disabled; preview unavailable",
|
|
620
|
+
previewPlayFail: "Preview failed: cannot play this voice",
|
|
621
|
+
previewAutoplay: "Autoplay blocked \u2014 click preview again",
|
|
622
|
+
previewCheck: "Preview failed: check network or ShortName",
|
|
623
|
+
previewBtnTitle: "Preview voice (current rate)",
|
|
624
|
+
synthesizing: "Synthesizing\u2026",
|
|
625
|
+
preview: "Preview",
|
|
626
|
+
custom: "Custom",
|
|
627
|
+
descVoice: "Edge TTS voice (presets, or a custom ShortName)",
|
|
628
|
+
descRate: "Speech rate (0.5 slow \u2013 2.0 fast, 1.0 normal)",
|
|
629
|
+
descInterrupt: "Interrupt sensitivity (0 high barrier / 2 low)",
|
|
630
|
+
sev0: "0 high",
|
|
631
|
+
sev1: "1 medium",
|
|
632
|
+
sev2: "2 low",
|
|
633
|
+
descSilence: "Silence pause before a sentence is committed (default 2000 ms)",
|
|
634
|
+
descIdle: "Auto-exit voice mode after idle minutes (default 10)",
|
|
635
|
+
descModelHost: "ASR model download source (official source / mirror, or any custom URL)",
|
|
636
|
+
descAutoSend: "Auto-send after finalized recognition (off = draft only; Ctrl / hold still sends)",
|
|
637
|
+
descSpokenFormat: "Inject spoken-format prompt into voice replies (colloquial, no Markdown; default off, live)",
|
|
638
|
+
descMode: "Interaction mode (toggle: continuous listen + auto-send / hold: press to talk)",
|
|
639
|
+
modeToggle: "Continue listen",
|
|
640
|
+
modeHold: "Hold to talk",
|
|
641
|
+
descWakeWord: "Wake word (default off; e.g. Hey D)",
|
|
642
|
+
wakePlaceholder: "e.g. Hey D",
|
|
643
|
+
settingsCardDesc: "Voice / rate / interrupt / silence / idle / model host / auto-send / mode / wake word / spoken format",
|
|
644
|
+
configUnavailable: "Configuration unavailable"
|
|
645
|
+
};
|
|
646
|
+
var lang = typeof navigator !== "undefined" && /^zh\b/i.test(navigator.language ?? "") ? "zh" : "en";
|
|
647
|
+
var t = (key) => lang === "zh" ? zh[key] : en[key] ?? zh[key];
|
|
648
|
+
|
|
505
649
|
// src/settings-form.tsx
|
|
506
650
|
var import_react = require("react");
|
|
507
651
|
var import_jsx_runtime = require("react/jsx-runtime");
|
|
508
|
-
var
|
|
652
|
+
var t2 = {
|
|
509
653
|
bg: "var(--dsw-alias-bg-layer-3)",
|
|
510
654
|
bgOpen: "var(--dsw-alias-bg-layer-2)",
|
|
511
655
|
border: "var(--dsw-alias-border-l2)",
|
|
@@ -515,8 +659,8 @@ var t = {
|
|
|
515
659
|
};
|
|
516
660
|
var BASE_PATH = "/voice-mode";
|
|
517
661
|
var cardStyle = {
|
|
518
|
-
border: `1px solid ${
|
|
519
|
-
background:
|
|
662
|
+
border: `1px solid ${t2.border}`,
|
|
663
|
+
background: t2.bg,
|
|
520
664
|
borderRadius: 12,
|
|
521
665
|
overflow: "hidden"
|
|
522
666
|
};
|
|
@@ -536,18 +680,18 @@ var setHeader = {
|
|
|
536
680
|
display: "flex"
|
|
537
681
|
};
|
|
538
682
|
var setHeadText = { flexDirection: "column", flex: 1, gap: 4, minWidth: 0, display: "flex" };
|
|
539
|
-
var setName = { color:
|
|
540
|
-
var setDesc = { color:
|
|
541
|
-
var setChevron = { color:
|
|
542
|
-
var setBody = { borderTop: `1px solid ${
|
|
683
|
+
var setName = { color: t2.label, fontSize: 15, fontWeight: 600, lineHeight: 1.4 };
|
|
684
|
+
var setDesc = { color: t2.term, fontSize: 13, lineHeight: 1.5 };
|
|
685
|
+
var setChevron = { color: t2.term, flex: "none", transition: "transform .16s", display: "inline-flex" };
|
|
686
|
+
var setBody = { borderTop: `1px solid ${t2.border}`, margin: "0 16px", paddingBottom: 8 };
|
|
543
687
|
var setRow = { alignItems: "center", gap: 12, padding: "12px 0", display: "flex" };
|
|
544
688
|
var setLabelBox = { flexDirection: "column", flex: 1, gap: 3, minWidth: 0, display: "flex" };
|
|
545
689
|
var setLabel = { fontSize: 13, lineHeight: "20px" };
|
|
546
|
-
var setHint = { color:
|
|
547
|
-
var setSeg = { border: `1px solid ${
|
|
690
|
+
var setHint = { color: t2.term, fontSize: 12, lineHeight: "18px" };
|
|
691
|
+
var setSeg = { border: `1px solid ${t2.border}`, borderRadius: 8, flexShrink: 0, gap: 2, padding: 2, display: "inline-flex" };
|
|
548
692
|
var setSegBtn = (on) => ({
|
|
549
693
|
font: "inherit",
|
|
550
|
-
color: on ?
|
|
694
|
+
color: on ? t2.label : "var(--dsw-alias-label-secondary)",
|
|
551
695
|
cursor: "pointer",
|
|
552
696
|
background: on ? "var(--dsw-alias-bg-layer-2)" : "transparent",
|
|
553
697
|
border: "none",
|
|
@@ -563,9 +707,9 @@ var inputStyle = {
|
|
|
563
707
|
maxWidth: "100%",
|
|
564
708
|
padding: "7px 10px",
|
|
565
709
|
borderRadius: 8,
|
|
566
|
-
border: `1px solid ${
|
|
710
|
+
border: `1px solid ${t2.border}`,
|
|
567
711
|
background: "var(--dsw-alias-bg-layer-2)",
|
|
568
|
-
color:
|
|
712
|
+
color: t2.label,
|
|
569
713
|
fontSize: 13,
|
|
570
714
|
fontFamily: "inherit",
|
|
571
715
|
outline: "none"
|
|
@@ -700,7 +844,10 @@ function SelectField({
|
|
|
700
844
|
},
|
|
701
845
|
children: [
|
|
702
846
|
options.map((o) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: o.v, children: o.label }, o.v)),
|
|
703
|
-
/* @__PURE__ */ (0, import_jsx_runtime.
|
|
847
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("option", { value: "__custom__", children: [
|
|
848
|
+
t("custom"),
|
|
849
|
+
"\u2026"
|
|
850
|
+
] })
|
|
704
851
|
]
|
|
705
852
|
}
|
|
706
853
|
),
|
|
@@ -728,7 +875,7 @@ function VoicePreviewButton({ voice, rate }) {
|
|
|
728
875
|
if (busy) return;
|
|
729
876
|
const v = voice.trim();
|
|
730
877
|
if (!v) {
|
|
731
|
-
setNote("
|
|
878
|
+
setNote(t("previewNameFirst"));
|
|
732
879
|
return;
|
|
733
880
|
}
|
|
734
881
|
setBusy(true);
|
|
@@ -749,7 +896,7 @@ function VoicePreviewButton({ voice, rate }) {
|
|
|
749
896
|
signal: AbortSignal.timeout(15e3)
|
|
750
897
|
});
|
|
751
898
|
if (res.status === 403) {
|
|
752
|
-
setNote("
|
|
899
|
+
setNote(t("previewDisabled"));
|
|
753
900
|
return;
|
|
754
901
|
}
|
|
755
902
|
if (!res.ok) throw new Error(`preview http ${res.status}`);
|
|
@@ -759,18 +906,18 @@ function VoicePreviewButton({ voice, rate }) {
|
|
|
759
906
|
audio.onended = () => URL.revokeObjectURL(url);
|
|
760
907
|
audio.onerror = () => {
|
|
761
908
|
URL.revokeObjectURL(url);
|
|
762
|
-
setNote("
|
|
909
|
+
setNote(t("previewPlayFail"));
|
|
763
910
|
};
|
|
764
911
|
try {
|
|
765
912
|
await audio.play();
|
|
766
913
|
} catch (e) {
|
|
767
914
|
URL.revokeObjectURL(url);
|
|
768
915
|
setNote(
|
|
769
|
-
e instanceof DOMException && e.name === "NotAllowedError" ? "
|
|
916
|
+
e instanceof DOMException && e.name === "NotAllowedError" ? t("previewAutoplay") : t("previewPlayFail")
|
|
770
917
|
);
|
|
771
918
|
}
|
|
772
919
|
} catch {
|
|
773
|
-
setNote("
|
|
920
|
+
setNote(t("previewCheck"));
|
|
774
921
|
} finally {
|
|
775
922
|
setBusy(false);
|
|
776
923
|
}
|
|
@@ -783,18 +930,18 @@ function VoicePreviewButton({ voice, rate }) {
|
|
|
783
930
|
gap: 5,
|
|
784
931
|
alignSelf: "flex-start",
|
|
785
932
|
cursor: busy ? "default" : "pointer",
|
|
786
|
-
color:
|
|
933
|
+
color: t2.label,
|
|
787
934
|
background: "var(--dsw-alias-bg-layer-2)",
|
|
788
|
-
border: `1px solid ${
|
|
935
|
+
border: `1px solid ${t2.border}`,
|
|
789
936
|
borderRadius: 6,
|
|
790
937
|
padding: "4px 10px",
|
|
791
938
|
fontSize: 12,
|
|
792
939
|
lineHeight: "18px"
|
|
793
940
|
};
|
|
794
941
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: { display: "flex", flexDirection: "column", gap: 4, alignItems: "flex-start" }, children: [
|
|
795
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("button", { type: "button", onClick: play, disabled: busy, style: btnStyle, title: "
|
|
942
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("button", { type: "button", onClick: play, disabled: busy, style: btnStyle, title: t("previewBtnTitle"), children: [
|
|
796
943
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", { viewBox: "0 0 16 16", width: 11, height: 11, "aria-hidden": "true", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { fill: "currentColor", d: "M4 3l9 5-9 5z" }) }),
|
|
797
|
-
busy ? "
|
|
944
|
+
busy ? t("synthesizing") : t("preview")
|
|
798
945
|
] }),
|
|
799
946
|
note && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { color: "var(--dsw-alias-state-error-primary)", fontSize: 12, lineHeight: "18px" }, children: note })
|
|
800
947
|
] });
|
|
@@ -828,22 +975,22 @@ function VoiceSettingsCard({ scope }) {
|
|
|
828
975
|
const value = snap?.value ?? {};
|
|
829
976
|
const unavailable = snap?.status === "unavailable" || snap?.status === "error";
|
|
830
977
|
if (unavailable) {
|
|
831
|
-
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { "data-dshvm-settings": "card", style: { color:
|
|
832
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { color: "var(--dsw-alias-state-error-primary)" }, children: "
|
|
833
|
-
"
|
|
978
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { "data-dshvm-settings": "card", style: { color: t2.term, fontSize: 12, padding: "14px 16px", ...cardStyle }, children: [
|
|
979
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { color: "var(--dsw-alias-state-error-primary)" }, children: t("configUnavailable") }),
|
|
980
|
+
t("configUnavailableNote")
|
|
834
981
|
] });
|
|
835
982
|
}
|
|
836
983
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { "data-dshvm-settings": "card", style: cardStyle, children: [
|
|
837
984
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("style", { children: focusVisibleCss }),
|
|
838
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("button", { type: "button", "aria-expanded": !collapsed, onClick: () => setCollapsed((c) => !c), style: { ...setHeader, background: collapsed ? "transparent" :
|
|
985
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("button", { type: "button", "aria-expanded": !collapsed, onClick: () => setCollapsed((c) => !c), style: { ...setHeader, background: collapsed ? "transparent" : t2.bgOpen }, children: [
|
|
839
986
|
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: setHeadText, children: [
|
|
840
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: setName, children: "
|
|
841
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: setDesc, children: "
|
|
987
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: setName, children: t("stateVoiceMode") }),
|
|
988
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: setDesc, children: t("settingsCardDesc") })
|
|
842
989
|
] }),
|
|
843
990
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { ...setChevron, transform: collapsed ? "rotate(0deg)" : "rotate(180deg)" }, "aria-hidden": "true", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", { viewBox: "0 0 16 16", width: 14, height: 14, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { fill: "currentColor", d: "M4 6l4 4 4-4z" }) }) })
|
|
844
991
|
] }),
|
|
845
992
|
!collapsed && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: setBody, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { marginTop: 4 }, children: [
|
|
846
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "voice", desc: "
|
|
993
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "voice", desc: t("descVoice"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
847
994
|
SelectField,
|
|
848
995
|
{
|
|
849
996
|
score: scope,
|
|
@@ -854,44 +1001,45 @@ function VoiceSettingsCard({ scope }) {
|
|
|
854
1001
|
footer: (v) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(VoicePreviewButton, { voice: v, rate: Number(value.rate ?? 1) })
|
|
855
1002
|
}
|
|
856
1003
|
) }),
|
|
857
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "rate", desc: "
|
|
858
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "interruptLevel", desc: "
|
|
1004
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "rate", desc: t("descRate"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NumberField, { score: scope, field: "rate", value: value.rate ?? 1, min: 0.5, max: 2, step: 0.1 }) }),
|
|
1005
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "interruptLevel", desc: t("descInterrupt"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
859
1006
|
SegGroup,
|
|
860
1007
|
{
|
|
861
1008
|
score: scope,
|
|
862
1009
|
field: "interruptLevel",
|
|
863
1010
|
value: value.interruptLevel,
|
|
864
1011
|
options: [
|
|
865
|
-
{ v: 0, label: "
|
|
866
|
-
{ v: 1, label: "
|
|
867
|
-
{ v: 2, label: "
|
|
1012
|
+
{ v: 0, label: t("sev0") },
|
|
1013
|
+
{ v: 1, label: t("sev1") },
|
|
1014
|
+
{ v: 2, label: t("sev2") }
|
|
868
1015
|
]
|
|
869
1016
|
}
|
|
870
1017
|
) }),
|
|
871
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "silenceMs", desc: "
|
|
872
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "idleTimeoutMinutes", desc: "
|
|
873
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "modelHost", desc: "
|
|
874
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "autoSend", desc: "
|
|
875
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "spokenFormat", desc: "
|
|
876
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "mode", desc: "
|
|
1018
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "silenceMs", desc: t("descSilence"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NumberField, { score: scope, field: "silenceMs", value: value.silenceMs ?? 2e3, min: 500, max: 3e4, step: 100 }) }),
|
|
1019
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "idleTimeoutMinutes", desc: t("descIdle"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NumberField, { score: scope, field: "idleTimeoutMinutes", value: value.idleTimeoutMinutes ?? 10, min: 1, max: 120, step: 1 }) }),
|
|
1020
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "modelHost", desc: t("descModelHost"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SelectField, { score: scope, field: "modelHost", value: value.modelHost ?? "", options: HOST_OPTIONS, placeholder: "https://..." }) }),
|
|
1021
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "autoSend", desc: t("descAutoSend"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { type: "checkbox", checked: Boolean(value.autoSend), onChange: (e) => void scope.set("autoSend", e.target.checked) }) }),
|
|
1022
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "spokenFormat", desc: t("descSpokenFormat"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { type: "checkbox", checked: Boolean(value.spokenFormat), onChange: (e) => void scope.set("spokenFormat", e.target.checked) }) }),
|
|
1023
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "mode", desc: t("descMode"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
877
1024
|
SegGroup,
|
|
878
1025
|
{
|
|
879
1026
|
score: scope,
|
|
880
1027
|
field: "mode",
|
|
881
1028
|
value: value.mode,
|
|
882
1029
|
options: [
|
|
883
|
-
{ v: "toggle", label: "
|
|
884
|
-
{ v: "hold", label: "
|
|
1030
|
+
{ v: "toggle", label: t("modeToggle") },
|
|
1031
|
+
{ v: "hold", label: t("modeHold") }
|
|
885
1032
|
]
|
|
886
1033
|
}
|
|
887
1034
|
) }),
|
|
888
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "wakeWord", desc: "
|
|
1035
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "wakeWord", desc: t("descWakeWord"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TextField, { score: scope, field: "wakeWord", value: value.wakeWord ?? "", placeholder: t("wakePlaceholder") }) })
|
|
889
1036
|
] }) })
|
|
890
1037
|
] });
|
|
891
1038
|
}
|
|
892
1039
|
|
|
893
1040
|
// src/client.tsx
|
|
894
1041
|
var import_jsx_runtime2 = require("react/jsx-runtime");
|
|
1042
|
+
var beepCtx = null;
|
|
895
1043
|
var inject = ["slots", "sessions", "settingsScope"];
|
|
896
1044
|
var WAVE_BARS = 14;
|
|
897
1045
|
var BASE_PATH2 = "/voice-mode";
|
|
@@ -941,7 +1089,7 @@ function apply(ctx) {
|
|
|
941
1089
|
name: "settings.plugin.item",
|
|
942
1090
|
key: "voice-mode",
|
|
943
1091
|
order: 100,
|
|
944
|
-
label: "
|
|
1092
|
+
label: t("stateVoiceMode")
|
|
945
1093
|
},
|
|
946
1094
|
() => React.createElement(VoiceSettingsCard, { scope: ctx.settingsScope.bind({ namespace: "voice-mode" }) })
|
|
947
1095
|
)
|
|
@@ -973,10 +1121,12 @@ function createAudioEngine(setUi) {
|
|
|
973
1121
|
setUi({ playing: true, playingCaption: frame.text, ttsNotice: null });
|
|
974
1122
|
void audio.play().catch(() => playNext());
|
|
975
1123
|
};
|
|
976
|
-
let beepCtx = null;
|
|
977
1124
|
const toolBeep = () => {
|
|
978
1125
|
try {
|
|
979
|
-
if (!beepCtx)
|
|
1126
|
+
if (!beepCtx) {
|
|
1127
|
+
beepCtx = new AudioContext();
|
|
1128
|
+
void beepCtx.resume?.();
|
|
1129
|
+
}
|
|
980
1130
|
const osc = beepCtx.createOscillator();
|
|
981
1131
|
const gain = beepCtx.createGain();
|
|
982
1132
|
osc.frequency.value = 880;
|
|
@@ -1100,7 +1250,7 @@ function createVoiceBus(basePath = BASE_PATH2, ctx) {
|
|
|
1100
1250
|
source.addEventListener("asr-error", (e) => {
|
|
1101
1251
|
try {
|
|
1102
1252
|
const p = JSON.parse(e.data);
|
|
1103
|
-
ui.error =
|
|
1253
|
+
ui.error = t("modelDownloadFail").replace("{file}", p.file ?? "");
|
|
1104
1254
|
ui.model = null;
|
|
1105
1255
|
notify();
|
|
1106
1256
|
} catch {
|
|
@@ -1110,7 +1260,7 @@ function createVoiceBus(basePath = BASE_PATH2, ctx) {
|
|
|
1110
1260
|
try {
|
|
1111
1261
|
const p = JSON.parse(e.data);
|
|
1112
1262
|
if (p.sessionId === activeSessionId) {
|
|
1113
|
-
ui.ttsNotice = "
|
|
1263
|
+
ui.ttsNotice = t("ttsNoticeFail");
|
|
1114
1264
|
notify();
|
|
1115
1265
|
}
|
|
1116
1266
|
} catch {
|
|
@@ -1148,10 +1298,10 @@ function createVoiceBus(basePath = BASE_PATH2, ctx) {
|
|
|
1148
1298
|
const out = await res.json();
|
|
1149
1299
|
activeSessionId = out.active ?? null;
|
|
1150
1300
|
notify();
|
|
1151
|
-
if (!res.ok) return { ok: false, error: out.error ?? "
|
|
1152
|
-
return { ok: out.active === sessionId, error: out.active === sessionId ? void 0 : "
|
|
1301
|
+
if (!res.ok) return { ok: false, error: out.error ?? t("enterFail") };
|
|
1302
|
+
return { ok: out.active === sessionId, error: out.active === sessionId ? void 0 : t("enterFail") };
|
|
1153
1303
|
} catch {
|
|
1154
|
-
return { ok: false, error: "
|
|
1304
|
+
return { ok: false, error: t("enterFail") };
|
|
1155
1305
|
}
|
|
1156
1306
|
},
|
|
1157
1307
|
async exit(sessionId) {
|
|
@@ -1226,7 +1376,7 @@ function MicButton({
|
|
|
1226
1376
|
const [, bumpUi] = (0, import_react2.useState)(0);
|
|
1227
1377
|
(0, import_react2.useEffect)(
|
|
1228
1378
|
() => bus.subscribe(() => {
|
|
1229
|
-
bumpUi((
|
|
1379
|
+
bumpUi((t3) => t3 + 1);
|
|
1230
1380
|
}),
|
|
1231
1381
|
[bus]
|
|
1232
1382
|
);
|
|
@@ -1308,7 +1458,7 @@ function MicButton({
|
|
|
1308
1458
|
if (!entered.ok) {
|
|
1309
1459
|
setLocalMode("off");
|
|
1310
1460
|
bus.setUi({
|
|
1311
|
-
error: entered.error === "voice mode disabled" ? "
|
|
1461
|
+
error: entered.error === "voice mode disabled" ? t("disabled") : entered.error ?? t("enterFail")
|
|
1312
1462
|
});
|
|
1313
1463
|
return;
|
|
1314
1464
|
}
|
|
@@ -1319,10 +1469,18 @@ function MicButton({
|
|
|
1319
1469
|
const engine = createAsrEngine({ silenceMs, interruptLevel, basePath, wakeWord: cfg.wakeWord }, sid);
|
|
1320
1470
|
bus.setUi({ mode: cfg.mode, wakeWord: cfg.wakeWord });
|
|
1321
1471
|
engineRef.current = engine;
|
|
1472
|
+
try {
|
|
1473
|
+
if (!beepCtx) beepCtx = new AudioContext();
|
|
1474
|
+
void beepCtx.resume?.();
|
|
1475
|
+
} catch {
|
|
1476
|
+
}
|
|
1322
1477
|
engine.onState((s) => {
|
|
1323
1478
|
bus.setUi({ state: s });
|
|
1324
1479
|
if (s === "idle") resetIdle();
|
|
1325
1480
|
});
|
|
1481
|
+
engine.onError((key) => {
|
|
1482
|
+
bus.setUi({ error: t(key) });
|
|
1483
|
+
});
|
|
1326
1484
|
engine.onLevel((l) => {
|
|
1327
1485
|
const cur = bus.ui.levels;
|
|
1328
1486
|
const next = cur.length < WAVE_BARS ? [...cur, l] : [...cur.slice(1), l];
|
|
@@ -1353,11 +1511,11 @@ function MicButton({
|
|
|
1353
1511
|
const r = actions?.submit?.();
|
|
1354
1512
|
if (r && typeof r.then === "function") {
|
|
1355
1513
|
r.catch(() => {
|
|
1356
|
-
bus.setUi({ error: "
|
|
1514
|
+
bus.setUi({ error: t("sendFailKept") });
|
|
1357
1515
|
});
|
|
1358
1516
|
}
|
|
1359
1517
|
} catch {
|
|
1360
|
-
bus.setUi({ error: "
|
|
1518
|
+
bus.setUi({ error: t("sendFailKept") });
|
|
1361
1519
|
}
|
|
1362
1520
|
};
|
|
1363
1521
|
cancelPendingSubmit();
|
|
@@ -1389,7 +1547,7 @@ function MicButton({
|
|
|
1389
1547
|
resetIdle();
|
|
1390
1548
|
} catch (e) {
|
|
1391
1549
|
setLocalMode("off");
|
|
1392
|
-
const msg = e instanceof DOMException ? e.name === "NotAllowedError" ? "
|
|
1550
|
+
const msg = e instanceof DOMException ? e.name === "NotAllowedError" ? t("micDenied") : t("micUnavailable") : t("startFail").replace("{err}", String(e instanceof Error ? e.message : e));
|
|
1393
1551
|
bus.setUi({ error: msg });
|
|
1394
1552
|
const sid2 = sidRef.current;
|
|
1395
1553
|
if (sid2) void bus.exit(sid2);
|
|
@@ -1480,8 +1638,8 @@ function MicButton({
|
|
|
1480
1638
|
}, []);
|
|
1481
1639
|
(0, import_react2.useEffect)(() => {
|
|
1482
1640
|
const onInput = (e) => {
|
|
1483
|
-
const
|
|
1484
|
-
if (!(
|
|
1641
|
+
const t3 = e.target;
|
|
1642
|
+
if (!(t3 instanceof HTMLTextAreaElement)) return;
|
|
1485
1643
|
if (localRef.current !== "on") return;
|
|
1486
1644
|
void exitModeRef.current("typing");
|
|
1487
1645
|
};
|
|
@@ -1530,7 +1688,7 @@ function MicButton({
|
|
|
1530
1688
|
const livePhase = useInput ? useInput((s) => s?.phase ?? "") : "";
|
|
1531
1689
|
const phaseRef = (0, import_react2.useRef)("");
|
|
1532
1690
|
phaseRef.current = livePhase;
|
|
1533
|
-
const label = on ? busy ? "
|
|
1691
|
+
const label = on ? busy ? t("recognizing") : holdMode ? t("holdToTalk") : t("voiceDetected") : local === "pending" ? t("entering") : t("voiceBtn");
|
|
1534
1692
|
const holdPtrRef = (0, import_react2.useRef)(null);
|
|
1535
1693
|
const onPointerDown = (e) => {
|
|
1536
1694
|
if (bootNow().mode !== "hold") return;
|
|
@@ -1581,9 +1739,9 @@ function MicButton({
|
|
|
1581
1739
|
onPointerUp,
|
|
1582
1740
|
onPointerCancel,
|
|
1583
1741
|
"data-dshvm": "mic",
|
|
1584
|
-
"aria-label": on ? "
|
|
1742
|
+
"aria-label": on ? t("ariaActive") : t("ariaEnter"),
|
|
1585
1743
|
"aria-pressed": on,
|
|
1586
|
-
title: on ? holdMode ? "
|
|
1744
|
+
title: on ? holdMode ? t("titleHold") : t("titleToggle") : t("titleEnter"),
|
|
1587
1745
|
style: {
|
|
1588
1746
|
border: "none",
|
|
1589
1747
|
background: on ? holdMode ? "rgba(88, 166, 255, 0.16)" : "rgba(63, 185, 80, 0.16)" : local === "pending" ? "rgba(88, 166, 255, 0.14)" : "transparent",
|
|
@@ -1630,7 +1788,7 @@ function VoiceStatusBar({ bus, sessionId }) {
|
|
|
1630
1788
|
}, [bus]);
|
|
1631
1789
|
const isActive = b.active === sessionId;
|
|
1632
1790
|
if (!isActive) return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_jsx_runtime2.Fragment, {});
|
|
1633
|
-
const stateText = b.ui.state === "loading-model" ? "
|
|
1791
|
+
const stateText = b.ui.state === "loading-model" ? t("loadingModel") : b.ui.state === "transcribing" ? t("recognizing") : b.ui.state === "speech" ? b.ui.mode === "hold" ? t("holdDots") : t("listening") : b.ui.state === "wake" ? t("sayWake").replace("{wake}", b.ui.wakeWord || t("wakeWord")) : b.ui.mode === "hold" ? t("barHold") : t("barListening");
|
|
1634
1792
|
const bars = Array.from({ length: WAVE_BARS }, (_, i) => b.ui.levels[i] ?? 0);
|
|
1635
1793
|
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
|
|
1636
1794
|
"div",
|
|
@@ -1661,7 +1819,7 @@ function VoiceStatusBar({ bus, sessionId }) {
|
|
|
1661
1819
|
},
|
|
1662
1820
|
i
|
|
1663
1821
|
)) }),
|
|
1664
|
-
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flexGrow: 1 }, children: b.ui.error ? b.ui.error : b.ui.state === "loading-model" || b.ui.model ? b.ui.model ?
|
|
1822
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flexGrow: 1 }, children: b.ui.error ? b.ui.error : b.ui.state === "loading-model" || b.ui.model ? b.ui.model ? `${t("loadingModel")} ${b.ui.model.file} ${b.ui.model.percent}%` : stateText : b.ui.partial ? b.ui.partial : b.ui.ttsNotice ? b.ui.ttsNotice : stateText }),
|
|
1665
1823
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
1666
1824
|
"button",
|
|
1667
1825
|
{
|
|
@@ -1676,7 +1834,7 @@ function VoiceStatusBar({ bus, sessionId }) {
|
|
|
1676
1834
|
fontSize: 12,
|
|
1677
1835
|
flexShrink: 0
|
|
1678
1836
|
},
|
|
1679
|
-
children: "
|
|
1837
|
+
children: t("exit")
|
|
1680
1838
|
}
|
|
1681
1839
|
)
|
|
1682
1840
|
]
|
|
@@ -1731,7 +1889,7 @@ function VoiceOverlay({ bus }) {
|
|
|
1731
1889
|
},
|
|
1732
1890
|
i
|
|
1733
1891
|
)) }),
|
|
1734
|
-
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: b.ui.playingCaption ?? "
|
|
1892
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: b.ui.playingCaption ?? t("reading") }, b.ui.playingCaption ?? "idle"),
|
|
1735
1893
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
1736
1894
|
"button",
|
|
1737
1895
|
{
|
|
@@ -1746,7 +1904,7 @@ function VoiceOverlay({ bus }) {
|
|
|
1746
1904
|
cursor: "pointer",
|
|
1747
1905
|
flexShrink: 0
|
|
1748
1906
|
},
|
|
1749
|
-
children: "
|
|
1907
|
+
children: t("skip")
|
|
1750
1908
|
}
|
|
1751
1909
|
)
|
|
1752
1910
|
]
|
package/lib/index.js
CHANGED
|
@@ -342,7 +342,7 @@ var TtsQueue = class {
|
|
|
342
342
|
enqueue(sessionId, text) {
|
|
343
343
|
let q = this.queues.get(sessionId);
|
|
344
344
|
if (!q) {
|
|
345
|
-
q = { pending: [], busy: false, seq: 0, epoch: 0, errorNotified: false };
|
|
345
|
+
q = { pending: [], busy: false, seq: 0, epoch: 0, errorNotified: false, backoff: 0 };
|
|
346
346
|
this.queues.set(sessionId, q);
|
|
347
347
|
}
|
|
348
348
|
q.pending.push({ text, epoch: q.epoch });
|
|
@@ -404,7 +404,12 @@ var TtsQueue = class {
|
|
|
404
404
|
}
|
|
405
405
|
} finally {
|
|
406
406
|
q.busy = false;
|
|
407
|
-
if (q.pending.length > 0)
|
|
407
|
+
if (q.pending.length > 0) {
|
|
408
|
+
const delay = q.errorNotified ? q.backoff : 0;
|
|
409
|
+
q.backoff = Math.min(8e3, delay + 1e3);
|
|
410
|
+
if (delay > 0) setTimeout(() => void this.pump(sessionId, q), delay);
|
|
411
|
+
else void this.pump(sessionId, q);
|
|
412
|
+
}
|
|
408
413
|
}
|
|
409
414
|
}
|
|
410
415
|
async close() {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-voice-mode",
|
|
3
3
|
"description": "Full-duplex voice mode for DeepSeek Harness: zipformer2 streaming ASR → editable draft, Edge TTS sentence-by-sentence read-aloud with live captions, true barge-in — on-device ASR, no API key. · DSH 语音双工对话:流式识别入草稿、按句朗读+实时字幕、开口即打断,识别本地推理、无需 API Key",
|
|
4
|
-
"version": "0.1
|
|
4
|
+
"version": "0.2.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"repository": {
|