dsh-voice-typing
DeepSeek Harness 的语音输入与对话朗读插件。
点一下麦克风按钮(或按 Alt+V)就能进入语音模式:你说话,文字自动打进输入框;
助手的回复可以自动朗读出来。全程本地离线识别,不需要 API Key、断网也能用。
本 README 面向使用者,按「从零到会用」的顺序一步步引导。每一步都告诉你 做什么、为什么、以及做完会看到什么。维护者须知与技术参考见文末。
目录
1. 它是什么
一句话:「说话代替打字,回复自动念给你听。」
它把两件独立的事合在一起,但各自可控:
| 功能 | 做什么 | 什么时候用 |
|---|---|---|
| 语音输入 | 你说话 → 文字自动进输入框 → (可选)自动发送 | 不想打字、手在忙、想快点说 |
| 对话朗读 | 助手的回复自动念出来 | 不想盯着屏幕、听回复更方便 |
关键特性:
- 点一下就开,再点一下就关:麦克风按钮是开关,不是「按住说话」。
- 本地离线识别:默认用 SenseVoice 模型,复用 DSH 已装好的那份,零下载、零密钥、断网可用。
- 边说边出字:说话时文字实时回填到输入框,不用等说完。
- 三种朗读引擎:在线 Edge(音质最好)、本地神经模型(离线、音质接近在线)、系统语音(最快)。
2. 安装(一步完成)
前置条件:你已经装好并运行过 DeepSeek Harness 桌面应用。
第 1 步 — 安装插件。在 DSH 的终端/命令行里执行:
dsh plugin --profile desktop add dsh-voice-typing
第 2 步 — 重启 DSH。装完插件必须重启才会生效。
第 3 步 — 启用组合包。打开 DSH →「设置 → 插件」,把 dsh-voice-typing 加进 dsh.profile.bundles(勾选启用)。
第 4 步 — 确认出现麦克风按钮。重启后,在聊天输入框附近应该能看到一个 麦克风图标。看到它,安装就算成功了。
💡 看不到麦克风按钮? 先确认「第 3 步」的启用组合包有没有勾上,再重启一次。 还不行就跳到故障排查。
3. 第一次使用(跟着走一遍)
下面按顺序做,每一步都说明你会看到什么。
3.1 打开语音模式
- 点一下麦克风按钮,或按全局热键
Alt+V。 - 看到:按钮变「实心/高亮」,旁边状态行出现 「聆听中」。
语音模式是一个开关。再点一下(或右键)就关闭,不是按住说话。
3.2 开始说话
- 对准麦克风说一句完整的话,例如「今天天气怎么样」。
- 看到:你说话时,文字实时出现在输入框里(边说边出字)。
3.3 停顿,自动发送
- 说完停下来。默认静音 1.5 秒 后判定「说完了一句」。
- 看到:文字被发送给助手,助手开始回复。
想改成「说完不自动发,我自己按回车」?在设置里关掉「识别后自动发送」。 见 5.2 语音识别。
3.4 (可选)听回复
- 勾选设置里的「朗读回复」后,助手的回复会自动念出来。
- 朗读时麦克风旁边会出现一个 ■ 停止朗读 按钮,点它就不念了。
- 想打断朗读并自己说话?直接开口说,朗读会自动停,你说的话会发出去。
「朗读回复」不需要开语音模式,只要勾上就生效。
3.5 退出语音模式
- 再点一下麦克风按钮(或按
Alt+V),或右键麦克风按钮。 - 看到:按钮恢复原样,状态行显示「已停止」。
4. 日常操作速查
| 你想做什么 | 怎么操作 |
|---|---|
| 开/关语音模式 | 点一下麦克风按钮,或按 Alt+V |
| 说完一句并发送 | 说一段话后停顿 1.5 秒(默认) |
| 说完立刻退出语音模式 | 设置「交互模式」→ 说完就关 |
| 停止朗读这一轮 | 点 ■ 停止朗读 按钮 |
| 打断朗读、自己说话 | 直接开口说(朗读自动停) |
| 临时不自动发送 | 关掉「识别后自动发送」,说完手动回车 |
| 只朗读、不开语音模式 | 只勾「朗读回复」,不点麦克风 |
麦克风按钮只有两个动作:
| 手势 | 行为 |
|---|---|
| 点一下 | 开 → 进入语音模式并开麦;关 → 只关麦,朗读继续播完 |
| 右键 | 同「点一下」(顺带屏蔽浏览器默认菜单) |
全局热键(Alt+V):按一下切换开/关,行为与点麦克风一致。
热键必须带至少一个修饰键,且主键不能是单个字母(否则打字会误触发)。
5. 设置面板详解(按你的需求调)
打开 DSH →「设置 → 语音输入」(settings.section 槽位)。
所有设置分 6 个可折叠分区(前 5 组默认展开,最后一组「高级与诊断」默认收起):
语音识别 / 语音交互 / 文本处理 / 语音合成 / 模型与下载 / 高级与诊断。
改完记得点 保存。
5.1 先知道这两个总开关
| 开关 | 控制什么 | 默认 |
|---|---|---|
| 语音模式(麦克风按钮) | 是否聆听你的说话 | 关 |
| 朗读回复(设置里) | 是否朗读助手的回复 | 关 |
两者独立。想「只听不说」就只开麦克风;想「只听回复不发言」就只勾朗读回复。
5.2 语音识别
| 设置 | 作用 | 怎么调 |
|---|---|---|
| 识别模型 | 用哪套模型认字 | 见 第 6 节。默认 SenseVoice,零下载。 |
| 识别语言 | 告诉模型你说的是哪种语言(仅 SenseVoice 生效;Whisper/zipformer2 会忽略,UI 会按模型禁用/提示) | 自动(推荐,由模型判断)/ 中文 / 英文 / 粤语 / 日文 / 韩文 |
| 实时字方式 | 说话时实时出字用哪种方式 | 见下方说明。默认「整段重识别」(精度最好)。 |
「实时字方式」三种选法:
| 方式 | 优点 | 缺点 | 何时用 |
|---|---|---|---|
| 整段重识别(默认) | 精度最好,与最终稿同源 | 句子越长越慢 | 多数情况 |
| 流式解码 | 延迟低,长句不变慢 | 精度略低 | 说长句、要快 |
| 自动 | 流式模型用流式,否则整段 | — | 不确定时 |
最终稿(断句后的那次识别)始终用当前模型,不受「实时字方式」影响。 「流式解码」固定依赖 zipformer2-zh(与当前所选识别模型无关);它没下载时 UI 会提示,实际会回退整段重识别。
5.3 语音交互
| 设置 | 作用 | 怎么调 |
|---|---|---|
| 交互模式 | 说完一段之后怎么办 | 连续对话(默认):说完自动接下一轮,麦克风不关;说完就关:说完就退出语音模式。依赖自动发送:关闭自动发送时该选项会被禁用(否则说完不会发送、也不会退出)。 |
| 静音断句(毫秒) | 静音多久算「说完一句」 | 默认 1500。太短会把长句切断;太长回复会慢。 |
| 识别后自动发送 | 说完是否自动发送 | 开(默认):停顿后自动发;关:文字只留在输入框,手动回车发送。 |
| 发送前等待(毫秒) | 说完后再等多久才发 | 默认 1200。用来合并短句、避免抢话。只在「自动发送」开时显示/生效;清空会回退默认。 |
| 语音模式快捷键 | 切换语音模式的全局热键 | 默认 Alt+V。格式如 Alt+V,改完立即生效;非法组合会阻止保存并提示。 |
快捷键注意事项:
- 必须带至少一个修饰键(
Ctrl/Alt/Shift/Meta)。 - 主键是单个字母时,必须搭配
Ctrl/Alt/Meta(不能只配Shift,否则打字会误触发)。 - 别用
Ctrl+Shift+V(浏览器「粘贴为纯文本」)、Ctrl+Space(输入法占用)。
静音断句与发送前等待在面板里并排显示,方便对照调整「说完」与「发送」的节奏。
5.4 文本处理
| 设置 | 作用 | 怎么调 |
|---|---|---|
| 清理语气词 | 去掉「嗯」「啊」「那个」等口头禅 | 勾选即生效(只处理识别结果,不改写说话内容)。 |
| 自定义词表 | 热词与替换规则 | 一行一条;正确=错误 表示把「错误」替换成「正确」,单行表示热词。热词仅 zipformer2(transducer)档位生效;保存时按行 trim、去空、上限 100 字/条、200 条。空格/换行输入不会被逐键吞掉。 |
5.5 语音合成(朗读)
本区顶部会显示「当前合成方式」(引擎 + 音色/模型),改完设置后保存即生效。
| 设置 | 作用 | 怎么调 |
|---|---|---|
| 朗读回复 | 总开关。关掉则下面所有朗读参数都不生效 | 勾选即朗读,不需要开语音模式。朗读关闭时本区仍可编辑/保存(仅保存、不生效),方便先配置。 |
| 朗读追赶最新 | 生成远快于朗读时,允许落后屏幕多少秒;超过就整段跳旧段、保留最新 3 段(从最新第 3 条开始读,不从段落中间开始念) | 默认 7.5 秒(0.5 秒步进);0 = 全部朗读、不跳段(长回复会越读越滞后);>120 按 120 秒生效。只在 flow(客户端 ack)模式生效。 |
| 语音引擎 | 用哪种方式念 | Edge 在线 / 本地神经模型 / 本地系统语音 / GPT-SoVITS 外部服务,见下方对比。引擎值非法时保存会被拒绝(不会静默落在线)。 |
| 朗读音色 | 用哪个发音人 | 在线用 Edge 音色(zh-CN-XiaoxiaoNeural 等);本地用系统语音;神经模型用「说话人」(单说话人模型会自动隐藏该项) |
| GPT-SoVITS 服务地址 | 外部 GPT-SoVITS 服务地址 | 默认 http://127.0.0.1:9880;根地址或 /tts 均可 |
| 服务端参考音频路径 | 官方 api_v2 必填 | GPT-SoVITS 所在机器上可访问的 wav 路径(插件只透传) |
| 参考音频文本/语言、合成语言 | 参考音色与多语种 | 参考文本与音频逐字匹配效果最好;语言 zh/en/ja/ko/yue |
| 朗读语速 | 快慢 | 默认 +10%;+0% 为正常,+50% 更快,-50% 更慢。四种引擎都生效(SoVITS 转 speed_factor 0.5~2) |
| 朗读音调 | 高低 | 只有在线 Edge 支持(如 +0Hz、-5Hz) |
| 朗读音量 | 大小 | 在线/本地支持(如 +0%),神经模型与 SoVITS 不支持 |
| 音频格式 | MP3 码率 | 只有在线 Edge 支持。24kHz/48kbps(推荐)/ 24kHz/96kbps(音质更好) |
四种语音引擎对比:
| 引擎 | 音质 | 联网 | 速度 | 备注 |
|---|---|---|---|---|
| Edge 在线 | ★★★ | 需要 | 快(预热后 ~600ms) | 音质最好,音色选择多 |
| 本地神经模型 | ★★☆ | 离线 | 中(~1.6s) | 比系统语音自然;需下载约 170MB 模型 |
| 本地系统语音 | ★☆ | 离线 | 最快(~800ms) | 机器感较强;Windows SAPI / macOS say / Linux espeak |
| GPT-SoVITS 外部服务 | 取决于你的服务与模型 | 本机 HTTP | 取决于服务端(GPU 快) | 用户自己跑 GPT-SoVITS api_v2;插件只转发文本合成,不内置模型、不做克隆 |
在线引擎合成失败时自动回退到本地,保证「总有声音」;
x-tts-engine-fallback会在 UI 提示一次。 GPT-SoVITS 外部服务不回退(避免把文本发给微软、也避免偷偷换音色),失败见/diagnostics的sovitsTts。配置保存会做校验:非法枚举/类型/范围(如
ttsEngine未知、sovitsUrl非 http(s)、readAloudLag非数字)返回 400 并显示错误,不会被静默改成别的值。
外部 GPT-SoVITS 引擎(可选)
插件不内置 GPT-SoVITS、也不做声音克隆:需要你自己运行官方 api_v2 服务,
把服务端可访问的参考音频/参考文本/语言填到设置里;插件只把每句文本 POST 到 /tts,
拿回 wav 走现有播放管线。
- 在 GPT-SoVITS 目录启动 API 服务:
python api_v2.py -a 127.0.0.1 -p 9880 -c GPT_SoVITS/configs/tts_infer.yaml - 设置 → 语音引擎选「GPT-SoVITS 外部服务」,填:
- 服务地址(默认
http://127.0.0.1:9880,填根地址或/tts都行); - 服务端参考音频路径(官方 api_v2 每个请求都要求它,必须是服务端机器上可访问的 wav);
- 参考音频文本 / 语言(可选;与参考音频逐字匹配时音质更好);
- 合成语言(zh / en / ja / ko / yue)。
- 服务地址(默认
- 模型/音色/参考音频都由 GPT-SoVITS 侧管理,插件不下载模型、不克隆声音。
服务未启动或参考音频路径不对时,
/tts会返回可读错误(含官方api_v2的 message 与提示), 计数进/diagnostics.sovitsTts;插件不会替你拉起 Python 服务。
5.6 模型与下载
| 设置 | 作用 | 怎么调 |
|---|---|---|
| 启用后预加载模型 | 选了模型就后台先加载好 | 开(推荐)。第一次说话就不用干等(Zipformer2 首次要 40 秒)。只覆盖当前识别/神经 TTS 模型,不覆盖实时字用的 zipformer2。 |
| 空闲释放(分钟) | 模型闲置多久后释放内存 | 10 分钟(默认)/ 永不释放 / 5 / 30 / 60 分钟。不释放当前正在使用的模型。 |
| 运行环境自检 | 检查平台、显卡、各引擎可用性 | 点「重新检查」,看绿色 ✓ / 红色 ✗(含四种 TTS 引擎与 GPT-SoVITS 配置项)。 |
模型常驻内存(Zipformer2 约 160MB,神经 TTS 约 170MB)。闲置释放能把内存还给系统。 下方的模型列表负责下载/删除识别与合成模型;已下载的档位才会出现在识别模型下拉里。
5.7 高级与诊断
设置面板最后一组默认收起,只提供排查入口,不含任何配置项:
GET /dsh-voice-typing/diagnostics:朗读调度(lagMs/trimmed/configWarnings等)、各 TTS 引擎失败、模型下载状态、环境自检结果;- 浏览器调试快照
__DSH_VOICE_DEBUG__():客户端播放/队列/回退/高亮等运行时状态。
6. 模型与下载(该选哪个)
6.1 识别模型
| 档位 | 大小 | 中文精度 | 特点 | 推荐场景 |
|---|---|---|---|---|
| SenseVoice(推荐) | 0(内置)/ 230MB | ★★★ | 带标点与数字规范化;零下载 | 大多数人 |
| Zipformer2 中文 | 160MB | ★★ | 流式,延迟最低;追求实时字速度、说长句 | |
| Whisper tiny | 99MB | ★ | 最小 | 先跑通 |
| Whisper base | 153MB | ★★ | 轻量折中 | 中文可用 |
| Whisper small | 358MB | ★★★ | 精度明显优于 base | 均衡 |
| Whisper medium | 902MB | ★★★★ | 更准 | 重精度 |
| Whisper large-v3-turbo | 989MB | ★★★★☆ | 接近 large-v3、体积小一半 | 综合最优 |
| Whisper large-v3 | 1.7GB | ★★★★★ | 精度天花板 | 重精度、不怕慢 |
怎么选:默认 SenseVoice 就行。它零下载、中文强、带标点。 想要实时字更快选 Zipformer2;想要更准选 Whisper 大档(代价是体积和耗时)。
6.2 合成(TTS)模型
| 档位 | 大小 | 特点 |
|---|---|---|
| MeloTTS 中英 | 170MB | 多说话人、本地离线、音质接近在线 |
6.3 怎么下载
第 1 步 — 打开「设置 → 语音输入 → 模型管理」。
第 2 步 — 在「识别模型」或「合成模型」列表里,找到你要的档位,点 下载。
第 3 步 — 看进度。下载时显示百分比;完成后出现 ✓ 和「已就绪」。
第 4 步 — 回到「语音识别」或「语音合成」,把「识别模型 / 神经模型」切到你刚下载的档位。
下载失败? 确认网络能访问模型源(国内走
hf-mirror.com镜像)。 进度卡在 100% 不动通常是校验失败,重试一次或换档位。
7. 自定义词表(修同音字与专名)
词表里两种写法,作用完全不同:
| 写法 | 作用 | 示例 |
|---|---|---|
正确=错误 |
替换:识别出「错误」时改写成「正确」 | 深度求索=深度球搜 |
只写 正确 |
热词提示:不改文本,只给识别器做偏置 | 专业术语 |
怎么用:
- 打开「设置 → 语音输入 → 交互与后处理」。
- 在「自定义词表」里一行一条填写。
- 保存。
注意事项:
- 中文精确匹配;英文忽略大小写并保持原词形态。
- 热词提示只有 Zipformer2(transducer)档位支持。用 SenseVoice 时,只写「正确」的条目不会有任何效果(想要替换效果请用
=写法)。
8. 故障排查
按症状找对策。
8.1 看不到麦克风按钮
- 确认插件已安装:
dsh plugin --profile desktop list里有没有dsh-voice-typing。 - 确认已在「设置 → 插件」的
dsh.profile.bundles里启用。 - 重启 DSH(插件加载需要重启,不是刷新页面)。
8.2 点麦克风没反应 / 显示「麦克风被阻止」
- 检查浏览器/系统是否给了麦克风权限(地址栏权限图标)。
- 确认没有别的程序(会议软件等)占着麦克风。
- 重试;若仍不行,重启 DSH。
8.3 能说话,但文字没出来 / 显示「未听到声音」
- 确认麦克风没静音、音量够大。
- 靠近麦克风,清晰地说一句。
- 若提示「模型加载中…」,等首次加载完成。
- 检查「识别模型」是否已就绪(见 6.3)。
8.4 能识别,但不对 / 同音字错
8.5 朗读没声音
按顺序排查(最常见的是「在线引擎需联网」):
- 确认设置里「朗读回复」已勾选。
- 确认「语音引擎」:
Edge 在线需要联网(断网请换本地引擎)。本地系统语音在当前平台需支持(Windows SAPI / macOS say / Linux espeak-ng)。本地神经模型需要已下载该档位模型(见 6.3)。
- 打开浏览器控制台,输入
__DSH_VOICE_DEBUG__()看ttsFailures。 - 若显示「朗读失败」,看 诊断接口里的
onlineTts/localTts/neuralTts/sovitsTts的lastError。 - 若本地引擎失败但显示「朗读中」却没声,多半是自动回退到在线但没联网。
8.6 朗读不完整 / 句子被切碎 / 把代码念出来
这些都是有意设计的防御,不是 bug:
- 代码块(
```)一个字都不会念 —— 这是刻意的,防止把代码念出来。 - 列表编号不会当句子念 ——
1. 第一步…只念「第一步…」,不念「一」。 - 长句不会被拦腰切断 —— 尽量切在句末标点(
。!?;)。 - 按段落朗读 —— 先按空行 / 列表项 / 标题 / 引用把回复分成段落,段落内再按句末标点分句。一段话从头读到尾才进入下一段,绝不从段落中间开始念。
- 朗读完整、不跳段 —— 宿主按客户端的真实播放进度逐句补发(客户端每开始播一句回一条 ack),生成再快也不会积压、不会丢掉「接下来要播的句子」。
8.7 快捷键不生效
- 确认设置里「语音模式快捷键」的格式正确(如
Alt+V)。 - 别与浏览器/系统的快捷键冲突(
Ctrl+Space、Ctrl+Shift+V等)。 - 确认主键不是单个字母。
9. 技术参考(维护者/进阶)
9.1 宿主接口
全部路由带 /dsh-voice-typing 前缀。
| 路由 | 说明 |
|---|---|
POST /transcribe |
请求体为 16 kHz 单声道 f32 PCM,返回 {text, model, language, hotwords, speech}(最终稿;speech 表示是否检测到人声) |
POST /asr-stream?sessionId=… |
送入增量 PCM,返回当前实时文字(流式 zipformer2;模型不可用时返回 {unavailable:true, reason}) |
POST /asr-stream/reset?sessionId=… |
结束该会话的流式解码并丢弃状态 |
GET /asr-ready |
识别模型是否已下载 + 已加载(客户端开麦前先问,首次加载要 20~40 秒) |
POST /asr-warmup |
确保识别模型真的加载好(没就绪就等到就绪) |
GET /tts?text=… |
文字 → 音频(在线出 MP3,本地系统语音与本地神经模型出 WAV)。在线失败自动回退到本地系统语音,响应头 x-tts-engine-fallback: local 标出 |
GET /voices?engine=online|local |
音色列表(在线 Edge 列表 / 本地系统语音) |
GET /tts/env |
合成运行环境自检(引擎可用性、平台能力) |
GET /tts/warmup |
预热在线 TTS 连接(本地引擎没有连接概念,直接返回) |
GET /latest-message?sessionId=… |
本轮 assistant 回复全文 |
GET /busy |
助手是否正在生成。降级用 —— 首选是 SSE 的 assistant-busy 事件(零延迟),SSE 断了才回退到轮询 |
GET /diagnostics |
诊断数据(统计、忙状态、各引擎合成失败计数、下载进度、朗读调度器状态 readAloud[]:mode/outstanding/acks/emitted/stalls/lagMs/playingMs/backlogMs/budgetMs/streaming/skim/trimmed/trimmedParagraphs/trimmedSentences/truncatedParagraphs/awaitingFlush,以及 readAloudScheduler 的 rejectedAcks/lastRejectedAck)。不在 /config 里 —— 混进去会被客户端当配置回传 |
POST /voice-session |
客户端心跳上报(诊断用) |
GET/POST /config |
读写生效配置 |
GET /models |
模型档位清单与本地状态(识别 + 合成) |
POST /model |
下载/准备指定档位 |
GET /stream |
SSE:turn-begin / turn-end / call-end / sentence / assistant-busy / model-progress / model-ready / model-error |
POST /reading-ack |
客户端朗读进度回报(流控)。请求体 {sessionId, turnId, count, durationMs?}(durationMs = 这一句真实音频时长,仅 count=1 时用,宿主据此精确计「正在播放的剩余时长」):每开始播放一句回一条,宿主随即补发下一句。收不到 ack 时宿主自动降级为旧的 400ms 时间窗口节奏(老客户端可用) |
9.2 SSE 事件信封
所有 SSE 事件的 data 都是同一个信封,业务字段平铺在里面:
event: sentence
data: {"v":3,"seq":12,"ts":1735000000000,"sessionId":"s1","text":"你好","turnId":"turn-x","index":0,"para":0,"paraStart":true}
| 字段 | 含义 |
|---|---|
v |
协议版本(当前 3;v3 之后只做只增不改的字段扩展,同包同版本一起加载) |
seq |
全局单调递增。跳号 = 丢了事件 |
ts |
事件产生时刻(epoch 毫秒) |
sessionId |
归属会话;null 表示全局事件(如模型下载进度) |
call-end只表示「这一次llm/stream调用结束」,不表示该次调用的句子已经全部发出:flow(流控)模式下剩余句子仍会按客户端 ack 逐句补发,所以call-end之后仍可能出现sentence。 真正的「一轮结束」是turn-end(下一轮开始时广播上一轮)。
sentence 事件另有业务字段:text(要念的句子)、turnId(属于哪一轮)、
index(本轮第几句,客户端据此去重)、para(自然段序号,一轮内从 0 递增)、
paraStart(是否该段第一个发出的片段 —— 客户端永不丢弃它)。
客户端据此可以:按 sessionId 过滤(多会话不串音)、按 seq 发现丢事件、
按 turnId 精确到某一轮做静音。
9.3 识别链路
浏览器采集 → AudioContext 原始 float32 → 线性重采样到 16 kHz → 上传宿主 → 工作线程里用 sherpa-onnx 识别(SenseVoice / Whisper / Zipformer2,按设置选择)→ Silero VAD 切段(灵敏度 0.5 / 最短语音 0.25 s / 静音断句 0.5 s / 单段最长 20 s, 音点前置补偿 0.3 s)→ 自定义词表与语气词后处理 → 写入草稿并发送。
识别跑在独立工作线程(lib/asr-worker.js)里,主线程不会被 0.5~3 秒的同步推理卡住。
本地神经合成同理,放在 lib/tts-worker.js 里跑。
⚠️ Zipformer2 是流式模型,必须用
OnlineRecognizer+isReady/inputFinished的解码方式。 用OfflineRecognizer配 transducer 会让 sherpa 的原生代码 直接终止进程(整个桌面应用退出)。
9.4 诊断接口
| 接口 | 用途 |
|---|---|
GET /dsh-voice-typing/config |
当前配置(诊断/统计走 /diagnostics) |
GET /dsh-voice-typing/voices?engine=online|local |
音色列表 |
GET /dsh-voice-typing/tts?text=… |
合成测试 |
GET /dsh-voice-typing/tts/warmup |
预热连接 |
GET /dsh-voice-typing/busy |
助手是否在生成 |
GET /dsh-voice-typing/stream |
SSE:sentence / turn-begin / turn-end / call-end / assistant-busy / model-* 事件 |
浏览器控制台里 __DSH_VOICE_DEBUG__() 能看到 ttsFailures、playSkipped 等运行时状态。
9.5 配置文件位置
底层配置落盘在 ~/.dsh/voice-typing.json,设置面板是它的编辑器(单一数据源)。
9.6 代码结构
| 文件 | 职责 |
|---|---|
lib/index.js |
宿主侧:HTTP 路由、配置读写、模型管理、朗读调度 |
lib/client.js |
浏览器侧:麦克风按钮、VAD 断句、草稿管理、播放队列、设置面板 |
lib/asr-worker.js |
识别工作线程(SenseVoice / Whisper / Zipformer2) |
lib/tts-worker.js |
本地神经合成工作线程(sherpa-onnx VITS) |
lib/edge-tts.js |
Edge 在线朗读(共享连接 + 串行锁) |
lib/local-tts.js |
跨平台系统语音(Windows SAPI / macOS say / Linux espeak-ng) |
lib/neural-tts.js |
本地神经 TTS 的宿主侧封装(线程管理、预加载、释放) |
lib/sovits-tts.js |
外部 GPT-SoVITS 引擎客户端:把每句文本 POST 到用户自己的 api_v2 服务;不内置模型、不做克隆、失败不回退 |
lib/model-catalog.js |
模型档位定义、并行分片下载、缓存、SHA256 校验 |
lib/text-rules.js |
词表解析、替换、语气词清理、输出规范化 |
lib/read-aloud-policy.js |
朗读发射规则(纯函数):时间窗口、单句上限、splitLongText(切成完整句子,绝不断成半句)、packWindowSentences(一批取几句,不合并成长文本) |
lib/paragraph.js |
自然段落切分(纯函数):空行 / 列表项 / 标题 / 引用为段落边界,splitParagraphs 支持流式累积(末尾未完成段进 rest) |
lib/speech-clean.js |
文本清洗与分句(纯函数):stripForSpeech(Markdown → 纯文本 + SSML 转义)、splitSentenceParts |
lib/read-aloud-scheduler.js |
朗读调度器:按自然段落收集、按客户端 ack 的播放进度发句(流控),无 ack 时降级为时间窗口;事件带 para / paraStart |
lib/fence-filter.js |
流式代码围栏过滤器(纯函数):跨 chunk 识别 ``` / ~~~ 围栏(围栏标记本身可能被 chunk 边界切开),围栏内内容一个字都不交给朗读 |
lib/tts-env.js |
运行环境自检 |
src/client/pure/*.js |
浏览器侧的纯函数源码(门限状态机、草稿合并、事件信封、定时器登记表)。不依赖浏览器 API,测试可直接原生 import |
scripts/bundle-client.mjs |
把 src/client/pure/ 内联进 lib/client.js(浏览器模块的 require 只能解析包名,不能 import 本地相对路径,所以纯函数只能有一份源码) |
9.7 维护者须知
这一节记录这个插件反复出问题的地方,以及对应的硬规则。 每一条都对应一个真实修过的 bug,改动这块代码前请先读一遍。
作用域:三个地方拿不到东西
宿主侧有三层作用域,引用错了会抛 ReferenceError, 而表现常常是「某个功能安静地不工作」,不报错。
| 位置 | 能拿到 | 拿不到 |
|---|---|---|
| 模块级函数 | 只有模块级变量 | settings、scope、t |
ctx.on('llm/stream', ...)(注册在 inject 之外) |
模块级变量 | scope、settings |
ctx.inject([...], cb) 回调内 |
全部 | — |
规则
llm/stream钩子里要用配置,走模块级的currentSettings,不要写settings。- 翻译函数
t不能在模块级函数里用;模块级label: () => ...只能写死文案(写() => t('x')会让设置面板白屏)。
组件必须在模块级定义
Label / Section / Toggle 这类小组件如果定义在组件体内,
每次渲染它们都是新的函数 → React 认为组件类型变了 → 卸载并重建 →
设置面板里的输入框每敲一个字符就失焦。
规则
- 任何被
React.createElement当作类型用的组件,都定义在模块级。 - 组件体内只放「每次渲染都该新建」的东西(style 对象、回调)。
引用稳定性:effect 的依赖必须稳定
useCallback 写成裸箭头函数时,每次渲染都是新引用,
依赖它的 effect 会反复重跑:连接刚建立就被关掉、无限重渲染、
点一下按钮就滚到顶部。
规则
- 所有传给
useEffect依赖数组的值都要useCallback包住,且依赖完整 (漏依赖 = 读到过期值)。 - 翻译函数
t必须useCallback包装。
静默失败:空 catch 会掩盖故障
catch {} 到处都是。改动时至少要区分「可忽略」和「要上报」。
规则
- 面向用户的失败必须有可见反馈(提示条 / 状态文字),不能只写日志。
catch {}里至少留一行注释说明为什么可以忽略。
状态复位:关掉什么就要清什么
规则
- 任何「停止」类动作,都要把该动作涉及的所有状态一次清干净: 定时器、队列、标志位、草稿基准。
- 定时器必须跟着状态一起清。漏清的后果是「关了麦草稿还自己发出去」。
- 清状态的职责要单一:
stopListening(聆听侧)、stopPlayback(朗读侧)、stopAll(两者,仅卸载时用)。一个动作不要隐式包含多个不相关职责。
共享状态:过期快照不能写回
异步请求拿到的数据是发起请求那一刻的快照。
规则
- 拿到配置后不要无条件写回共享存储(
configStore.set(next))。 用户在这期间改过设置的话,旧快照会把他的修改覆盖掉。 - 共享存储的权威来源只有一个(设置面板:加载时和保存时)。
- 读配置时要合并,不要整份取舍:
configRef = { ...宿主返回的, ...共享存储 }—— 存储里有值的键以存储为准(它是用户刚保存的),其余用宿主这份补齐。 - 拉配置要能重试:只拉一次的话,宿主刚启动时那一次失败就再也补不回来。
回合边界:不要用启发式
「这是不是新的一轮对话」曾经用「会话里的用户消息条数变化」判断 —— 但本轮用户消息写入 session 的时机和 LLM 开始输出不一致。
规则
- 判定新回合必须容忍「消息延迟写入」:同一回合内多次
llm/stream(工具调用链)不能重复触发打断。 - 用
llm/stream请求里的options.messages数用户消息条数, 不要读 session 里的。只认「条数增加」为新回合。 - 这套回合判定抽在独立模块
lib/turn-model.js(createTurnModel工厂)。 - 每一轮对话有唯一的 id(
turnId,递增序号),句子里带着它。 ⚠️ 轮次 id 不能用「本轮最后一条 user 消息的内容锚点」生成。 - 回合事件只有
turn-begin/turn-end两个,不要再加别名事件。 turn-end(协议 v3)表示真正的一轮结束;call-end表示一次llm/stream调用结束。- 回合状态表不会无限增长:用空闲超时兜底 ——
turnModel.prune()每 60 秒 清掉超过 10 分钟没活动的会话的回合状态。
外部 GPT-SoVITS:只做转发,不内置、不克隆、不回退
GPT-SoVITS 是 PyTorch 推理体系(官方只提供 api_v2.py HTTP 服务),没有能在 Electron 里直接跑的 ONNX 管线。
所以插件把它当成外部 TTS 引擎:
- 插件不管理模型/参考音频、不做声音克隆,只按官方
/tts契约转发text/text_lang/prompt_lang(以及用户配置的ref_audio_path/prompt_text),拿回 wav; - 失败绝不回退 Edge/系统语音:用户选它就是要自己的声音与隐私,不能偷偷把文本发给微软、也不能换音色;
- 宿主超时(
SOVITS_TIMEOUT_MS=20s)必须小于客户端单句超时(25s),否则客户端先放弃、错误只剩「没声音」; - 失败计入
/diagnostics.sovitsTts(failures/lastError/lastAt)。
回归:test/sovits-tts.test.mjs(契约/URL 规范化/失败/超时/不回退 + 宿主接线 + 构建产物 UI)。
朗读失败不静默(可观测性)
「朗读没声」最怕无从排查。现在三种情况都留痕:
- 宿主:
/diagnostics里localTts/onlineTts/neuralTts各带failures/lastError/lastAt。 - 客户端:
synth失败时ttsFailuresRef计数 + 节流(>4 秒)弹一次短词 「朗读失败」,完整说明走悬停。 - 调试:浏览器控制台
__DSH_VOICE_DEBUG__()能看到ttsFailures。
朗读无声:共享连接坏掉后必须丢弃(最坑的一条)
在线(Edge)合成复用同一个 WebSocket 实例,换连接单句要贵 4~5 秒。 复用本身没错,错在什么时候该丢掉它。真实故障是「合成结束但没有收到音频数据」 —— 这也是连接坏了。这条原来不丢连接,坏连接被一直复用,之后每一句都返回空音频。
规则
- 判断「该不该丢连接」必须走同一个判据
isBrokenConnection(),不要四处各写正则。 - 它的正则必须包含「没有收到音频数据 / 超时」。只判 WebSocket 字面错误必然漏。
chunks.length === 0那条分支要自己调disposeTts()再抛错,别指望外层 catch。- 回归测试
test/edge-tts.test.mjs(合并了原 tts-pending/reconnect/lock-hardtimeout):用假合成器模拟「坏连接返回空音频」, 断言第二次调用会重建实例并成功。 - 改完要重启 DSH(
lib/edge-tts.js是宿主侧模块)。
朗读无声:播放上下文必须等恢复
浏览器自动播放策略会把新创建的 AudioContext 留在 suspended。
若在上下文恢复完之前就 source.start(0),音频会一直排队不响。
修复:play() 在 start 前 await resume(),并在首次用户手势(点击/按键)时
预热并恢复播放上下文。
等多久、超时怎么办:
- 不能只等 1 秒就跳过。
resume()是异步的,实测要 1~3 秒。 - 现在的做法:先
await playCtx.resume(),再轮询到state === 'running', 最多等 8 秒。 - 8 秒还挂起才算彻底失败,这时跳过这一句并
playSkippedRef += 1, 调试快照__DSH_VOICE_DEBUG__().playSkipped能查到。 - 回归测试:
test/client-behavior.test.mjs用真实构建产物lib/client.js(vm + 假 DOM/AudioContext) 驱动真实播放循环,覆盖 resume/gen 复查、ack 恰好一次、停止/换轮后不出声等行为; 仓库内原read-aloud-resume.test.mjs测的是复制出来的逻辑(已与真实实现漂移),已删除。
朗读队列:代次机制
speakGenRef 递增即作废所有旧音频。
规则
- 停止朗读要三件事一起做:清队列 + 代次自增 + 停掉当前播放。 少一件就会出现「停了还在播」。
- 停当前播放不能只靠「当前那一个取消句柄」:要登记所有还活着的音频源,停止时逐个
stop()。 - SSE 是常驻的,所以「本轮不再朗读」必须有单独的标记(
mutedTurnIdRef)。 - 队列为空时不要把状态设成「朗读中」。
朗读切分:按段落收集,段内再按完整句子,代码块绝不能念
层级:
- 先按段落收集(splitParagraphs,lib/paragraph.js):空行 / 列表项 / 标题 / 引用为段落边界。段落是朗读的基本单元,段内句子连贯、不混入其它段落。
- 段落内按句末标点切句(splitLongText):保证完整句子,绝不在句子中间 / 词汇中间断开。
两条硬规则:
- 不要在句子中间切断。切分优先级(
splitLongText):- 句末标点
。!?;→ 完整句子; - 句内标点
,、:→ 半句,仅在远超上限(>2 倍)时才用; - 硬切(无任何标点)→ 停顿怪,但总比无限长好。
- 略超上限但不到 2 倍 → 整句不切。
- 句末标点
- 代码块一个字都不能念。围栏识别必须在流层面做,而且必须跨 chunk(
与旧行为一致:任意位置;`~~~` 围栏按 Markdown 规范要求**行首**,避免正文里 3 个连续 `~` 把后文静默吞掉)(`lib/fence-filter.js`):模型按 token 流式输出,围栏标记 ``可能正好被 chunk 边界切成两半("" + "js")。旧实现逐块split('```')` 会在这种切法下状态错位:代码被念出来、围栏后的正文反而被当代码丢掉。
回归:test/read-aloud-fence.test.mjs、test/read-aloud-list.test.mjs。
当前策略速查(2026-11-01 定稿)
项 值/行为 发句节奏 客户端每开始播放一句回 /reading-ack,宿主补发;未确认上限READ_ALOUD_LEAD=3追赶预算 readAloudLag,默认 7.5 秒(0 = 关闭追赶,全部朗读)追上去哪 跳过未开始的整段旧段落,保留最新 3 段(从最新第 3 段开始读) 生成很快时 skim:正在读的段只读首句就跳走(判据 backlogMs超预算且仍在生成)计时口径 lagMs= 正在播放的剩余时长 + 已发未确认 + 队列未发 + 当前段尾巴(含流水线提前量)收尾 pending(无 ack)+ 超预算:等 1.2s grace 再排空;flow:按 ack 逐句补发兜底 watchdog 10s 放行一条;SSE 重连清零旧未确认;客户端安全网按整段软上限裁剪 诊断 /diagnostics的readAloud[]:lagMs/playingMs/backlogMs/budgetMs/skim/trimmed*/keepParagraphs/awaitingFlush
朗读发送节奏:客户端播放进度流控(不是固定时间窗口)
助手一次可能吐出一大段文本,而浏览器真正「念出来」受音频播放时长限制 (23 秒/句)。如果宿主只管按固定节奏猛发(历史上是 400ms/句 = 2.5 句/秒), 生产速度是消费速度的 58 倍 —— 客户端必然积压,最后只能丢内容, 表现为「朗读不按自然段落、总挑到段落中间」。所以节奏必须由播放进度驱动。
规则(唯一实现在 lib/read-aloud-scheduler.js):
- 客户端播放进度驱动:宿主维护「已发未确认」句数,上限
READ_ALOUD_LEAD(3)。 客户端每开始播放一句就POST /reading-ack回一条,宿主才补发下一句。 于是朗读节奏 = 真实播放节奏:不积压、不跳段,和屏幕内容的差距恒定在几句以内。 - 首句不排队:第一句无条件立刻发(实测 0ms),用户尽早听到声音。
- 段落是朗读单元:一段的句子全部发出后才轮到下一段;短段落(≤60 字)整段作为一个朗读片段
(仍然是完整句子),超过单句上限才按句末标点切句。正常情况下绝不从段落中间开始念
(事件带
para/paraStart,客户端据此按整段裁剪);例外是下面的 「生成很快时只读首句」——此时正在读的段允许在首句后截断(用户明确要求)。 - 老客户端兼容:收不到 ack 时自动降级为旧的 400ms 时间窗口节奏;
pending(旧)模式下调用结束时同步发完剩余句子(不丢尾巴)。 - 追赶最新(可调,默认 7.5 秒):估算「待朗读音频时长」= 正在播放的剩余时长 + 已发未确认 + 队列里没发的 + 当前段落剩余文本(按约 210ms/字估算,客户端会回传真实音频时长所以一般更准);超过设置项
readAloudLag就跳到最新几段:保留「已开读的段」+「最新 3 段」,中间未开始发的完整段落整段跳过(当前段读完后,下一段是最新第 3 段,再顺序读到最新段 —— 既追上最新内容,又保留一点上下文)。 - 生成很快时每段只读首句就跳:仍在生成中、且「还没发出去的内容」(
backlogMs,不含正在播放/已发未播的流水线提前量)超过预算时,正在读的段落不再强制读完 —— 发出首句后就把该段剩余句子放弃,直接跳到下一段/最新段(任务要求:跳走时只需要读首句)。判断用backlogMs而不是lagMs:生成慢时流水线提前量本身就可能超过预算,用lagMs会把正常朗读也截断。生成结束后(或backlogMs回到预算内)恢复正常完整朗读,剩余段落不会被读残。 跳过时只整段丢「还没开始发」的段;例外:生成很快、仍在流式输出时,正在读的段可以 只读首句就跳走(下方的 skim),不再强制整段读完;生成结束后的剩余段落完整读完。0= 关闭追赶(全部朗读,长回复会越读越滞后)。 - watchdog 只在真发句 / 收到 ack 时重新计时:持续收到文本不算进度 —— 否则客户端不 ack 时 watchdog 永远不会触发(评审实测 2.2s 放行 0 次)。
- 收尾 grace:
pending(老客户端 / 首条 ack 未到)且积压超预算时,一次调用结束**先等FLUSH_GRACE_MS(1.2s)**再同步排空;期间 ack 到了就切 flow 走追赶(否则"结束即排空"会让追赶在这条调用上完全失效)。老客户端没有 ack,grace 到点后照旧发完,不丢尾巴。 - SSE 重连释放配额:页面刷新 / 断线重连时,旧连接遗留的「已发未确认」永远不会再被 ack,新连接建立即清零(否则新页面首句要等 watchdog 10 秒)。
- watchdog:
flow模式下某句发出去ACK_STALL_MS(10 秒)还没 ack (客户端掉线 / 播放被跳过;长句播放本身也可能十几秒,所以阈值不能太短), 放行一条,保证朗读不会永久卡死。
回归:test/read-aloud-scheduler.test.mjs(调度器纯逻辑:流控/追赶/保留 3 段/skim/watchdog/重连)、
test/read-aloud-catchup.test.mjs(真实钩子 + 真实 ack 的追赶端到端)、
test/reading-ack-contract.test.mjs(ack 契约/边界)、
test/read-aloud-catchup.test.mjs 的 e2(pending 收尾时限)、端到端 test/read-aloud-chain.test.mjs。
客户端安全网:按整段裁剪(不承担「追最新」职责)
「追赶最新」由宿主侧负责(
readAloudLag,见上一节):积压超预算就整段跳旧段、 保留最新 3 段,生成很快时每段只读首句。 下面客户端的安全网只兜底「老宿主 / ack 丢失 / 重连」这类异常,正常流控下不会触发。
生成通常比朗读快。客户端待播队列积压太多时,听到的内容会离屏幕越来越远; 但绝不能丢「接下来马上要播的那几句」 —— 那会让播放位置向前跳、落点在段落 中间(用户听到的就是「不是按自然段落朗读,总挑到非首句」)。
规则
- 只读当前这一轮的句子:句子的
turnId与当前轮次不一致就丢掉。 - 安全网裁剪(
MAX_PENDING)按整段进行,且是软上限:超限时从最旧开始整段丢弃 (同一para的整组一起丢),并保护 ①正在播放的段落 ②本轮第一段 ③最新一段。 可丢的组都在受保护组之后时宁可不丢(允许暂时超过上限),绝不为了压上限丢掉最新内容。 于是播放永远是「完整段落」的向前推进,不会从段落中间开始、也不会丢「正在读那段的剩余句子」。 - 不重复播放:客户端按
(turnId, index)去重;宿主段落补全时按已发字符数 减前缀,绝不把已发句子再发一遍(旧实现用字符串startsWith且无兜底, 一旦不匹配就把整段重发 —— 那是「读完后面又回去读前面」的来源)。 - 正常情况下流控已把队列压到几句,这条安全网只在异常(客户端卡顿 / ack 丢失)时生效。
朗读高亮覆盖层:绝不能画在弹框/输入框上面
朗读时会在页面上叠一层半透明色块,圈出「正在念的那一句」。它是 position: fixed
的覆盖层,z-index 不能取整型最大值:DSH 的弹框/遮罩是 z-index:1000 级别的浮层,
覆盖层取 2147483647 就会在设置弹框打开时画到弹框上面(用户反馈)。
规则
- 覆盖层
z-index: 900:高于消息正文(正文没有 z-index),低于弹框/遮罩(1000+); - 第二道保险:检测到
[aria-modal="true"]/[role="dialog"]/[role="alertdialog"]时直接隐藏覆盖层(data-voice-reading-hidden),并用MutationObserver监听 弹框挂载/卸载 —— 弹框关闭后自动恢复并重画; - 旧样式迁移:已注入的旧
<style>(z-index 2147483647)会在下次set()时被覆写, 页面不刷新也能生效。 - 裁剪到会话滚动视口:消息滚出视口后,Range 的矩形仍然会报「原位置」
(父级
overflow只是视觉裁掉),覆盖层会把高亮画到输入框/顶栏上面 (用户反馈:滚动到输入框下方时高亮显示在输入框上)。所以每个色块都与 「滚动视口 ∩ 窗口 ∩(底部输入区以上)」求交集,完全在视口外的不画; 底部输入区只认「下半屏、够宽」的[data-slot*="composer"] / textarea / contenteditable, 避免把顶栏搜索框当底栏;
回归:test/highlight-overlay.test.mjs(对构建产物 lib/client.js 断言 z-index/隐藏/
旧样式迁移/启动清理/视口裁剪)。
朗读管道不能永久卡死
pumpQueue 的消费循环 await 每句的合成 promise。若某句挂住,await 永不 resolve。
规则
- 每句合成都要有超时兜底:
await Promise.race([promise, sleep(...)])。 - 播放上下文要在 try 内取。
- object URL 必须在
finally里释放。 - 宿主
llm/stream里next()同步抛错时必须复位 busy。
状态行只放短词
规则
- 状态行只显示短词(「加载模型…」「朗读中」「已停止」)。
- 完整说明放到
…Detail文案里,鼠标悬停显示。 - 状态行给一个
minWidth:换状态时宽度不变,界面不跳。
草稿:我们记的和用户看到的不一定一致
规则
- 写新内容之前先调
syncUserEdit()检测用户是否改过。 - 「我们有没有写过」不能只看内容是否为空(首次写入会被误判)。
流式识别:门限不能一刀切
规则
- 底噪只在静音时更新。
- 进入说话态后,单帧偏低不能中断;连续若干帧都低才退出。
定时器:必须登记到登记表
规则
- 源码里不允许出现裸的
window.setTimeout/window.setInterval。 - 定时器按 group 登记(
mic/send/ui/app/request),状态复位时按 group 一次清干净。 - 短时等待统一用
sleep(ms)。
同一判据只能有一处实现
规则
- 能共享就共享(宿主侧无限制,可以自由拆文件)。
- 因浏览器模块不能 import 本地文件而必须写两份时,
两份要由同一份用例表(
test/fixtures/)驱动,断言两端行为一致。 - 配置清单的唯一真源在宿主:
GET /config返回生效配置。
下载:数据必须是真的
规则
- 档位里声明的文件大小必须是实际大小,不能用估计值。
- 文件名要对着仓库实际的名字写。
- 进度用绝对字节语义,不要用增量语义。
原生库:类别用错会杀进程
规则
- 流式 transducer 必须用
OnlineRecognizer+isReady/decode/inputFinished。 - 离线模型才用
OfflineRecognizer。
改动之后
npm test必须全绿(42 个文件;先跑bundle-sync校验产物同步,再跑client-behavior行为回归)。- 需要连活插件的集成测试单独跑:
npm run test:silence -- http://127.0.0.1:<端口>。 - 需要跑「真加载模型」的慢集成(SenseVoice 预加载等):
$env:DSH_VT_SLOW_TESTS=1; npm test。 默认套件走快速路径并显式打印 ⏭ 跳过,不会悄悄漏测。 - 客户端行为回归在
test/client-behavior.test.mjs:直接加载lib/client.js产物, 用假 React/DOM/AudioContext 驱动真实播放循环,断言 ack 恰好一次 / 顺序 / 段落完整 / 停止与换轮后不出声。改完src/client/并重建产物后一定要跑它(形状断言测不出时序缺陷)。 - 改完
lib/index.js(宿主侧)要重启 DSH; 改完lib/client.js(浏览器侧)要浏览器强制刷新。 - 改完
src/client/pure/要跑npm run build:client重新生成lib/client.js。npm test里的bundle-sync.test.mjs会检查产物是否同步。
配置项的配合关系(生效条件矩阵)
配置统一走 GET/POST /config:GET 返回已归一化的完整配置;POST 是 patch(只接受已声明键)。
加载时非法/越界值回退默认并记入 /diagnostics.configWarnings;保存时非法值返回 400 {ok:false, errors},不会静默改值。
| 配置 | 生效条件 / 配合 |
|---|---|
readAloud |
朗读总开关。关闭时 /tts 不会被调用;但朗读相关设置仍可编辑/保存 |
ttsEngine |
online→voice/pitch/volume/ttsFormat;neural→neuralModel/neuralSpeaker;local→localVoice;sovits→sovits*(rate 转 speed_factor)。非法值保存被拒,运行时不静默落 online |
rate |
四种引擎都生效(online/local 百分比、neural 倍率、sovits speed_factor 0.5~2) |
pitch / volume / ttsFormat |
仅特定引擎有效:pitch/format 仅 online,volume 仅 online/local |
readAloudLag |
只在 flow(客户端 ack)模式生效;0=关闭追赶;非有限值回退默认(7.5) |
language |
只对 SenseVoice 生效;Whisper/zipformer2 忽略(UI 按模型禁用/提示) |
liveAsr |
stream 固定依赖 zipformer2-zh(与 model 无关);不可用时提示并回退整段 |
model |
必须是已声明档位;非法值保存被拒,运行时不再静默回退 SenseVoice |
autoSend |
关闭时 autoSendExtraMs 隐藏;mode=once 的「说完退出」只在自动发送后发生,因此会禁用并说明 |
preloadModels × idleReleaseMinutes |
预加载只覆盖 model/neural(不覆盖 liveAsr 的 zipformer2);idle 释放不释放当前选中模型;0=永不释放 |
sovits* |
只对 ttsEngine=sovits 生效;sovitsRefAudio 是服务端机器可访问路径(官方 api_v2 每请求必填),留空则不带该字段由服务端决定 |
words |
热词仅 zipformer2(transducer)生效;按行保存,保存时 trim/去空 |
CI 与自动发布(GitHub Actions)
仓库自带两个 workflow(.github/workflows/),都走 pnpm:
ci.yml:每次 push / PR 到main自动跑check:client+npm test。 改完代码提交后,GitHub 上的红绿状态就是最直接的回归信号。npm-publish.yml:GitHub 上发布一个 Release(release事件published)时, 先跑一遍同样的测试,全绿后自动npm publish --access public。
发布到 npm 前要做的两件事:
- 在 GitHub 仓库设置里加一个
NPM_TOKENsecret(值来自 npm 官网的 Access Token, 建议用publish权限的 token,不要用自动生成的npm_token)。 - 在 GitHub 上创建一个 Release(
vX.Y.Z),tag 就打在要发布的提交上。
发布成功与否看 workflow 的 publish job;测试失败时不会发出版本,修复后重新打 Release 即可。
版本号以
package.json的version为准,tag 只用来触发工作流。
10. 许可
MIT
No comments yet. Be the first to write one.