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 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
- For release and awesome-dsh-plugin submission flow, see `BEST_PRACTICES.md` and `docs/publish/` in the repo root.
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
  [![npm version](https://img.shields.io/npm/v/dsh-voice-mode?style=flat-square)](https://www.npmjs.com/package/dsh-voice-mode)
4
- [![License](https://img.shields.io/github/license/qishuilalala/dsh-voice-mode?style=flat-square)](https://github.com/qishuilalala/dsh-voice-mode/blob/main/plugin/dsh-voice-mode/LICENSE)
4
+ [![License](https://img.shields.io/github/license/qishuilalala/dsh-voice-mode?style=flat-square)](LICENSE)
5
5
  [![dsh-plugin](https://img.shields.io/badge/dsh--plugin-voice-brightgreen?style=flat-square)](https://github.com/topics/dsh-plugin)
6
6
 
7
- > Full-duplex voice conversation mode for DeepSeek Harness (dsh): speak, get a
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 语音双工对话模式:会话内一键进入 边说边出字的流式识别 停顿自动发送 最终答复按句流式朗读 + 实时字幕,开口即可打断(barge-in)。无需 API Key,模型在本地宿主端推理。
13
-
14
- [English](./README.en.md)
15
-
16
- ![demo](https://raw.githubusercontent.com/qishuilalala/dsh-voice-mode/HEAD/plugin/dsh-voice-mode/assets/demo.gif)
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
  ![语音模式:实时字幕与状态条](https://raw.githubusercontent.com/qishuilalala/dsh-voice-mode/HEAD/assets/screenshot-voice.png)
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` 后,进入语音模式处于待机态,说出唤醒词才开始识别(如「你好小D」),避免误触
28
- - **输出链路**:只朗读最终答复的 `text-delta`(reasoning/工具调用不读),按句流式 Edge TTS 朗读 + 右下角实时字幕浮层;工具调用触发提示音;全文照常写入聊天记录;可选的口语化提示词(设置 `spokenFormat`,默认关)让语音会话回复为自然短句、不带 Markdown 排版符号,朗读侧再做一轮标记剥离,听感更顺
29
- - **开口打断(barge-in)**:三档灵敏度的发声前沿检测 → 本地静音 + host 合成队列作废(epoch)+ 正在运行的回合取消(保留半截并自然续入你的新消息)
30
- - **模型懒加载与进度**:首次使用自动下载 zipformer2 中文流式模型(约 160MB,`.part` 断点续传),状态条实时显示下载进度;可用 `npm run prefetch` 预下载
31
- - **容错**:麦克风被拒红点提示、模型下载失败可见提示、TTS 连接失败状态条提示(自动重试)、提交失败文字留在草稿、SSE 断线自动重连
32
- - **设置**:设置 → Plugins → 插件配置 → 语音模式(voice-mode),可调音色 / 语速 / 打断灵敏度 / 静音停顿 / 空闲超时 / 模型镜像 / 自动发送 / 交互模式 / 唤醒词 / 口语化提示词;**音色可试听**(「试听」按钮按当前音色 + 当前语速即时合成预览,无需进入语音模式;自定义 ShortName 同样可试听)
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
- **bundle 插件需重启 dsh 生效**(不同平台的重启方式):
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 音色(见下方常用音色表),**即时生效**;行内「试听」按钮按当前音色 + 当前语速合成预览(下拉常用音色与「自定义」ShortName 均可试听,失败有可见提示) |
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` 修改后**立即生效**(TTS 热切换 / 提示词组装时实时读取);其余设置下次进入语音模式时生效。设置项默认值由插件配置(`base` 层)提供——未显式修改时跟随配置。
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
- | `zh-TW-YunJheNeural` | 云哲 · 台湾腔 · 男声 |
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
- > 说明:`voice/rate/interruptLevel/silenceMs/idleTimeoutMinutes/modelHost/autoSend`
146
- > 的最终生效值以**设置面板**为准;bundle 配置仅为这些键提供默认播种值
147
- > (`enabled/cacheDir` 仍只由 bundle 配置控制)。
148
- > 插件 HTTP 命名空间固定为 `/voice-mode`(与客户端 bundle 契约一致,不可配置)。
80
+ ### 配置(bundle config / settings.yaml)
149
81
 
150
- ## API
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
- ![architecture](https://raw.githubusercontent.com/qishuilalala/dsh-voice-mode/HEAD/plugin/dsh-voice-mode/assets/architecture.svg)
174
-
175
86
  ```
176
- input: mic ──RMS VAD(2s 静音切句)──▶ POST /voice-mode/asr(f32 PCM,16k,增量解码)
177
- zipformer2 流式识别(宿主端 WASM)
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
- - 语音与朗读只发生在全局单活指针 `activeVoiceSession` 指定的会话;普通会话 `llm/stream` 直达、零开销(模式隔离)
186
- - `llm/stream` tap 无损:每个 chunk 原样透传,切句/合成只旁观,不阻塞模型流
187
- - zipformer2 在宿主端推理(sherpa-onnx Node WASM),浏览器只负责采集(`getUserMedia` 16k 单声道)与端点检测
188
- - TTS 队列按会话隔离 + epoch 版本号:打断后旧帧全部作废,真正静音
92
+ - 识别在 **host 端本地运行**(zipformer2 int8 WASM,模型懒下载),音频不上传第三方;
93
+ - 朗读由 **Edge TTS**(微软语音服务,无需 API Key)逐句合成,流式播放;
94
+ - 同一时间仅一个会话处于语音模式(全局单活);LLM 流被无损观察(不阻塞)。
189
95
 
190
96
  ## 已知限制
191
97
 
192
- - 发声打断依赖浏览器回声消除(`echoCancellation`);扬声器音量过大时可能漏声到麦克风(JS 层无法做 AEC)
98
+ - 发声打断依赖浏览器回声消除(`echoCancellation`);扬声器音量过大时可能漏声到麦克风
193
99
  - `Ctrl+Shift+V` 会覆盖浏览器「粘贴纯文本」快捷键(普通粘贴仍可用 `Ctrl+V`)
194
100
  - 识别模型为简体中文优先;识别质量受环境噪声影响
195
- - 浏览器自动播放策略:朗读需要页面已有用户交互(点击麦克风即满足);若浏览器拦截播放且状态条无提示,请确认网页处于前台且非静音状态
196
- - **唤醒词为轻量实现**(基于流式识别文本匹配,非专用 KWS 引擎):嘈杂环境可能延迟或误激活;唤醒词本身不会进入聊天(命中即丢弃缓冲)
197
- - hold 模式按住时如果切换窗口/标签页会**放弃本段**(防持续收音),回来需重新按住
198
- - hero(新会话空态)没有语音入口:语音模式是会话级功能,请先进入会话使用输入框麦克风按钮
199
- - 「试听」的请求超时兜底使用 `AbortSignal.timeout`(Chrome 103+ / Firefox 100+ / Safari 16+);更老的浏览器点击试听会立即显示失败提示,属预期降级
200
- - `spokenFormat` 提示词经官方 `system-prompt/assemble` 瀑布注入;若当前会话使用**完整提示词**配置(persona `complete: true` 的 agent preset),其提示词会整体替换系统提示词(官方 complete 契约),此时口语化提示词不注入
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
- | 状态条显示「正在加载模型… x%」卡住 | 检查网络;模型大(160MB)可先 `npm run prefetch`;国内网络把 `modelHost` 配成 `https://hf-mirror.com` |
208
- | 状态条显示「语音模型下载失败」 | 两镜像均不可达:检查网络/代理后重新进入语音模式(断点续传) |
209
- | 有字幕(浮层)但听不到声音 | 检查系统音量/输出设备;浏览器自动播放被拦时点击页面任意处后再试 |
210
- | 状态条显示「朗读连接失败:正在重试…」 | Edge TTS 服务不可达(境外服务),稍后自动重试;持续失败请检查网络/代理 |
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
- node test/hold-e2e.js # hold 模式验收(独立浏览器,/asr 路由拦截)
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/client.tsx client:麦克风按钮 + 状态条 + 朗读浮层 + 打断
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
- 发布与精选列表提交流程见仓库根 `BEST_PRACTICES.md` 与 `docs/publish/`。
142
+ ## License
273
143
 
274
- ## 许可
144
+ [MIT](LICENSE)
275
145
 
276
- MIT
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 t2 = text.trim();
112
- if (!t2) return;
120
+ const t3 = text.trim();
121
+ if (!t3) return;
113
122
  for (const fn of listeners) {
114
123
  try {
115
- fn(t2, meta);
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.slice().buffer
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.slice().buffer
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.slice().buffer
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.slice().buffer
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((t2) => t2.stop());
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 t = {
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 ${t.border}`,
519
- background: t.bg,
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: t.label, fontSize: 15, fontWeight: 600, lineHeight: 1.4 };
540
- var setDesc = { color: t.term, fontSize: 13, lineHeight: 1.5 };
541
- var setChevron = { color: t.term, flex: "none", transition: "transform .16s", display: "inline-flex" };
542
- var setBody = { borderTop: `1px solid ${t.border}`, margin: "0 16px", paddingBottom: 8 };
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: t.term, fontSize: 12, lineHeight: "18px" };
547
- var setSeg = { border: `1px solid ${t.border}`, borderRadius: 8, flexShrink: 0, gap: 2, padding: 2, display: "inline-flex" };
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 ? t.label : "var(--dsw-alias-label-secondary)",
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 ${t.border}`,
710
+ border: `1px solid ${t2.border}`,
567
711
  background: "var(--dsw-alias-bg-layer-2)",
568
- color: t.label,
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.jsx)("option", { value: "__custom__", children: "\u81EA\u5B9A\u4E49\u2026" })
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("\u8BF7\u5148\u586B\u5199\u97F3\u8272\u540D\uFF08ShortName\uFF09");
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("\u8BED\u97F3\u6A21\u5F0F\u5DF2\u7981\u7528\uFF08\u63D2\u4EF6 enabled=false\uFF09\uFF0C\u65E0\u6CD5\u8BD5\u542C");
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("\u8BD5\u542C\u5931\u8D25\uFF1A\u65E0\u6CD5\u64AD\u653E\u8BE5\u97F3\u8272");
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" ? "\u6D4F\u89C8\u5668\u62E6\u622A\u4E86\u81EA\u52A8\u64AD\u653E\uFF0C\u8BF7\u518D\u70B9\u4E00\u6B21\u8BD5\u542C" : "\u8BD5\u542C\u5931\u8D25\uFF1A\u65E0\u6CD5\u64AD\u653E\u8BE5\u97F3\u8272"
916
+ e instanceof DOMException && e.name === "NotAllowedError" ? t("previewAutoplay") : t("previewPlayFail")
770
917
  );
771
918
  }
772
919
  } catch {
773
- setNote("\u8BD5\u542C\u5931\u8D25\uFF1A\u8BF7\u68C0\u67E5\u7F51\u7EDC\u6216\u97F3\u8272\u540D\uFF08ShortName\uFF09\u662F\u5426\u6B63\u786E");
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: t.label,
933
+ color: t2.label,
787
934
  background: "var(--dsw-alias-bg-layer-2)",
788
- border: `1px solid ${t.border}`,
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: "\u8BD5\u542C\u5F53\u524D\u97F3\u8272\uFF08\u5F53\u524D\u8BED\u901F\uFF09", children: [
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 ? "\u5408\u6210\u4E2D\u2026" : "\u8BD5\u542C"
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: t.term, fontSize: 12, padding: "14px 16px", ...cardStyle }, children: [
832
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { color: "var(--dsw-alias-state-error-primary)" }, children: "\u914D\u7F6E\u6682\u4E0D\u53EF\u7528" }),
833
- "\uFF08\u8BBE\u7F6E\u6587\u6863\u672A\u5C31\u7EEA\uFF0C\u9762\u677F\u5C31\u7EEA\u540E\u4F1A\u81EA\u52A8\u51FA\u73B0\uFF09\u3002"
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" : t.bgOpen }, children: [
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: "\u8BED\u97F3\u6A21\u5F0F" }),
841
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: setDesc, children: "\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" })
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: "Edge TTS \u97F3\u8272\uFF08\u4E0B\u62C9\u5E38\u7528\uFF0C\u5176\u4F59\u9009\u300C\u81EA\u5B9A\u4E49\u300D\u624B\u52A8\u586B ShortName\uFF09", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
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: "\u6717\u8BFB\u8BED\u901F\u500D\u7387\uFF080.5 \u6162\u901F \uFF5E 2.0 \u5FEB\u901F\uFF0C1.0 \u6B63\u5E38\uFF09", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NumberField, { score: scope, field: "rate", value: value.rate ?? 1, min: 0.5, max: 2, step: 0.1 }) }),
858
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "interruptLevel", desc: "\u53D1\u58F0\u6253\u65AD\u7075\u654F\u5EA6\uFF080 \u9AD8\u95E8\u69DB / 1 \u4E2D / 2 \u4F4E\uFF09", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
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: "0 \u9AD8\u95E8\u69DB" },
866
- { v: 1, label: "1 \u4E2D" },
867
- { v: 2, label: "2 \u4F4E" }
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: "\u8BF4\u5B8C\u6574\u4E00\u53E5\u7684\u9759\u97F3\u505C\u987F\u6BEB\u79D2\u6570\uFF08\u9ED8\u8BA4 2000 = 2 \u79D2\uFF09", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NumberField, { score: scope, field: "silenceMs", value: value.silenceMs ?? 2e3, min: 500, max: 3e4, step: 100 }) }),
872
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "idleTimeoutMinutes", desc: "\u65E0\u6D3B\u52A8\u81EA\u52A8\u9000\u51FA\u8BED\u97F3\u6A21\u5F0F\u7684\u5206\u949F\u6570\uFF08\u9ED8\u8BA4 10\uFF09", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NumberField, { score: scope, field: "idleTimeoutMinutes", value: value.idleTimeoutMinutes ?? 10, min: 1, max: 120, step: 1 }) }),
873
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "modelHost", desc: "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", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SelectField, { score: scope, field: "modelHost", value: value.modelHost ?? "", options: HOST_OPTIONS, placeholder: "https://..." }) }),
874
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "autoSend", desc: "\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", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { type: "checkbox", checked: Boolean(value.autoSend), onChange: (e) => void scope.set("autoSend", e.target.checked) }) }),
875
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "spokenFormat", desc: "\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", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { type: "checkbox", checked: Boolean(value.spokenFormat), onChange: (e) => void scope.set("spokenFormat", e.target.checked) }) }),
876
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "mode", desc: "\u4EA4\u4E92\u6A21\u5F0F\uFF08toggle \u6301\u7EED\u8046\u542C+\u9759\u97F3\u65AD\u53E5 / hold \u6309\u4F4F\u8BF4\u8BDD\uFF09", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
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: "\u6301\u7EED\u8046\u542C" },
884
- { v: "hold", label: "\u6309\u4F4F\u8BF4\u8BDD" }
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: "\u5524\u9192\u8BCD\uFF08\u9ED8\u8BA4\u5173\uFF1B\u5982\u300C\u4F60\u597D\u5C0FD\u300D\uFF0C\u8BF4\u51FA\u540E\u5F00\u59CB\u8BC6\u522B\uFF09", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TextField, { score: scope, field: "wakeWord", value: value.wakeWord ?? "", placeholder: "\u5982\uFF1A\u4F60\u597D\u5C0FD" }) })
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: "\u8BED\u97F3\u6A21\u5F0F"
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) beepCtx = new AudioContext();
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 = `\u8BED\u97F3\u6A21\u578B\u4E0B\u8F7D\u5931\u8D25\uFF08${p.file ?? ""}\uFF09\uFF1A\u8BF7\u68C0\u67E5\u7F51\u7EDC\u540E\u91CD\u65B0\u8FDB\u5165\u8BED\u97F3\u6A21\u5F0F\u91CD\u8BD5`;
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 = "\u6717\u8BFB\u8FDE\u63A5\u5931\u8D25\uFF1A\u6B63\u5728\u91CD\u8BD5\u2026";
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 ?? "\u8FDB\u5165\u8BED\u97F3\u6A21\u5F0F\u5931\u8D25" };
1152
- return { ok: out.active === sessionId, error: out.active === sessionId ? void 0 : "\u8FDB\u5165\u8BED\u97F3\u6A21\u5F0F\u5931\u8D25" };
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: "\u8FDB\u5165\u8BED\u97F3\u6A21\u5F0F\u5931\u8D25" };
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((t2) => t2 + 1);
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" ? "\u8BED\u97F3\u6A21\u5F0F\u5DF2\u7981\u7528\uFF08\u63D2\u4EF6 enabled=false\uFF09" : entered.error ?? "\u8FDB\u5165\u8BED\u97F3\u6A21\u5F0F\u5931\u8D25"
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: "\u53D1\u9001\u5931\u8D25\uFF0C\u5DF2\u4FDD\u7559\u5728\u8349\u7A3F" });
1514
+ bus.setUi({ error: t("sendFailKept") });
1357
1515
  });
1358
1516
  }
1359
1517
  } catch {
1360
- bus.setUi({ error: "\u53D1\u9001\u5931\u8D25\uFF0C\u5DF2\u4FDD\u7559\u5728\u8349\u7A3F" });
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" ? "\u9EA6\u514B\u98CE\u88AB\u62D2\u7EDD\uFF1A\u8BF7\u5728\u6D4F\u89C8\u5668\u5730\u5740\u680F\u5141\u8BB8\u9EA6\u514B\u98CE\u6743\u9650" : "\u9EA6\u514B\u98CE\u4E0D\u53EF\u7528" : `\u8BED\u97F3\u6A21\u5F0F\u542F\u52A8\u5931\u8D25\uFF1A${String(e instanceof Error ? e.message : e)}`;
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 t2 = e.target;
1484
- if (!(t2 instanceof HTMLTextAreaElement)) return;
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 ? "\u8BC6\u522B\u4E2D\u2026" : holdMode ? "\u6309\u4F4F\u8BF4\u8BDD" : "\u8BED\u97F3\u4E2D" : local === "pending" ? "\u8FDB\u5165\u4E2D\u2026" : "\u8BED\u97F3";
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 ? "\u8BED\u97F3\u6A21\u5F0F\u8FDB\u884C\u4E2D" : "\u8FDB\u5165\u8BED\u97F3\u5BF9\u8BDD\u6A21\u5F0F",
1742
+ "aria-label": on ? t("ariaActive") : t("ariaEnter"),
1585
1743
  "aria-pressed": on,
1586
- title: on ? holdMode ? "\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" : "\u8BED\u97F3\u6A21\u5F0F\u8FDB\u884C\u4E2D \xB7 \u70B9\u51FB\u9000\u51FA\uFF08Ctrl+Shift+V\uFF09\xB7 \u6309\u4F4F Ctrl \u7ACB\u5373\u53D1\u9001" : "\u8FDB\u5165\u8BED\u97F3\u5BF9\u8BDD\u6A21\u5F0F\uFF08Ctrl+Shift+V\uFF09",
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" ? "\u6B63\u5728\u52A0\u8F7D\u6A21\u578B\u2026" : b.ui.state === "transcribing" ? "\u8BC6\u522B\u4E2D\u2026" : b.ui.state === "speech" ? b.ui.mode === "hold" ? "\u6309\u4F4F\u8BF4\u8BDD\u2026" : "\u8046\u542C\u4E2D\u2026" : b.ui.state === "wake" ? `\u8BF4\u300C${b.ui.wakeWord || "\u5524\u9192\u8BCD"}\u300D\u5F00\u59CB` : b.ui.mode === "hold" ? "\u8BED\u97F3\u6A21\u5F0F \xB7 \u6309\u4F4F\u8BF4\u8BDD\uFF08\u77ED\u6309\u9000\u51FA\uFF09" : "\u8BED\u97F3\u6A21\u5F0F \xB7 \u8046\u542C\u4E2D\u2026";
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 ? `\u6B63\u5728\u52A0\u8F7D\u6A21\u578B\u2026 ${b.ui.model.file} ${b.ui.model.percent}%` : stateText : b.ui.partial ? b.ui.partial : b.ui.ttsNotice ? b.ui.ttsNotice : stateText }),
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: "\u9000\u51FA"
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 ?? "\u6717\u8BFB\u4E2D\u2026" }, b.ui.playingCaption ?? "idle"),
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: "\u8DF3\u8FC7"
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) void this.pump(sessionId, q);
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.5",
4
+ "version": "0.2.1",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "repository": {