# dsh-gpt-sovits **中文** · [English](./README.en.md) > 这是本项目的**完整文档**。首页的精简版见 [../README.md](../README.md)。 > ### 🐋 这只鲸鱼娘,自己给自己装了一副嗓子 > > 本项目由 **DeepSeek(`deepseek-flash`)** 在 DSH 会话中逐轮写成 —— 出主意、修 bug、写测试、读宿主源码、量自己的宿主,都是它做的。 > > **代码是 AI 写的,声音是作者训练的。** > > **验证状态:全部通过** ✅ —— 61 项离线自检 ✅ / 16 项真实引擎集成测试 ✅ / 界面按钮与自动朗读人工实测 ✅ > > 开发与验证环境:**Windows 11** · DSH 桌面版(客户端契约 `0.2.0-rc.2`)· Node **v24.21.0** · **RTX 5070 Ti (16 GB)** · Python **3.9.13** + torch **2.7.0+cu128** · GPT-SoVITS **v2Pro** @ `127.0.0.1:9880` > > 详细说明见 [它是怎么来的](#它是怎么来的) 与 [✅ 测试通过的运行环境](#-测试通过的运行环境)。 给 DeepSeek Harness(DSH)Web GUI 接入 **GPT-SoVITS** 语音合成:每条助手回复一键朗读、自动朗读开关、音色预设与设置面板。用你自己本地部署的 GPT-SoVITS 引擎和音色,完全离线、不经云端。 ```sh dsh plugin --profile desktop add github:91koukou/dsh-gpt-sovits ``` **本仓库不附带任何模型权重、参考音频或音频样本**,也不替你启动引擎 —— 这些都由你自己准备。 --- ## 前置条件 1. 一份**已部署好的 GPT-SoVITS**(`api_v2.py` 能跑起来) 2. 至少一对自己训练的 **GPT(`.ckpt`)+ SoVITS(`.pth`)** 权重,以及一段**参考音频** 3. DSH 桌面版或 Web 版 引擎自己启动: ```sh cd <你的 GPT-SoVITS 目录> runtime\python.exe api_v2.py -a 127.0.0.1 -p 9880 -c GPT_SoVITS/configs/tts_infer.yaml ``` --- ## 为什么是"自己写"而不是装现成的 同类插件确实存在,但都不满足"接 GPT-SoVITS 且带界面按钮"这个组合: | 插件 | 引擎 | 逐条朗读按钮 | 自动朗读 | 设置面板 | |---|---|---|---|---| | [`dsh-say`](https://github.com/fangqian616/dsh-say) | **GPT-SoVITS** ✅ | ❌ 只有 agent 工具 `tts_*` | ❌ | ❌ | | [`dsh-gsv-tts`](https://github.com/TaoruiLiu19/dsh-gsv) | GSV-TTS-Lite(GPT-SoVITS 的**分支**,非上游) | ✅ | ✅ | ✅ | | [`dsh-fish-tts`](https://github.com/MaRi23333/dsh-fish-tts) | Fish Audio 云端 | ✅ | ✅ | ✅ | | [`dsh-voice-chat`](https://github.com/maoyuching/dsh-voice-chat) | Edge TTS / MiMo / 自定义 OpenAI 兼容 | ✅ | ✅ | ✅ | | **本插件** | **GPT-SoVITS** ✅ | ✅ | ✅ | ✅ | `dsh-gpt-sovits` 与 `dsh-sovits` 这两个包名在 npm 上均为 404 —— 上游 GPT-SoVITS + 完整 GUI 的位置是空的。 --- ## 功能 - **逐条朗读**:每条定稿助手回复的操作条上有一个 🔊 按钮;播放中再点即停,点别的回复直接切换(单信道抢占)。 - **自动朗读(排队,不抢占)**:输入栏左侧的小喇叭开关。开启后自动朗读新回复;**同一条回复只读一次**(`localStorage` 记录,刷新页面、切换会话都不重复)。回复是**流式增长**的,所以只朗读**新增的、已到句号的部分**,**一句接一句排队念完,绝不打断正在念的那句**。详见 [为什么自动朗读要排队](#为什么自动朗读要排队)。 - **启动问候**:用你的方案实现 —— 插件运行后**每 0.5 秒**向空闲的引擎请求一次问候语,**识别到引擎开始工作就停止**(引擎状态每 0.1 秒检测一次)。它同时是"插件活了"的信号和**引擎预热**。文案可改、可关。详见 [启动问候](#启动问候)。 - **处理过程与总结分流**:流式步骤说明**一句一请求**(`cut0`,开始最早、不夹断);定稿总结**打包成块交给引擎切**(`cut5`,请求数少、快一倍)。详见 [处理过程与总结:两种朗读策略](#处理过程与总结两种朗读策略)。 - **新一轮取消上一轮**:按外壳自己的轮次计数(`timeline.turnOrder`)判定,新一轮开始时`stop()` 清空待播队列、作废飞行中的合成。轮次内**不打断**。详见 [新一轮开始时取消上一轮的一切](#新一轮开始时取消上一轮的一切)。 - **句间不卡顿**:播放第 N 句时,第 N+1、N+2 句**已在合成**(预取窗口 2);短句在定稿打包时合并,不再各占一个请求。详见 [句间停顿从哪来以及怎么压掉](#句间停顿从哪来以及怎么压掉)。 - **把符号念成人话**:`cmd.exe` → 「cmd点exe」、`3-10` → 「3到10」、`v2.7.0` → 「v2点7点0」、`36.5` → 「36点5」、`127.0.0.1` → 「127点0点0点1」、`是/否` → 「是或否」、`3/4` → 「4分之3」、`C:\Users` → 「C:杠Users」;日期 `2024-10-03` 保持原样,不会被误当成区间。 - **单位不会念错**:`120km/h` → 「120千米每小时」、`100MB/s` → 「100兆字节每秒」、`3000r/min` → 「3000转每分钟」、`5m/s²` → 「5米每秒平方」;裸缩写也展开 —— `2.4GHz` → 「2点4吉赫兹」、`144Hz` → 「144赫兹」、`20ms` → 「20毫秒」、`16GB` → 「16吉字节」。**词表方式**,新增单位只需加一行;斜杠在单位里是「每」不是「或」,见 [符号怎么念成话](#符号怎么念成话)。详见 [符号怎么念成话](#符号怎么念成话)。 - **句子之间的停顿由标点决定**:引擎不再自己加静音(`fragment_interval: 0`),插件裁掉每段音频尾部的静音,再按该句的标点插入对应停顿 —— 句号 340ms、逗号 150ms、省略号 460ms、**段落 520ms**。详见 [分句与停顿](#12-分句与停顿pause_ms-segmentsentence-splitintosentences)。 - **中文分句完整**:`。!?;…` 及其连写(`……`、`!?`)都是句末,句末引号归前句(`他说:“走吧。”` 是一句);**段落是硬边界**,不会被合并成一段。 - **切换工作区/会话即取消朗读**:不再出现"切走以后旧对话还在念、越切排队越多"。信号是外壳发给每个槽位的 `sessionId`,**上一句的值存在模块级变量里**——因为切换会话会把整棵子树卸载重挂,存在组件里的值(`useRef`/`useState`)在重挂后一律是空的,那样永远发现不了切换。详见 [切换会话时取消](#15-切换会话时取消sessionid-跨重挂存活)。 - **音频只在内存**:不再为每句话写一个 WAV 到硬盘,直接从内存提供服务,条数(240)与总字节(64 MiB)双重上限。升级时自动回收旧版留下的音频文件。 - **逐句语速与情感**:语速、`temperature`(表现力)、`top-k`、`top-p` 随**每一句**发出,**改动从下一句生效**,已经念过的不重做;音量是播放属性,改了立刻生效也不重新合成。**生成质量参数(采样步数、超采样)刻意不开放逐句调整**。 - **文本清洗**:代码块整块略过、URL/路径/哈希/长标识符替换成"链接/路径/编号/长代码",Markdown 标记与 HTML 标签剥掉 —— 只读该读的。 - **音色预设**:名称 + 参考音频路径 + 参考文本 + 参考语言,可增删;可设默认音色。权重按需切换(`set_gpt_weights` / `set_sovits_weights`),成对未变时**跳过切换**以免每次朗读都重载模型。 - **设置面板**:设置 → 语音朗读(GPT-SoVITS)。顶部是**运行状况**(引擎、是否正在生成、问候语状态、当前音色与参考音是否就绪、已加载权重)与**引擎控制台**;下面是引擎地址、音色、语言、语速、采样步数、音量、播放倍速、试听。保存即生效。 - **表格不念分隔线**:回复以原始 markdown 进入管线,所以表格带着竖线和 `---` 一起来 —— 引擎会把两者都念出来。分隔行整个丢掉,单元格用逗号连接(分句器把它当弱停顿),一行读成一组值。 - **引用的符号会念出名字**:``把 `;` 也作为切分符号`` 以前会**丢掉分号**(引擎直接扔掉裸 `;`),句子在说一个它从没念过的东西。现在反引号里的符号**读名字** —— 分号、逗号、句号、左方括号;而**当标点用**的标点行为不变。代码标识符不受影响(`split` 仍是 "split")。- **引擎控制台**:引擎的 stdout/stderr(TTS 配置、权重加载、每次合成的目标文本、访问日志、报错堆栈)+ 启动器判定 + 生成记录,**内嵌在设置页**,可选 1.5 秒 / 0.5 秒 / 手动刷新。中文按 UTF-8 正确显示。详见 [引擎控制台](#引擎控制台内嵌在设置页)。 - **同源短链接**:合成音频落盘后以同源 URL 提供,**不把音频塞进模型上下文**。 - **只监听本机**:所有路由拒绝非 loopback 来源,写接口额外校验同源,即使宿主监听 0.0.0.0 也不对外开放。 --- ## 安装 ```sh dsh plugin --profile desktop add file:C:/绝对路径/dsh-gpt-sovits ``` 装完**刷新页面**(客户端半身在启动图里合成,新插件需要一次页面加载才会出现)。卸载: ```sh dsh plugin --profile desktop remove dsh-gpt-sovits ``` ### 版本与下载 **任何版本都可以随时下载或安装**,不必迁就最新版。 | 你想要 | 怎么做 | |---|---| | 最新版 | 仓库首页 → **Code** → Download ZIP | | **某个发行版** | **[Releases](https://github.com/91koukou/dsh-gpt-sovits/releases) 页** → 选版本 → `Source code (zip)` | | 任意历史提交 | `git clone` 后 `git checkout <提交哈希>` | | 安装指定版本 | `dsh plugin --profile desktop add github:91koukou/dsh-gpt-sovits#v0.1.0` | 每个发行版都有 tag 锚定,**旧版本不会被后续更新覆盖**: | 版本 | 说明 | |---|---| | [`v0.1.0`](https://github.com/91koukou/dsh-gpt-sovits/releases/tag/v0.1.0) | 首个发布版:逐条朗读按钮、自动朗读开关、音色预设、设置面板 | | `v0.2.0` | 引擎生命周期跟随 DSH、文本规范化、流式/总结分流、队列式朗读、启动问候、引擎控制台 | | `v0.3.0` | 分句补全中文标点与段落边界、按标点插入停顿并裁掉引擎尾部静音、切换工作区取消朗读、音频改内存、逐句语速与情感 | | `v0.3.1` | **切换会话真正停播**:判据改为跨卸载重挂存活的模块级变量(v0.3.0 那版存在组件 `useRef` 里,而会话槽位按代际 key 重挂,于是永不触发);驱动卸载时额外释放音频元素 | | `v0.3.2` | **四处可靠性修复**:计划内的取消不再被上报成播放失败、被淘汰的音频自动重新合成、一个回合只重置一次(原先 8 个副本各来一遍)、诊断事件有了内存与磁盘上限 | 版本变更记录见 [CHANGELOG.md](../CHANGELOG.md)。 --- ## 前置:启动 GPT-SoVITS API 插件只负责"说话",不负责起引擎。引擎侧用官方 `api_v2.py`: ```sh cd <你的 GPT-SoVITS 目录> runtime\python.exe api_v2.py -a 127.0.0.1 -p 9880 -c GPT_SoVITS/configs/tts_infer.yaml ``` `-c` 指向的 yaml 里 `custom:` 段的 `t2s_weights_path` / `vits_weights_path` 决定默认音色权重。启动要等模型加载完(首次约 20–60 秒),设置页的"引擎"一行会显示 `运行中`。 > 健康探测打的是 `GET /control`。这个接口**不带参数时按设计返回 HTTP 400**,所以插件把 400 视为"活着",只有连不上才算"未连接"。 --- ## 配置 打开 **设置 → 语音朗读(GPT-SoVITS)**。 | 项 | 默认 | 说明 | |---|---|---| | 引擎地址 | `http://127.0.0.1:9880` | `api_v2.py` 的监听地址 | | 检出目录 | 自动发现 | GPT-SoVITS 安装目录,用来列出你训练好的权重。**填了就跳过盘符扫描**(见 [已知限制](#已知限制)) | | 音色预设 | 空 | `名称` + `参考音频路径` + `参考音频文本` + `参考语言` | | 默认音色 | 第一个 | 朗读时用的音色;留空 = 用列表第一个 | | 朗读文本语言 | `zh` | `zh/en/ja/ko/yue/auto` | | 语速 | `1.0` | 对应引擎的 `speed_factor`,0.5–2.0 | | 采样步数 | `32` | **嫌慢就降到 16**,这是最有效的加速手段 | | 音量 / 播放倍速 | 90% / 1× | 浏览器侧,仅影响本机播放 | | 自动朗读新回复 | 关 | 等同输入栏那个小喇叭开关 | 落盘位置:`$DSH_HOME/gpt-sovits/`(`settings.json` + `audio/`)。也可以在 profile 的 `cordis.patch.yml` 里给 `gpt-sovits` 行加 `config` 固定默认值 —— 但**设置页保存的值优先**。 ### 参考音频怎么选 - 3–10 秒、干净人声、无背景音乐,效果最好。 - 路径必须是**引擎所在机器能读到的路径**(`ref_audio_path` 是服务端路径,不是浏览器上传)。 - **`参考音频文本` 强烈建议填写**:留空会让引擎先对参考音频做一次识别,首次合成会从 ~1.5 秒变成 ~40 秒(结果会被缓存,之后恢复)。 --- ## 路由 | 方法 | 路径 | 说明 | |---|---|---| | `GET` | `/gpt-sovits/api?action=settings` | 读有效设置 | | `POST` | `/gpt-sovits/api?action=settings` | 写设置补丁 | | `POST` | `/gpt-sovits/api?action=synthesize` | 合成一段文本,返回 `{url, bytes, voice, cached}` | | `GET` | `/gpt-sovits/api?action=health` | 探测引擎 | | `GET` | `/gpt-sovits/audio/.wav` | 播放合成结果(`id` 是内容摘要,可长期缓存) | `/tts` 请求体遵循 `api_v2.py` 的契约:`media_type: "wav"`、`streaming_mode: false`、`text_split_method: "cut5"`、`sample_steps`、`repetition_penalty: 1.35` 等;引擎**直接返回 WAV 字节**(上游没有 JSON/base64 音频,`media_type` 是 `wav|raw|ogg|aac` 的封闭集合,`mp3` 会被 400 拒绝)。 --- ## 开发与自检 ```sh # 离线自检:语法、模块加载契约、slot 名、文本管线、store 读取、状态目录、编码(61 项) node scripts/selfcheck.mjs # 真实集成测试:起本地 HTTP 载体,用真引擎跑一遍全链路(16 项) # 默认优先测工作区源码;--source installed 则测「运行中的 DSH 实际加载的那一份」 node scripts/integration.mjs --ref <你的参考音频.wav> node scripts/integration.mjs --skip-synthesis # 不触网,只测路由与校验 node scripts/integration.mjs --ref ref.wav --gpt GPT_weights/your.ckpt --sovits SoVITS_weights/your.pth ``` > `--ref` 是合成类检查的**必需**参数:本仓库不附带任何参考音频,请指向你自己的。 `--experimental-vm-modules` 下自检还会额外做一次 ESM 解析(默认跳过该项)。 > ⚠️ `integration.mjs` 会把 `speed` / `sampleSteps` 写进**真实的** `settings.json`(它测的就是真插件)。跑完请到设置页确认这两项是你想要的值。 ### 界面是怎么接进去的 两个按钮都放进 DSH 自己的槽位,并套用 DSH 自己的按钮规范,而不是自画一套: | 元素 | 槽位 | 位置 / 实现 | |---|---|---| | 🔊 朗读按钮 | `conversation.chat.assistant-actions` | 每条助手回复的操作条(与复制/分支/反馈同一行),`order: 20`;几何按官方 `MessageFeedbackActions` 的 28px 盒 / 6px padding / 15px 字形,播放中改用外壳自带的 **Pause** 图标 | | 🔊 自动朗读开关 | `conversation.input.left` | 输入框工具栏行,`order: 30`;与官方 `VoiceInput` 的 28px 触发器同规格 | | 设置页 | `settings.section` | 齿轮 → 语音朗读(GPT-SoVITS),`order: 60` | **开关为什么不在"左边"** —— 这是外壳的布局决定的。输入框那一行: ```css .row { flex-wrap:wrap; justify-content:space-between; gap:12px; padding:2px 8px 6px; } ``` 三个 flex 子项:① 权限 + 计划组(`input.permission` + `input.plan`)② **`input.left`(本插件)** ③ `trailing`(模型选择 + 发送)。`space-between` 把三者**均摊整行**,所以小喇叭落在①与③**之间偏右** —— 视觉上就是"权限控制的右边"。 要挪位置只能换槽位:`input.left` 已经是第②个位置,再靠左做不到;改用 `input.right` 会并入③那一组,或调 `order` 调整组内顺序。 **图标说明**:DSH 自带 377 个图标,**没有任何喇叭/音量/声音图标**(只有 Play/Pause),所以喇叭是本地绘制的 —— 但严格遵循外壳的图标契约(`size=16`、`viewBox="0 0 16 16"`、`fill="none"`、1px `currentColor` 描边),因此看起来属于同一套。悬停与激活态由注入的 CSS(`:hover` / `[data-active]`)提供,而不是内联样式 —— 内联样式表达不了这两者。 ### 引擎生命周期:跟着 DSH 起,跟着 DSH 停 **引擎的生死由 DSH 决定**:打开 DSH 时自动把引擎拉起来,关闭 DSH 时自动把它停掉。不需要你手动管,也不会留下占着显存的孤儿进程。 实现是一个 **Python 监督进程**(`lib/supervisor.py`,用 `pythonw.exe` 运行,全程无窗口): | 阶段 | 行为 | |---|---| | 启动 | 插件把监督进程拉起,并把 **DSH 自己的 pid** 通过 `--adopt-pid` 交给它 | | 等待就绪 | 监督进程自己轮询端口(socket 连接),就绪后把引擎 pid 写进 `$DSH_HOME/gpt-sovits/engine.pid` | | 运行期 | 每 2 秒检查一次 DSH 是否还在(`OpenProcess` + `GetExitCodeProcess`) | | DSH 关闭 | 检测到 DSH 消失 → `taskkill /T /F` 停掉引擎 → 清理 pid 文件 → 自己退出 | | 插件卸载 | 插件注册的 disposer 调用 `supervisor.py --stop`,同样收尾 | | 上次崩溃残留 | 下次 DSH 启动时读 `engine.pid`,若记录的 DSH 已死,先把遗留引擎清掉 | **实测验证**(本机): | 测试 | 结果 | |---|---| | 冷启动到就绪 | **10 秒** | | 杀掉绑定的父进程后 | 引擎 + 监督进程 **1 秒内全部退出**,端口释放,pid 文件清理 | | 引擎已在运行时再启动 | 识别为"已在应答",**不重复启动**(python 进程数 1 → 1) | **为什么不再需要开机自启**:早期版本让引擎独立于 DSH 存活(三层自启动:启动器 / 无窗口包装 / 登录快捷方式),因为"重启 DSH 会带走引擎"。现在的目标是相反的 —— 所以**计划任务和启动文件夹快捷方式都已移除**,否则它们会不断把引擎复活,与"关 DSH 就关引擎"直接冲突。 **手动启动引擎**(想在开 DSH 之前先热身,或完全不用 DSH 时): ```sh $DSH_HOME/gpt-sovits/start-engine.bat ``` 它调用同一个 `supervisor.py`,区别是不传 `--adopt-pid`:启动后监督进程就退出,**引擎留下继续运行**(手动起的引擎不该因为这个窗口关闭就被杀掉)。 - **诊断日志**:`$DSH_HOME/gpt-sovits/engine-start.log`,每次判定与耗时都有记录(`ready=True`、`watching DSH pid N`、`engine stopped: True`)。 - **配置自动跟随**:端口(`serverUrl`)与可选的 `engineRoot` 从 `settings.json` 读取,设置页改了,两条启动路径都跟着变。 > **踩过的坑(都已修,别再踩)**: > 1. **批处理里不能有中文。** `cmd.exe` 按控制台代码页解析 `.bat`,非 ASCII 注释会被当命令执行(实测报 `'独立于' is not recognized as an internal or external command`)。`start-engine.bat` 因此全 ASCII。 > 2. **别用 `timeout` 做延时。** 输入被重定向时 `timeout.exe` 直接报 `Input redirection is not supported` 并立刻退出,2 秒内跑完 60 次轮询、把成功启动误判成 TIMEOUT。 > 3. **别用 `ping` 做延时。** 那正是"闪窗"的根源:`ping.exe` 是控制台程序,每次轮询新起一个进程就是一个窗口,冷启动会闪 30–45 个。现在改用 socket 轮询,一个窗口都没有。 > 4. **判断引擎存活不能走代理。** 本机注册表里有系统代理,`urllib` 默认读它 —— `urlopen('http://127.0.0.1:9880/control')` 的响应**来自代理**(返回 404),而当时 9880 上根本没有进程在听。监督进程与插件现在都显式绕过代理,只走 loopback。 > 5. **判断进程存活不能用 `tasklist` 文本匹配。** 实测把一个活着的进程误判为"已死",监督进程启动完引擎后立刻退出,留下一个永远不被回收的引擎。改用 `OpenProcess` + `GetExitCodeProcess`。 > 6. **`netstat` 解析不可靠。** 对活着的监听者返回了空集,导致"认领已有引擎"失败。改用 `GetExtendedTcpTable`(ctypes,精确且不新起进程)。 ### 自动朗读是"下一条起"生效 点开输入栏那个小喇叭(或设置页的「自动朗读新回复」)之后,**已经显示在屏幕上的那条回复不会被朗读** —— 开关是关于"新回复"的,不是"把当前这条念一遍"。 想验证它真的在工作:**开着开关再发一条消息**,新回复一到就会自己出声。 ### 为什么要"一句一请求" **症状**(用户实测):文本一长,会出现**上一句讲到一半就被下一句挤掉**,或者**某句干脆没念出来**。 **原因**:当时是**两个切分器在互相打架**。 | | 旧做法 | 问题 | |---|---|---| | 插件侧 | 把句子**攒到 ~110 字**再发一个请求 | 一个请求里可能同时含"这句的后半"和"下一句" | | 引擎侧 | 请求里再带 `text_split_method: "cut5"`,**又切一次** | 两套边界不一致 | 于是引擎在按**自己的**边界合成,而插件已经按**自己的**边界发下一个请求了 —— 引擎手上那半句还没合成完,就被新请求覆盖,音频自然断在半句。超长句还有一条**按字符数硬切**的退化路径,同样造成夹断。 **改法**: | | 现在 | |---|---| | 插件侧 | **一句一请求** —— 每个请求恰好一个完整句子,`splitIntoChunks` 只按句末标点(`。!?!?;;…`)与换行切分 | | 引擎侧 | **`text_split_method: "cut0"`** —— 明确告诉引擎"不要切,给你的就是完整一句" | | 超长句 | 阈值从"打包用的 110 字"改为**安全阀 `SENTENCE_SOFT_MAX = 280`**;超过时**先在从句标点(,、;:—–)处断开**,绝不按字数硬切 | | 播放 | 仍是流水线:第 N+1 句在本句播放时预取,所以听感上依然连贯 | **实测**(真实引擎,4 句连发): ``` [1] HTTP 200 201004 B RIFF 2214ms 先运行 cmd点exe 安装依赖。 [2] HTTP 200 154924 B RIFF 784ms 端口范围是 3到10。 [3] HTTP 200 203564 B RIFF 934ms 版本 v2点7点0 修好了。 [4] HTTP 200 175404 B RIFF 863ms 温度设为 36点5 度。 ``` 每句都是一个独立的完整 WAV,没有一句被截断。 > **回归保护**:自检里有一条断言锁死 `text_split_method: 'cut0'`,并**显式禁止 `cut5` 回来** —— 因为把两层切分重新引入,症状只会表现为"有时候念一半",很难归因。 ### 为什么自动朗读要排队 **症状**:回复长的时候,**上一句念到一半就被掐掉**,换下一句。用户实测反馈"下一句进入处理就抛弃上一句"。 **诊断日志给出的因果链**(几十次重复出现,不是偶然): ``` [19:50:56.981] gate-fb4fe213 auto:true ← 文本更新,闸门放行 [19:50:56.982] speak fb4fe213 ← 开始朗读 [19:50:56.986] play-failed 221bbd60 ← 4 毫秒后,前一条被中止 ``` 统计:`play-failed` **84 次**、`played` 只有 **51 次**。**约五分之四的朗读是被中途掐掉的。** **根因有两个,都不在切分层**: | # | 问题 | 说明 | |---|---|---| | 1 | **抢占式播放** | 自动朗读调用 `play()`,而 `play()` 第一件事就是 `stop()` —— 于是每次文本更新都杀掉正在念的那句 | | 2 | **流式重复朗读** | `selectLatestMessageId` **不要求定稿**(`nodeBlocks` 会回退到流式中的 `node.data.blocks`),所以文本每增长一次就触发一轮朗读,**开头几句被反复念** | **改法:队列 + 增量** | 机制 | 作用 | |---|---| | `player.enqueue(key, text, words, complete)` | 把句子**追加**到播放队列,而不是抢占 | | `player.drain()` | 逐句取队列播放;播完一句再看队列,因此播放中新增的句子也会被接上 | | **按消息记录已入队偏移** | `queued` map 记录每条消息已入队的字符数,**同一条消息的旧句子不会再入队** —— 这是"流式不重复"的关键 | | **只取到最后一个句号为止** | 半句话不入队(否则会被合成一次、写完后再说一次),尾巴留给下次;`complete` 时才把尾巴冲出来 | | 单次 hook 返回**原始字符串** `"id|0/1"` | 对象选择器每次渲染都是新身份,会破坏 store 的变更比较;字符串只在"增长或定稿"时变化 | | 占位集合语义改为"**这条归队列所有**" | 原来表示"已念过",在流式下会把第一句之后的所有句子都丢掉;现在队列念完才释放 | **保留的抢占**:点 🔇 停止、点别的回复的 🔊 按钮 —— 那是用户明确要求换目标,应该立刻切。 **实测与回归**: ``` ✓ client: a growing reply is queued sentence by sentence, never cut off — 4 sentences in order, none cut off, none replayed ✓ client: an unterminated sentence waits for its ending — fragment withheld, complete sentence spoken once ``` > 队列测试会**让 `stop()` 直接抛错** —— 只要实现里再出现"增长时停播",测试立刻失败。这是刻意的:这个 bug 出现过两次,不能再回来。 ### 处理过程与总结:两种朗读策略 DSH 的一次回复其实是**两种性质完全不同的输出**,所以用两套策略: | 阶段 | 文本特征 | 切分策略 | 为什么 | |---|---|---|---| | **处理过程** | **流式增长**,逐步给出步骤与状态 | **插件侧一句一请求 + `cut0`**,进队列 | 边到边念、**开始得最早**;引擎不再切,不会和插件的边界打架 | | **最终总结** | **一次输出一大段** | **插件打包成块 + `cut5`**,由**引擎自己切分** | 逐句发要付几十次请求开销,引擎会跟不上听者的速度 | **实测对比**(真实引擎,同样 4 句): ``` streaming 单句 cut0 HTTP 200 152364 B RIFF ✅ 869ms settled 打包块 cut5 HTTP 200 509484 B RIFF ✅ 1620ms ``` 打包后 **1 个请求 1.6 秒**;逐句则要 4 个请求,按单句耗时推算约 **3.4 秒** —— **快一倍**。 **`text_split_method` 现在按请求传递**(客户端决定): | 来源 | 传入 | 理由 | |---|---|---| | 流式增长 | `cut0` | 已经是完整单句,再切就会两套边界打架 | | 定稿总结(打包块) | `cut5` | 块内由**引擎**按标点切,避免把长段落当一句合成 | | 手动点 🔊 按钮 | `cut5` | 一次性给出整段文本,正是"让引擎自己切"的场景 | | **未指定(默认)** | **`cut0`** | 默认**不能**切:意外的切分正是当初截断的根因 | ### 新一轮开始时,取消上一轮的一切 **需求**:进入新一轮回答时,前面所有的处理与播放都要取消掉,让 GPT-SoVITS 专心生成新的内容。 **信号**:外壳自己的轮次计数 —— `snapshot.timeline.turnOrder`(外壳源码里就是这么判断轮次的: ```js if (snapshot.timeline.turnOrder.at(-1) !== data.turn) return false; ``` **行为**:计数增长即判定新一轮开始 → 1. 清空 `CLAIMED`(让新回复能被正常认领) 2. **`player.stop()`** —— 停掉当前播放、清空待播队列、清空每条消息的已入队偏移 3. **`generation += 1`** —— 让**已经在飞行中**的旧回合合成结果作废,不会在新一轮开始后才播出来 **为什么用 `stop()` 而不是"温和清空队列"**:温和清空会留一个尴尬状态 —— 队列空了但当前那句还在念,而它的后续永远不会来。你的要求明确是"取消前面所有的处理和播放",所以**在新轮次边界上,新回合的优先级高于旧回合的尾巴**。 **注意:轮次内不会打断**。`stop()` 只在 `turnOrder` 增长时调用,正常的一句话接一句话的流式朗读完全不受影响。 ### 符号怎么念成话 引擎会把符号**直接丢掉或念错**,所以这些替换必须在插件侧做完。规则按顺序执行,**顺序本身就是正确性的一部分**: | 规则 | 输入 → 输出 | 顺序上的理由 | |---|---|---| | 文件扩展名 | `cmd.exe` → `cmd点exe` | 必须**先于** `cleanForSpeech`:那一趟会把路径和长标识符塌缩成"路径/长代码",扩展名就没了 | | 分数 | `3/4` → `4分之3` | 中文读法是**分母在前**;必须先于"或",否则会念成"3 或 4" | | 斜杠(其余) | `是/否` → `是或否`,`是/否/待定` → `是或否或待定` | 真分数排除后,"或"是安全默认值 | | 反斜杠 | `a\b` → `a杠b` | 路径分隔符读作「杠」 | | 版本号 | `v2.7.0` → `v2点7点0` | 必须**先于**通用小数规则,否则 `2.7` 会被当成度量值 | | 区间 | `3-10` / `3 ~ 10` / `3–10` → `3到10` | 用 `\b` 限定数字边界,避免咬到 `abc-def` 这类标识符 | | 日期 | `2024-10-03` → **原样保留** | **先遮蔽、处理完再还原**。用负向断言试过,不可靠:`2024-10-03` 实测被切成了 `2024-10到03` | | IP 地址 | `127.0.0.1` → `127点0点0点1` | 三点全匹配,先于通用小数规则 | | 通用小数 | `36.5` → `36点5` | 排在最后,只处理剩下的小数点 | **URL / 路径 / UUID 也走遮蔽**:它们在替换前被抽出、末尾还原,否则斜杠规则会把 `https://a.com/b` 念成「链接 或 或 a点com 或 b」。 > **斜杠的取舍**:`3/4` 一律读作分数。真正表示"每"或比值的场景(比如 `1/2 杯`)无法与分数区分,**需要时请在源文本里写清楚**。这是刻意的选择:读错成"4分之3"比读成"1 或 2"更不容易误导。 ### 启动问候 插件启动后会朗读一句问候。它**不是预置音频,而是一次真实的合成** —— 这一点是刻意的,因为它同时干两件事: | 目的 | 说明 | |---|---| | **① 证明插件真的活了** | 一次真实合成会走完整条链路:插件加载 → 引擎应答 → 权重与参考音频可解析。任何一环坏了,问候就不会响 | | **② 引擎预热(更重要)** | GPT-SoVITS 的**第一次 `/tts`** 才加载权重、BERT、CNHuBERT —— 那 10–30 秒冷启动就是花在这里。**问候把这笔开销提前付掉了**,等你真正提问时模型已经就位 | #### 用轮询代替一切时序猜测 **这条设计是被三次失败逼出来的。** 之前每一版都在猜"引擎什么时候会就绪": | 版本 | 做法 | 为什么失败 | |---|---|---| | ① | 客户端挂载后延迟 1.2 秒触发 | 引擎空转,模型没开始加载,那 1.2 秒纯浪费 | | ② | 宿主启动后固定 1.5 秒预热 | **实测引擎要 19 秒才绑定端口** → 请求打空,每次冷启动都丢 | | ③ | 轮询引擎就绪(`waitForEngine`) | 仍然假设"启动是唯一竞争者";而且 `greetingPromise` 失败后没重置 → **永久卡死** | **现在的规则只有一句话:引擎空闲就生成,一开始干活就让路。** | 环节 | 参数 | 说明 | |---|---|---| | 客户端询问 | **每 500ms** | 宿主**立即**返回一个 `state`,不会阻塞、不会堆积 | | 宿主判断引擎状态 | **每 100ms** | **纯读本地变量**(`engineQueueDepth` + 忙/闲时间戳),零开销 | | 引擎连通性探测 | **每 2 秒**(100ms 节拍的第 20 次) | `probeHealth` 是一次 HTTP 往返,**10 次/秒会干扰它本要观测的合成** | | 识别到引擎开始工作 | **立即让路** | `greetingGate()` 返回 `engine-busy`,问候语不抢引擎 | `?action=greeting` 的 `state` 会直接告诉你它在等什么: | state | 含义 | |---|---| | `probing` | 还没确认引擎是否可连 | | `engine-down` | 引擎没起来 | | `engine-busy` | **引擎正在干活,问候语让路** | | `synthesizing` | 问候语正在合成 | | `ready` | 音频就绪,附带 `url` | | `disabled` | 设置里关掉了 | **实测(真实引擎,冷启动)**: ``` t+0.0–2.1s state=probing ← 等待连通性确认 [info] engine busy ← 确认空闲,开始合成 [info] greeting ready (126764 bytes) — engine warm t+26.2s ✅ READY 126764 B RIFF ← 真实 WAV ``` 26 秒就是冷启动加载权重 + BERT + HuBERT 的时间 —— **这笔开销在问候语里付掉了**。 #### 一个致命的坑:不能嵌套引擎队列 `withEngine` 是**串行队列**,而 `speak()` 内部**自己会入队**。所以这样写会**死锁**: ```js await withEngine(() => speak({ ... })) // ❌ 外层占着队列,内层等外层释放 ``` 实测症状:`greeting.state` **永远停在 `synthesizing`**,`busy=True`,60 秒毫无进展。修法是**直接调用 `speak()`**,让队列只在它内部入队一次。 > 自检里有一条断言锁死这点:**全文件只允许一个 `withEngine(` 调用点**,且禁止 `withEngine(() => speak`。 | 其他行为 | 说明 | |---|---| | 每会话一次 | `sessionStorage` 标记 + 模块级闸门(防止多标签页竞态)。**刷新页面不会重复问候**,重开 DSH 才会 | | 文案 | 默认「你好,欢迎回来」,设置页可改、可关(`greetOnStart` / `greetText`) | | 失败时 | 宿主仍返回 **HTTP 200 + `ok:false`**。问候只是锦上添花,**不能因为合成不出来就让插件看起来是坏的** | ### 只有真机才会暴露的坑(已修) 这些都不是推测,是诊断日志量出来的运行时事实。列在这里是因为**每一条都对应一个"看起来完全正常、就是不响"的症状**: | # | 坑 | 症状 | 修法 | |---|---|---|---| | 1 | `turnTail` 是 list 槽位,**每轮渲染一次**,N 个驱动器抢一个单信道播放器 | 时响时不响 | 改为**模块级共享占位集合**:谁先看到谁朗读 | | 2 | 用**每实例**的 `armed` 标志判断"刚被打开" | N 份状态互不可见 → **全都只记录、没人朗读** | 闸门改用**持久化的"上次已读"标记**(全局共享) | | 3 | 组件在**调用 hook 之前 `return`**(`if (typeof turn !== "number") return null` 在 `useChat(...)` 之前) | 违反 Hooks 规则 → React 抛错 → **错误边界静默卸载** → 按钮和驱动器**在界面上根本不存在** | 所有 hook 无条件提前调用;并加**静态断言**禁止"hook 前早返回" | | 4 | `turnTail` 的 `turn` prop **实测是 `object` 而非 number** | 类型判断永远成立 → 驱动器永不工作 | **彻底不依赖该 prop**,改用占位集合协调 | | 5 | 诊断探针**无节流**,每次渲染发一次 HTTP | 一次页面加载刷出**几千条**日志与同步写盘 | 按事件名去重 + 每副本只报一次 | | 6 | 引擎绑在 DSH 生命周期上 | **每次重启 DSH 就静音** | 引擎改为跟随 DSH 起停(见上) | | 7 | **两个切分器打架**:插件把句子攒到 110 字发一个请求,引擎又用 `cut5` 再切一次 | **上一句念一半被挤掉**,或某句**完全没念** | **一句一请求 + `cut0`**:只留一个切分器(见下) | | 8 | 集成测试**隐式依赖权重状态**:新加的测试先合成了同一套权重,后面的权重切换测试就测不到 setter 调用 | 测试报 `no weight switch was issued`(**测试自身的顺序问题,不是产品 bug**) | 会合成的新测试一律排在权重切换测试**之后**,并在注释里写明原因 | 回归测试兜底:假 store + 假 React effect + 假 Audio,验证"3 个驱动器挂载、**恰好 1 个**发声"、"开关关闭时静默"、"已存在的回复不重读"。 ### 状态目录与生效方式 | 事项 | 说明 | |---|---| | 设置/音频默认位置 | `$DSH_HOME/gpt-sovits`;宿主进程若读不到 `DSH_HOME`,回退到 `~/.dsh/gpt-sovits` | | 宿主半身改动 | `dsh-gpt-sovits` 是 `file:` 依赖,pnpm 会**复制**而非链接,所以改工作区源码不影响已装副本;改完要重新同步:直接覆盖 `~/.dsh/profiles/desktop/node_modules/dsh-gpt-sovits/`,或先 `remove` 再 `add` | | 客户端半身何时出现 | 客户端 bundle 在启动图里合成,**需要刷新页面** | | 宿主半身何时重载 | 安装时插件会即时挂载(路由立刻可用);但已加载的模块代码**不会热重载**,代码改动需重启 DSH | ### 句间停顿从哪来,以及怎么压掉 **症状**(用户实测):句子与句子之间的间隔太长。 **两个来源,各自处理**: | 来源 | 为什么产生停顿 | 处理 | |---|---|---| | **串行节奏** | 旧实现是 `await 合成` → `await 播放` → 下一句。每句开头都有一段**合成空窗**,句子越多累积越明显 | **预取流水线**:播放第 N 句时,第 N+1、N+2 句已经在合成(`PREFETCH_AHEAD = 2`) | | **极短句各占一个请求** | "好的。""然后呢。""完成了。" 各发一个请求,每个都带一份合成开销 | **定稿打包时合并**:按 `SENTENCE_BLOCK_MAX = 160` 字符合并,短句不再一人一个请求 | **关于"并行"的现实**:`api_v2.py` 是 **GIL 限制的 Python 进程,单次合成内部无法并行** —— 并行的空间只在**请求层面**。预取窗口也因此**刻意设得很小**:引擎本来就是一次处理一个请求(`workers=1`),多发几个不会让它更快,只会让"取消上一轮"更迟钝。 **回归测试**(让播放变慢,然后问"同时最多有几个合成在飞"): ``` ✓ client: synthesis runs ahead of playback, so sentences do not gap — 3 requests, up to 2 in flight at once ``` 串行实现**永远只能到 1**,所以这条断言真正锁住了这个修复。 ### 设置页会显示运行状况 设置 → 语音朗读(GPT-SoVITS)顶部新增**运行状况**面板,**每 2 秒刷新**: | 字段 | 回答什么问题 | |---|---| | 引擎 | 通不通 | | **正在生成** | 引擎忙不忙(单 worker,忙 = 有请求在排队或处理中) | | **启动问候** | `idle` / `synthesizing` / `ready`,含字节数与错误 | | 当前音色 | 音色名 + **参考音频是否存在** | | **已加载权重** | 引擎当前持有哪对 GPT / SoVITS 权重 | | **最近生成的语句** | 时间 + 耗时 + 文本;`缓存命中` 表示没走引擎 | **为什么加这个**:之前"问候语没出现"**无法从外部判断** —— 模型在加载、试音占着唯一的 worker、音频已合成但没被播放,这三种情况看起来完全一样,只能翻日志。现在一眼可辨。 > 面板读取的是新增的 `GET /gpt-sovits/api?action=status` 路由。 ### 引擎控制台(内嵌在设置页) 原本要看引擎在干什么,得自己开一个 `api_v2.py` 的控制台窗口。**现在引擎的完整输出被捕获,显示在 DSH 右侧栏的一个标签页里。** | 标签 | 内容 | |---|---| | **引擎输出** | `api_v2.py` 的 stdout/stderr:TTS 配置、权重加载、每次合成的目标文本、访问日志、报错堆栈 | | **启动器** | 监督进程的判定:spawn 命令、引擎 pid、`ready=True`、`watching DSH pid N`、停止记录 | | **生成记录** | 每次合成的时间 / 耗时 / 音色 / 文本;`cached` 表示没走引擎 | **为什么不用独立控制台窗口** —— 这个方案被否掉有两个硬理由: | 问题 | 说明 | |---|---| | **抢焦点,且关掉会杀引擎** | 独立窗口会在启动时抢走焦点;更糟的是**关闭它等于杀掉引擎** —— 因为那个窗口**就是**该进程的控制台 | | **之前根本看不到** | 监督进程把引擎输出丢进了 `DEVNULL`,所以从 UI 完全看不见引擎侧的问题,日志里只有一句 `ready=True` | **为什么也不是右侧栏标签页** —— 这条路走过,也被否掉: | 问题 | 说明 | |---|---| | **让 DSH 显示有后台任务在跑** | 注册标签会让界面出现"有任务运行中"的观感 | | **重启后插件可能起不来** | `sidebarRightTabs` 是宿主组合出来的客户端能力。缺少它的构建里,插件带着这个缺失加载,**可能整个插件失效** | **最终方案:内嵌在设置页**。它是普通的 slot 内容,不依赖任何外壳能力,**不可能影响启动**。 现在:输出落盘到 `$DSH_HOME/gpt-sovits/engine-output.log`(**每次启动截断**,只显示本次运行),面板刷新增设三档: | 档位 | 用途 | |---|---| | **1.5 秒**(默认) | 日常观察 | | **0.5 秒** | 想看清楚正在发生什么(你说刷新不及时,为此加的) | | **手动** | 想静静读一段日志时,不要它自己滚 | 另有**暂停**按钮(保留当前内容不再覆盖)与**立即刷新**。 #### 中文乱码是怎么修的(你报的 bug) **症状**:控制台里的中文全是乱码。 **根因**(实测原始字节): ``` 按 UTF-8 读: address ('127.0.0.1', 9880): ͨ��ÿ���׽��ֵ�ַ(Э��/�����ַ/�˿�)ֻ����ʹ��һ�Ρ� 按 GBK 读: address ('127.0.0.1', 9880): 通常每个套接字地址(协议/网络地址/端口)只允许使用一次。 ``` **Python 在 Windows 上按控制台代码页(cp936)输出 stdout**,而面板按 UTF-8 解码 —— 于是包括报错在内的中文全部乱码,**而报错恰恰是出问题时最需要读的内容**。 **两层修复**: | 层 | 做法 | |---|---| | 根源 | 监督进程 spawn 引擎时设 `PYTHONIOENCODING=utf-8` + `PYTHONUTF8=1` —— **让引擎直接输出 UTF-8** | | 兜底 | 面板读取时检测替换字符 `U+FFFD`,命中则按 GBK 重解 —— **兼容旧监督进程留下的日志** | 修复后实测同一句中文字符串完整显示,**零替换字符**。 ### 已知限制 - **Windows 下会枚举盘符以自动发现引擎**(`lib/index.js` 的 `engineRootCandidates()`)。如果你没有在设置里填「检出目录」也没有设 `DSH_SOVITS_ENGINE_ROOT`,插件会**从 `A:\` 到 `Z:\` 逐个尝试读取盘符根目录**,挑出名字匹配 `/^gpt[-_]?sovits/i` 的目录作为候选。 它的实际行为比听起来克制: | 项 | 实际行为 | |---|---| | 读什么 | **只读盘符根目录这一层**(`readdirSync`),不递归、不遍历子目录树 | | 拿什么 | 只拿**目录名**,用于匹配;不读取文件内容 | | 不存在的盘 | `A:\`、`B:\` 之类的空软驱会在 `try/catch` 里被跳过(有些机器的 A: 是软驱,读它会弹「请插入磁盘」) | | 何时执行 | 只在访问「模型列表」和打开设置页时;`listWeights()` 命中第一个合法检出即停止 | | 写盘吗 | **完全不写**。这条路径上没有任何写入操作 | **想让它彻底不扫**:在设置页「检出目录」里填上你的 GPT-SoVITS 路径(或设环境变量 `DSH_SOVITS_ENGINE_ROOT`)。显式配置排在候选列表最前,命中后盘符扫描不会执行。 之所以这么设计:官方 Windows 包是个压缩档,人们解压到哪儿都有(常见是副盘根目录),没有固定安装位置;而且路径必须是**引擎进程**能读到的,让用户手输容易出错。 - **引擎冷启动时,屏幕上会反复闪出 `ping 127.0.0.1` 的 cmd 窗口** —— 这是**预期行为,不是故障**,但确实不好看。 原因在启动器 `start-engine.bat` 的等待循环:它用 `ping -n 4 127.0.0.1 >nul` 当延时(**不能用 `timeout`** —— 那个在 stdin 被重定向时会立刻退出,见上文那个坑)。而 `ping.exe` 是控制台程序,**每轮轮询都会新起一个进程**,于是每轮都可能闪一个窗口。模型加载越久,闪的次数越多 —— 冷启动通常 **30–45 次**(每次间隔约 4 秒,最多 60 次)。 | 情况 | 会不会闪 | |---|---| | DSH 启动时引擎**还没起** | ✅ 会闪,直到端口就绪 | | 引擎**已在运行** | ❌ 不闪 —— 启动器检测到端口已监听会**立即返回**,一次轮询都不做 | | 由计划任务 / 启动文件夹拉起 | 一般不闪(`run-hidden.vbs` 用 `shell.Run(..., 0, ...)` 隐藏窗口),但若同时还有插件那条路径在跑,仍可能闪 | **不想看到它**,任选其一: 1. **让引擎先起来再开 DSH** —— 引擎跑着时,插件这条路径一次都不轮询(最省事) 2. **把引擎交给开机自启** —— 登录时由计划任务或启动文件夹拉起,等 DSH 启动时端口已在监听 3. **关掉插件的开机自启** —— 删掉启动文件夹里的 `GPT-SoVITS 语音引擎.lnk`,改为手动双击 `启动语音引擎.cmd` 4. **换掉那个延时** —— 把 `start-engine.bat` 第 92 行的 `ping -n 4 127.0.0.1 >nul` 换成别的等待方式(例如 PowerShell 的 `Start-Sleep`) > 之所以没在插件里直接"修掉":`windowsHide: true` 与 `detached: true` 组合下,子进程自己新建的控制台窗口是否被抑制,在不同 Windows 版本上表现不一致;而这段逻辑一旦改错,代价是**引擎起不来、语音全程沉默** —— 比多闪几个窗口严重得多。所以这里记录现象与出路,而不是拿一个能跑的启动器去冒险。 - 合成缓存是进程内 `Map` + 落盘 WAV,上限 120 条;重启清空索引(WAV 仍在 `audio/`)。 - 播放倍速依赖浏览器的 `preservesPitch`;不支持时锁定 1×(设置页会说明)。 - `prompt_text` 留空时首次合成很慢(引擎要先做 ASR),之后走缓存恢复正常。 --- ## 它是怎么来的 **这只鲸鱼娘自己给自己装了一副嗓子。** 这个插件的每一行代码,都是在 DSH 里由 **DeepSeek(`deepseek-flash`,DeepSeek 官方 provider)** 与作者对话逐轮写出来的 —— 包括读外壳源码确定槽位契约、量出 store 的真实结构、定位那串"看起来完全正常就是不响"的 bug,以及顶着一次次失败把 i18n、自检和集成测试补齐。 作者做的事是:提需求、配好引擎和音色、在真机上点按钮、把报错截图发回来。**声音是作者训练的,嗓子是它自己装的。** 值得注意的是,它一开始**并不了解自己的宿主**:第一次尝试把 `snapshot.legacy.nodes` 当成节点容器(那是另一个 DSH 版本、另一个插件留下的写法),连错四轮;最后是靠一个写进宿主的**诊断探针**把容器的真实成员列表打出来才修对。README 里那张"坑"表,就是这么攒出来的。 --- ## ✅ 测试通过的运行环境 > **下表是这份代码真实跑通、并且测试全部通过的环境** —— 不是"推荐配置",也不是从文档抄来的。 > > 下列每一项都由本机实测采集;配套的 **61 项离线自检**与 **16 项真实引擎集成测试**都在这一组合下通过,界面按钮与自动朗读也在这台机器上由作者实际点击验证。 > > 换到别的 OS、别的 DSH 版本或别的显卡,**理论上可用但未经测试** —— 若遇到问题欢迎开 issue 附上你的环境信息。 | 项目 | 测试通过的实际值 | |---|---| | 操作系统 | **Windows 11 专业版** `10.0.26200`(64 位) | | 宿主 | **DSH 桌面版**(Electron),profile = `desktop` | | 宿主客户端契约 | `@deepseek-ai/dsh-client-*` **0.2.0-rc.2** | | Node.js(插件运行时) | **v24.21.0**(DSH 自带运行时;`engines` 要求 `>=22`) | | GPU | **NVIDIA GeForce RTX 5070 Ti**,16303 MiB 显存,驱动 616.92 | | Python(引擎侧) | **3.9.13**(GPT-SoVITS 集成运行时) | | PyTorch / CUDA | **2.7.0+cu128** / **CUDA 12.8** | | GPT-SoVITS 检出 | `GPT-SoVITS-v2pro-20250604-nvidia50`(含 `GPT_weights` … `v4`、`SoVITS_weights` … `v4` 全部权重目录) | | 实测使用的模型版本 | **v2Pro** | | 引擎监听 | `http://127.0.0.1:9880` | | 插件版本 | v0.3.2 | **实测性能**(同一台机器,`sample_steps 32`、参考文本已填): | 场景 | 耗时 | |---|---| | 引擎冷启动(加载权重) | 约 **12–30 秒** | | 首次合成(参考音未识别过) | 约 **20 秒** | | 之后的新句子 | **1.0–1.6 秒** | | 重复的句子(缓存命中) | **0 秒** | > 引擎侧显存占用实测约 **2.7 GB**(v2Pro 权重 + BERT + CNHuBERT)。 --- ## 引用与致谢 本项目的代码以 MIT 发布,其中**有一处是实质性复用他人实现**,其余为接口契约与布局常量的借鉴。**完整清单(含具体文件、具体语句、以及我做了哪些改动)见 [THIRD-PARTY-NOTICES.md](../THIRD-PARTY-NOTICES.md)** —— 这里只给摘要: | 来源 | 许可证 | 复用了什么 | |---|---|---| | [`MaRi23333/dsh-fish-tts`](https://github.com/MaRi23333/dsh-fish-tts) | MIT | **`cleanForSpeech` 的文本清洗正则链 —— 十来个正则逐字符相同**(`lib/client.js` 的 `cleanForTts`),以及 `window.__ModuleLoader__.load` 的 bundle 形态 | | [`fangqian616/dsh-say`](https://github.com/fangqian616/dsh-say) | MIT | **GPT-SoVITS `/tts` 请求体与默认值**、`GET /control`(HTTP 错误视为存活)的探活方式(`lib/engines/gptsovits.js`) | | DSH 内置 `@deepseek-ai/dsh-client-*` | MIT | 插件注册模式(`ctx.slots.inject` / `register` / `settings.section`)、注入样式表的做法、**操作条按钮与图标契约的几何常量**(28px 盒 / 6px padding / 15px 字形 / `size=16` / 1px `currentColor`)、以及"读 `snapshot.nodes.values()`"这一句 | | [`MeteorNOX/DeepSeek-Balance-Whale-Widget`](https://github.com/MeteorNOX/DeepSeek-Balance-Whale-Widget) | MIT | `DSH_HOME \|\| ~/.dsh` 的两级状态目录回退 | | [`RVC-Boss/GPT-SoVITS`](https://github.com/RVC-Boss/GPT-SoVITS) | MIT | `api_v2.py` 的端点契约(没有代码被复制) | 下面这几个项目**只作为参考阅读,没有复用代码**,但值得致谢: [`TaoruiLiu19/dsh-gsv-tts`](https://github.com/TaoruiLiu19/dsh-gsv)(证明了宿主/客户端分半这条路可行)、 [`maoyuching/dsh-voice-chat`](https://github.com/maoyuching/dsh-voice-chat)、 [`haide-D/SillyTavern-EchoCore`](https://github.com/haide-D/SillyTavern-EchoCore)。 **唯一运行时依赖**:`@deepseek-ai/schemastery`(MIT,© DeepSeek),从 registry 安装、未内联。 --- ## 许可与第三方声明 - 本项目是**第三方社区插件**,与 DeepSeek、GPT-SoVITS(RVC-Boss)及其各自的维护者**无任何隶属、合作或背书关系**。"DeepSeek"、"DeepSeek Harness" 与 "GPT-SoVITS" 等名称归其权利人所有,此处仅为描述性使用。 - 本插件**不分发、不托管任何模型权重、参考音频或合成音频**,也不包含任何语音克隆服务。它只是把你**自己**的本地引擎接到 DSH 界面上。 - **仅可使用你有权使用的声音**:未经授权,不得克隆、模仿或合成公众人物、名人或他人的声音。参考音频与训练权重的权利与合规责任完全由使用者承担。 - **合成音频可能被误认为真人发声**:对外分发时建议主动披露其为 AI 合成内容。 - 本项目代码以 **MIT** 许可发布;你的模型、参考音频与生成音频**不在**该许可覆盖范围内。 --- ## 附录:各项功能的实现方式 前面各节讲的是**为什么这么做**,这一节讲**具体怎么做** —— 文件、函数、参数、数据结构。想改代码的人从这里开始。 ### 目录与分工 ``` lib/ ├─ index.js 宿主半身:HTTP 路由、引擎调用、权重切换、监督进程管理、日志读取 ├─ client.js 客户端半身:槽位组件、播放器与队列、文本管线、设置页 UI └─ supervisor.py 监督进程:无窗口启动引擎、就绪等待、生命周期绑定、日志捕获 scripts/ ├─ selfcheck.mjs 61 项离线自检(静态断言 + VM 沙箱行为测试) └─ integration.mjs 16 项真实集成测试(真 HTTP 载体 + 真引擎) ``` **为什么要分三个文件**:宿主与客户端运行在**不同进程/环境**(宿主是 Node,客户端是浏览器)。`supervisor.py` 再单独一层,因为它必须在 DSH **之外**存活才能管住引擎的生命周期。 ### 1. 逐条朗读按钮 | 项 | 实现 | |---|---| | 槽位 | `conversation.chat.assistant-actions`,`order: 20` | | Props | `{ messageId, useChat, t }` —— 由外壳注入,不是插件提供的 | | 取文本 | 单次 hook 调用:`useChat((snapshot) => selectText(snapshot, messageId))`,返回**原始字符串** | | 图标 | 本地绘制(外壳 377 个图标里**没有喇叭**)。遵循外壳图标契约:16px 视框、`fill:none`、1px `currentColor` | | 几何 | 对齐外壳自己的操作按钮:28px 盒、6px padding、15px 字形 | | 位置 | `placeAfterBranch()` 在挂载后把按钮**移到分支按钮之后**(槽位默认给的位置在它之前) | | 常显 | 清掉操作条的内联 `opacity`(外壳 `[data-actions-reveal=hover]` 会把整排隐藏) | **为什么用 DOM 移动而不是 CSS `order`**:那一排里还夹着 Tooltip 包装层、无障碍文本、用量信息,纯 CSS 排序很脆。移动自己的节点只需一次 DOM 操作,找不到分支按钮就原地不动、不报错。 ### 2. 文本管线(`lib/client.js`) 按顺序执行,**顺序本身就是正确性的一部分**: ``` 原始 Markdown ↓ speakNormalize(text, words) ↓ cleanForSpeech(..., words) ↓ splitIntoBlocks() / splitIntoChunks() ↓ 播放队列 ``` **`speakNormalize(text, words)`** —— 符号转口语,内部有严格的执行次序: | 步骤 | 动作 | 为什么在这个位置 | |---|---|---| | 1 | **遮蔽日期**(`2024-10-03` → 占位符) | 否则区间规则会把它切成 `2024-10到03`(负向断言试过,不可靠) | | 2 | **遮蔽 URL / 路径 / UUID** | 否则斜杠规则会把 `https://a.com/b` 念成「链接 或 或 a点com 或 b」 | | 3 | 文件扩展名 → `点exe` | 必须早于 `cleanForSpeech`,那一趟会把长标识符塌缩成"长代码" | | 4 | 分数 → `4分之3` | 必须早于"或",否则 `3/4` 读成「3 或 4」 | | 5 | 其余斜杠 → `或`;反斜杠 → `杠` | 真分数已排除,"或"是安全默认值 | | 6 | 版本号 → `v2点7点0` | 必须早于通用小数规则 | | 7 | 区间 → `3到10` | `\b` 限定数字边界,避免咬到 `abc-def` | | 8 | IP → `127点0点0点1`,通用小数 → `36点5` | 排最后,只处理剩下的点 | | 9 | **还原遮蔽的 token** | 读回原样 | **`cleanForSpeech(text, words)`** —— 剥 Markdown:代码围栏整块丢、行内代码去反引号、图片丢、链接留文字、URL/路径/UUID/长标识符替换成词、标题/引用/列表/分隔线标记去掉、HTML 标签去掉、强调符去掉、空白折叠。 **`splitIntoChunks(text)`** —— 按句末标点(`。!?!?;;…`)与换行切。**超长句先在从句标点(`,、;:—–`)断开**,阈值为 `SENTENCE_SOFT_MAX = 280`。 **`splitIntoBlocks(sentences, max)`** —— 定稿打包:把连续整句合并到 `SENTENCE_BLOCK_MAX = 160` 字符一块。**绝不在句子中间切**;单句超过上限时自己成一块。 ### 3. 播放器与队列(`SovitsPlayer`) ``` pending[] 待播项 { text, key, splitMethod } queued Map draining 是否有 drain 在跑(单例) generation 世代计数,stop() 时 +1 → 作废飞行中的合成 ``` **`enqueue(key, text, words, complete)`** —— 自动朗读的唯一入口: 1. 取该消息**已入队偏移** `queued.get(key)`,算出新增部分 2. 只取到**最后一个句末标点**为止(`complete` 时放宽到段落换行)。半句话不入队,否则会被合成一次、写完后再说一次 3. `cleanForSpeech(speakNormalize(...))` 清洗 4. **分流**:`complete ? 打包成块 + cut5 : 逐句 + cut0` 5. 入 `pending`,更新偏移,触发 `drain()` **`drain(words)`** —— 预取流水线: ``` keepAhead() 最多 PREFETCH_AHEAD=2 个合成在飞 while (ahead 非空): item = ahead.shift() url = await item.promise ← 此时该合成早已开始,与上一句播放重叠 keepAhead() ← 立刻补位,下一个合成与本句播放重叠 await playClip(url, generation) if (generation 变了) return ← 被 stop() 作废 ``` **`finally` 里用 drain 自己的 `generation` 比较**再决定是否重启 —— 写成 `this.generation === this.generation` 会让被取消的队列复活(踩过)。 **`stop()`** —— 停音频、清 `pending`、清 `queued`、`generation += 1`。 ### 4. 自动朗读驱动器 `conversation.chat.turnTail` 是 **list 槽位**,外壳**每个回合渲染一次** → N 个副本同时存在,而播放器是单信道。协调方式: | 机制 | 作用 | |---|---| | `CLAIMED`(模块级 Set) | 一条回复**归队列所有**,直到队列念完才释放 | | `lastRead`(localStorage) | "上次已读"标记,**刷新页面、切换会话都不重读** | | `selectLatestState` | 单次 hook 返回 `"|<0/1>"` **原始字符串** —— 对象选择器每次渲染都是新身份,会破坏 store 的变更比较 | | `turnCount` | `snapshot.timeline.turnOrder.length`,外壳自己判断轮次用的就是这个 | **所有 hook 无条件调用,在任何 early return 之前** —— 违反 Hooks 规则会让 React 抛错、错误边界静默卸载,整个组件消失(这是按钮曾经完全不出现的真实原因)。 ### 5. 新一轮取消 ``` turnOrder.length 增长 → CLAIMED.clear() → player.stop() 停音频、清队列、清偏移、generation+1 → diag('turn-reset') ``` `stop()` 只在**轮次边界**调用,**轮次内不打断**。它同时作废飞行中的合成,所以旧回合的音频不会在新回合开始后才播出来。 ### 6. 分流与宿主侧切分控制(`lib/index.js`) 请求体带 `splitMethod`,宿主映射到引擎参数: ```js text_split_method: splitMethod === 'cut5' ? 'cut5' : 'cut0' ``` | 来源 | 传入 | 理由 | |---|---|---| | 流式增长 | `cut0` | 已是完整单句,再切就是两套边界打架 | | 定稿总结(打包块) | `cut5` | 块内由引擎按标点切 | | 手动点 🔊 | `cut5` | 一次性给整段文本 | | **未指定** | **`cut0`** | 默认不能切:意外切分正是当初截断的根因 | ### 7. 权重切换 - 权重是**引擎全局状态**(`set_gpt_weights` / `set_sovits_weights`,**只接受 GET**) - 记录 `activeGpt` / `activeSovits`,**成对未变则跳过** —— 否则每次朗读都重载模型 - `api_v2.py` 是 `workers=1`,所以所有引擎操作走一条**串行队列** `withEngine()` > **不能嵌套 `withEngine`**:`speak()` 内部自己入队,`withEngine(() => speak(...))` 会让外层占着队列等内层、内层等外层 → **死锁**(实测卡 60 秒以上)。自检里有一条断言锁死"全文件只允许一个 `withEngine(` 调用点"。 ### 8. 启动问候(每 0.5 秒轮询 + 0.1 秒状态检测) ``` 客户端 宿主 │ GET ?action=greeting ────────→ greetingGate() │ ├─ disabled 关掉了 │ ├─ ready 已有音频 │ ├─ synthesizing 正在合成 │ ├─ engine-busy ← 引擎在干活,让路 │ ├─ probing 还没确认可连通 │ └─ idle → 开始合成 │ ←── { state, url?, bytes? } ──── (立即返回,绝不阻塞) │ └─ 每 500ms 再问一次,直到拿到 url 就播放并停止 ``` | 参数 | 值 | 位置 | |---|---|---| | 客户端询问间隔 | **500 ms**(最多 240 次 ≈ 2 分钟) | `GREETING_RETRY_MS` / `GREETING_ATTEMPTS` | | 引擎状态检测 | **100 ms** | `FAST_MS` | | 连通性探测 | 每 2 秒(100ms 的第 20 次) | `PROBE_EVERY` | **为什么状态检测要分两档**:忙碌判断**纯读本地变量**(`engineQueueDepth` + 时间戳),零开销;而连通性是一次 HTTP 往返,**10 次/秒会干扰它本要观测的合成**,所以走慢档。 **每会话一次**:`sessionStorage` 标记 + 模块级闸门(防多标签竞态)。刷新页面不重读,重开 DSH 才重读。 **失败不报错**:宿主返回 HTTP 200 + `ok:false` + `state`。问候是锦上添花,不能因为合成不出来就让插件看起来是坏的。 ### 9. 引擎生命周期与监督进程(`lib/supervisor.py`) ``` 插件启动 → spawn pythonw.exe supervisor.py --adopt-pid ... (detached,无窗口) → 监督进程:若端口已被占但无响应则清理;否则 spawn api_v2.py → 轮询就绪(socket 连接,非 ping)→ 写 engine.pid → 每 2 秒 OpenProcess 检查 DSH 是否还在 DSH 退出 → 杀掉引擎 → 清理 pid 文件 → 自己退出 下次启动 → 读 engine.pid,若记录的 DSH 已死,先回收遗留引擎 ``` **用到的 Win32 调用**(都通过 `ctypes`,不新起进程): | 目的 | API | 为什么不 shell out | |---|---|---| | 判断进程存活 | `OpenProcess` + `GetExitCodeProcess` | `tasklist` 文本匹配**把活着的进程误判为已死**,导致监督进程启动完引擎就退出 | | 查端口持有者 | `GetExtendedTcpTable` | `netstat` 解析对活着的监听者**返回空集** | **无窗口**:`pythonw.exe` + `CREATE_NO_WINDOW` + `stdout/stderr` 重定向到文件。早期用批处理 + `ping` 做延时,`ping.exe` 是控制台程序,冷启动会闪 30–45 个窗口。 **UTF-8**:spawn 引擎时设 `PYTHONIOENCODING=utf-8` + `PYTHONUTF8=1`,否则 Windows 上 Python 按 cp936 输出,面板按 UTF-8 解码 → 中文乱码。 **代理绕过**:`urllib` 默认读系统代理。实测系统代理会应答 `127.0.0.1:9880/control`(返回 404,而当时**根本没有进程在监听**)。监督进程与插件对 loopback 都显式绕过代理。 ### 10. 路由一览(全部仅限 loopback) | 方法 | 路径 | 作用 | |---|---|---| | `GET` | `?action=settings` | 读设置 | | `POST` | `?action=settings` | 存设置(额外校验同源) | | `GET` | `?action=models` | 发现检出目录里的训练权重 | | `POST` | `?action=synthesize` | 合成一段文本,返回 `{ url, bytes, cached }` | | `GET` | `?action=health` | 探测引擎 | | `POST` | `?action=ensure-engine` | 请求拉起引擎 | | `GET` | `?action=greeting` | 问候语状态 / 音频(**立即返回**) | | `GET` | `?action=status` | 运行状况:引擎、忙碌、问候语、权重、合成历史 | | `GET` | `?action=logs` | 引擎输出 + 启动器日志 + 生成记录(`&lines=N`,上限 2000) | | `GET` | `/gpt-sovits/audio/.wav` | 播放合成结果(`id` 是内容摘要) | ### 11. 音频缓存(内存) - **键**:`文本 + 音色名 + GPT 权重 + SoVITS 权重 + 参考音频 + 参考文本 + 参考语言 + 朗读语言 + 语速 + 采样步数 + 引擎地址 + 逐句情感` 的摘要 - **位置**:**只在内存**(`Map`),不落盘 - **上限**:条数 240 **且** 总字节 64 MiB,最旧优先淘汰 - **为什么权重进键**:同一句话在不同模型下是**不同的音频**,不能互相顶替 - **为什么情感也进键**:同理,不同温度是不同音频 - 详见 [13. 内存音频](#13-内存音频audiocache-evictaudio) ### 12. 分句与停顿(`PAUSE_MS` / `segmentSentence` / `splitIntoSentences`) **分句用「匹配」而不是「切分」**: ```js const SENTENCE_END_RE = /[。!?!?;;…]+[”’"』」))】》〉\]]*|$/g ``` 终止符的 `+` 与尾随引号都在同一个匹配里,所以 `……`、`!?`、`……。”` 都**留在句尾**。用先行/后顾断言切分试过,结果是 `他沉默了三秒…` 加一个孤立的 `…`,引擎把它当两句话说。 | 规则 | 实现 | |---|---| | 段落是硬边界 | 先按 `\n\s*\n+` 切段,段内单换行(软换行)视作空格 | | 段末停顿最长 | 非末段的最后一句 `pause` 改写为 `paragraph` | | 超长句降级 | `> SENTENCE_SOFT_MAX`(280) 时在从句标点断开,最后一段保留整句停顿 | | 短句合并(罕见) | 仅当 ≤ `SENTENCE_MERGE_MAX`(10) 且待合并片段还没有强停顿时 | | 段首标点剥离 | `tidy()` 去掉开头的 `。!?,…` —— 否则引擎会念出一个空句 | **停顿由插件插入,引擎不加静音**: ```js const PAUSE_MS = { paragraph: 520, period: 340, exclamation: 340, question: 340, ellipsis: 460, semicolon: 240, colon: 200, comma: 150, none: 120 }; ``` 请求里 `fragment_interval: 0` 关掉引擎自己的分段静音(默认 **0.3 秒**)—— 那才是句间长且不均匀的真正来源:用 `cut5` 交出去的块在引擎内部仍会被切,**每个内部分界都加 0.3 秒**。 **尾部静音裁剪**(`audibleEnd`): 1. `fetch` 音频 → `AudioContext.decodeAudioData` 2. 从末尾向前找第一个 `|sample| > 0.001` 的位置 3. 加 0.02 秒余量(避免切掉尾音),得到 `end` 秒 4. 播放时 `ontimeupdate` 到 `end` 就 `pause()` 并手动 resolve —— `pause` 不触发 `ended` 结果缓存在 `trimCache`(键是内容摘要 URL),失败一律返回 `null` → 整段播放。**宁可不动,也不要裁错**。 ### 13. 内存音频(`audioCache` / `evictAudio`) ```js const audioCache = new Map() // clipId -> { buffer, contentType, createdAt } let audioCacheBytes = 0 const AUDIO_CACHE_LIMIT = 240 const AUDIO_CACHE_BYTES = 64 * 1024 * 1024 ``` | 项 | 做法 | |---|---| | 存哪 | **只在内存**,不再写 `$DSH_HOME/gpt-sovits/audio/` | | 服务 | `GET /gpt-sovits/audio/.wav` 直接 `res.end(entry.buffer)` | | 淘汰 | `evictAudio()` 按条数**与**总字节双重上限,最旧优先 | | 重复插入 | 先减旧值再重新入 Map(移到队尾),不会重复计数 | | 缓存未命中 | 返回 `clip-missing` 让客户端重取,而不是空 200 | | 升级清理 | 启动时 `purgeLegacyAudioFiles()` 删掉旧版留下的 `.wav`,并记录删了几个 | **为什么条数和字节都要限**:只限条数时,一段很长的总结会让占用无界增长;只限字节时,短句会因为条数过多而频繁淘汰正在播的那句。 ### 14. 逐句参数(`express` / `expressionSettings`) | 控制 | 引擎字段 | 作用 | |---|---|---| | 语速 | `speed_factor` | 0.5–2 | | 表现力 | `temperature` | 0.05–2,越高越有起伏 | | 采样宽度 | `top_k` | 1–100 | | 核采样 | `top_p` | 0.05–1 | | 音量 | —(播放属性) | 只影响播放,不重新合成、不进键 | **客户端**:`expressionSettings()` 在**每次请求**里读当前值 → 改动**从下一句生效**,已经念过的不重做。 **宿主**:`synthesize({ ..., express })` 用 `clamp()` 逐项夹到合法区间,**不信任客户端**。缺省值与这些控件存在之前**逐字节相同**,所以不带 `express` 的请求行为不变。 **缓存键包含 `expressKey`** —— 同一句话在不同温度下是**不同的音频**,不能互相顶替。 **刻意排除**:`sample_steps`、`super_sampling`。它们描述引擎**花多少算力**,不是声音像不像;逐句不同会让同一段回答中途变清晰度。自检里有断言禁止它们进入 `express`。 ### 15. 切换会话时取消(`sessionId` 跨重挂存活) ``` sessionId 变化 → CLAIMED.clear() → player.restart() → PLUGIN_EPOCH += 1 → 界面重挂 驱动卸载 → player.stop() + 释放