🎁 福利专区全网大模型免费应用 + 新用户福利 + 注册活动入口,低成本玩转 AI

语音朗读 91koukou/dsh-gpt-sovits

DeepSeek 客户端
91koukou13 天前有提交已提供中文文档扫描:含敏感能力兼容 / 相关生态

这是本项目的完整文档。首页的精简版见 ../README.md。

34.9/100
综合分
7
GitHub 星标
—
npm 周下载
—/5
用户评分

数据截至 2026/10/21 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。

相关生态插件(非 dsh 原生,按其对应运行时安装)
git clone https://github.com/91koukou/dsh-gpt-sovits.git

状态速览

判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态
信任档位:已验证
是什么
生态应用
桌面端 / Web 外壳,不以 dsh plugin add 安装
装得上吗
本站已在 dsh 0.2.1-rc.2 下真实安装成功
非静态推断
L4 · 真实安装等级说明 →
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
活跃:最近一次提交在 13 天前
档位由哪些信号合成

本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。

验证判据与等级说明 →
社区实装还没有人打卡 · 装过的第一个就是你
安装兼容性检查需源码安装

仓库 package.json 标记 private,未发布到 npm,需从源码安装

展开查看检查明细 ↓

以下结论由程序自动检查 npm 包、engines 声明与入口文件得出,未做人工实机验证——能装不等于用着没问题。

✗npm 包dsh-gpt-sovits(未发布到 npm,仅可源码安装)
✓Node 引擎要求 >=22 · 基线 Node 22.19 满足
✓dsh CLI 依赖未声明 dsh 版本约束
✓入口文件main/exports/bin 已声明

源码安装时若 pnpm 拦截 prepare 脚本,按其提示在 pnpm-workspace.yaml 的 allowBuilds 中放行后重跑。

验证方式:npm registry 存在性 + package.json 静态校验 · 最后检查 2026/10/29 04:18:11

安全扫描自动 · 静态分析源码
v2.7.0
🔵 扫描:含敏感能力尚无人工评估
展开查看扫描说明与证据 ↓

静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。

可执行外部命令读写/删除本地文件发起外部网络请求
查看扫描证据(共 51 处,展示前 23 条)
  • 发起网络请求
    lib/index.js:140
    return fetch(url, { ...init, ...ENGINE_FETCH_INIT })
  • 发起网络请求
    lib/index.js:142
    return fetch(url, init)
  • 读取本地文件
    lib/index.js:389
    entries = readdirSync(drive, { withFileTypes: true })
  • 读取本地文件
    lib/index.js:404
    return existsSync(join(root, 'api_v2.py')) && existsSync(join(root, 'GPT_SoVITS', 'pretrained_models'))
  • 读取本地文件
    lib/index.js:423
    const python = candidates.find((candidate) => existsSync(candidate))
  • 读取本地文件
    lib/index.js:430
    yaml: existsSync(yaml) ? yaml : '',
  • 读取本地文件
    lib/index.js:454
    entries = readdirSync(root, { withFileTypes: true })
  • 读取本地文件
    lib/index.js:466
    files = readdirSync(dir, { withFileTypes: true })
  • 写入本地文件
    lib/index.js:515
    mkdirSync(stateDir, { recursive: true })
  • 写入本地文件
    lib/index.js:516
    writeFileSync(join(stateDir, 'settings.local.bat'), `${lines.join('\r\n')}\r\n`, 'ascii')
  • 写入本地文件
    lib/index.js:522
    copyFileSync(supervisorSource, join(stateDir, 'supervisor.py'))
  • 写入本地文件
    lib/index.js:558
    mkdirSync(stateDir, { recursive: true })
  • 写入本地文件
    lib/index.js:576
    renameSync(file, previous)
  • 写入本地文件
    lib/index.js:619
    mkdirSync(stateDir, { recursive: true })
  • 删除本地文件
    lib/index.js:683
    rmSync(join(dir, name), { force: true })
  • 执行外部命令来自配置/变量
    lib/index.js:1468
    const child = spawn(paths.python, args, {
  • 执行外部命令来自配置/变量
    lib/index.js:1538
    const child = spawn(paths.python, [supervisor, '--stop', '--state-dir', stateDir, '--log', engineLogPath()], {
  • 发起网络请求
    lib/client.js:998
    const response = await fetch(`${API}?action=ensure-engine`, { method: "POST" });
  • 发起网络请求
    lib/client.js:1031
    const response = await fetch(`${API}?action=synthesize`, {
  • 发起网络请求
    lib/client.js:1106
    const buffer = await fetch(audio.src).then((response) => response.arrayBuffer());
  • 发起网络请求
    lib/client.js:1171
    const response = await fetch(url, { method: "HEAD" });
  • 发起网络请求
    lib/client.js:1399
    fetch(`${API}?action=settings`),
  • 发起网络请求
    lib/client.js:1400
    fetch(`${API}?action=health`),
扫描于 2026/11/4(0 天前) · 范围 入口闭包 + 发布文件清单 · 来源 GitHub 仓库

静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。

依赖的 DSH / Cordis 模块
@deepseek-ai/schemastery
给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个

功能亮点

  • 一键朗读助手回复
  • 自动朗读
  • 音色预设

README

由 DeepSeek 最新模型翻译生成
dsh-gpt-sovits 中文 · English 这是本项目的完整文档。首页的精简版见 ../README.md。 🐋 这只鲸鱼娘,自己给自己装了一副嗓子 本项目由 DeepSeek(deepseek-flash) 在 DSH 会话中逐轮写成 —— 出主意、修 bug、写测试、读宿主源码、量自己的宿主,都是它做的。 代码是 AI 写的,声音是作者训练的。 验证状态:全部通过 ✅ —— 62 项离线自检 ✅ / 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 引擎和音色,完全离线、不经云端。
展开完整文档(README 剩余 1 节)
dsh-gpt-sovits

中文 · English

这是本项目的完整文档。首页的精简版见 ../README.md。

🐋 这只鲸鱼娘,自己给自己装了一副嗓子

本项目由 DeepSeek(deepseek-flash) 在 DSH 会话中逐轮写成 —— 出主意、修 bug、写测试、读宿主源码、量自己的宿主,都是它做的。

代码是 AI 写的,声音是作者训练的。

验证状态:全部通过 ✅ —— 62 项离线自检 ✅ / 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 引擎和音色,完全离线、不经云端。

dsh plugin --profile desktop add github:91koukou/dsh-gpt-sovits

本仓库不附带任何模型权重、参考音频或音频样本,也不替你启动引擎 —— 这些都由你自己准备。

前置条件

1. 一份已部署好的 GPT-SoVITS(api_v2.py 能跑起来)
2. 至少一对自己训练的 GPT(.ckpt)+ SoVITS(.pth) 权重,以及一段参考音频
3. DSH 桌面版或 Web 版

引擎自己启动:

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 | GPT-SoVITS ✅ | ❌ 只有 agent 工具 tts_ | ❌ | ❌ |
| dsh-gsv-tts | GSV-TTS-Lite(GPT-SoVITS 的分支,非上游) | ✅ | ✅ | ✅ |
| dsh-fish-tts | Fish Audio 云端 | ✅ | ✅ | ✅ |
| 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。详见 分句与停顿。
- 中文分句完整:。!?;… 及其连写(……、!?)都是句末,句末引号归前句(他说:“走吧。” 是一句);段落是硬边界,不会被合并成一段。
- 切换工作区/会话即取消朗读:不再出现"切走以后旧对话还在念、越切排队越多"。信号是外壳发给每个槽位的 sessionId,上一句的值存在模块级变量里——因为切换会话会把整棵子树卸载重挂,存在组件里的值(useRef/useState)在重挂后一律是空的,那样永远发现不了切换。详见 切换会话时取消。
- 音频只在内存:不再为每句话写一个 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 也不对外开放。

安装

dsh plugin --profile desktop add file:C:/绝对路径/dsh-gpt-sovits

装完刷新页面(客户端半身在启动图里合成,新插件需要一次页面加载才会出现)。卸载:

dsh plugin --profile desktop remove dsh-gpt-sovits

版本与下载

任何版本都可以随时下载或安装,不必迁就最新版。

| 你想要 | 怎么做 |
| 最新版 | 仓库首页 → Code → Download ZIP |
| 某个发行版 | 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 | 首个发布版:逐条朗读按钮、自动朗读开关、音色预设、设置面板 |
| v0.2.0 | 引擎生命周期跟随 DSH、文本规范化、流式/总结分流、队列式朗读、启动问候、引擎控制台 |
| v0.3.0 | 分句补全中文标点与段落边界、按标点插入停顿并裁掉引擎尾部静音、切换工作区取消朗读、音频改内存、逐句语速与情感 |
| v0.3.1 | 切换会话真正停播:判据改为跨卸载重挂存活的模块级变量(v0.3.0 那版存在组件 useRef 里,而会话槽位按代际 key 重挂,于是永不触发);驱动卸载时额外释放音频元素 |
| v0.3.2 | 四处可靠性修复:计划内的取消不再被上报成播放失败、被淘汰的音频自动重新合成、一个回合只重置一次(原先 8 个副本各来一遍)、诊断事件有了内存与磁盘上限 |
| v0.3.3 | 启动内存检查:每次启动 DSH 先读一次可用内存,低于 4.0 GB(可调)就不进入启动引擎的阶段;插件本身照常加载,设置页与状态卡会说明原因,可手动启动引擎 |

版本变更记录见 CHANGELOG.md。

前置:启动 GPT-SoVITS API

插件只负责"说话",不负责起引擎。引擎侧用官方 api_v2.py:

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/<id>.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 拒绝)。

开发与自检

离线自检:语法、模块加载契约、slot 名、文本管线、store 读取、状态目录、编码(62 项)
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 |

开关为什么不在"左边" —— 这是外壳的布局决定的。输入框那一行:

.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 时):

$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(外壳源码里就是这么判断轮次的:

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() 内部自己会入队。所以这样写会死锁:

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 里那张"坑"表,就是这么攒出来的。

✅ 测试通过的运行环境

下表是这份代码真实跑通、并且测试全部通过的环境 —— 不是"推荐配置",也不是从文档抄来的。

下列每一项都由本机实测采集;配套的 62 项离线自检与 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.3 |

实测性能(同一台机器,sample_steps 32、参考文本已填):

| 场景 | 耗时 |
| 引擎冷启动(加载权重) | 约 12–30 秒 |
| 首次合成(参考音未识别过) | 约 20 秒 |
| 之后的新句子 | 1.0–1.6 秒 |
| 重复的句子(缓存命中) | 0 秒 |

引擎侧显存占用实测约 2.7 GB(v2Pro 权重 + BERT + CNHuBERT)。

引用与致谢

本项目的代码以 MIT 发布,其中有一处是实质性复用他人实现,其余为接口契约与布局常量的借鉴。完整清单(含具体文件、具体语句、以及我做了哪些改动)见 THIRD-PARTY-NOTICES.md —— 这里只给摘要:

| 来源 | 许可证 | 复用了什么 |
| MaRi23333/dsh-fish-tts | MIT | cleanForSpeech 的文本清洗正则链 —— 十来个正则逐字符相同(lib/client.js 的 cleanForTts),以及 window.__ModuleLoader__.load 的 bundle 形态 |
| 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 | MIT | DSH_HOME \|\| ~/.dsh 的两级状态目录回退 |
| RVC-Boss/GPT-SoVITS | MIT | api_v2.py 的端点契约(没有代码被复制) |

下面这几个项目只作为参考阅读,没有复用代码,但值得致谢: TaoruiLiu19/dsh-gsv-tts(证明了宿主/客户端分半这条路可行)、 maoyuching/dsh-voice-chat、 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 62 项离线自检(静态断言 + 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<messageId, 已入队字符数> 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 返回 "<id>|<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,宿主映射到引擎参数:

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 <DSH 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/<id>.wav | 播放合成结果(id 是内容摘要) |

11. 音频缓存(内存)

- 键:文本 + 音色名 + GPT 权重 + SoVITS 权重 + 参考音频 + 参考文本 + 参考语言 + 朗读语言 + 语速 + 采样步数 + 引擎地址 + 逐句情感 的摘要
- 位置:只在内存(Map<clipId, {buffer, contentType, createdAt}>),不落盘
- 上限:条数 240 且 总字节 64 MiB,最旧优先淘汰
- 为什么权重进键:同一句话在不同模型下是不同的音频,不能互相顶替
- 为什么情感也进键:同理,不同温度是不同音频
- 详见 13. 内存音频

12. 分句与停顿(PAUSE_MS / segmentSentence / splitIntoSentences)

分句用「匹配」而不是「切分」:

const SENTENCE_END_RE = /[。!?!?;;…]+[”’"』」))】》〉\]]|$/g

终止符的 + 与尾随引号都在同一个匹配里,所以 ……、!?、……。” 都留在句尾。用先行/后顾断言切分试过,结果是 他沉默了三秒… 加一个孤立的 …,引擎把它当两句话说。

| 规则 | 实现 |
| 段落是硬边界 | 先按 \n\s\n+ 切段,段内单换行(软换行)视作空格 |
| 段末停顿最长 | 非末段的最后一句 pause 改写为 paragraph |
| 超长句降级 | > SENTENCE_SOFT_MAX(280) 时在从句标点断开,最后一段保留整句停顿 |
| 短句合并(罕见) | 仅当 ≤ SENTENCE_MERGE_MAX(10) 且待合并片段还没有强停顿时 |
| 段首标点剥离 | tidy() 去掉开头的 。!?,… —— 否则引擎会念出一个空句 |

停顿由插件插入,引擎不加静音:

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)

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/<id>.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() + 释放

信号是外壳给的,不用猜:conversation.chat.turnTail 的 standardProps 里就有 sessionId: SessionId,而且该槽位 scope: "session"。

关键:上一句的值不能存在组件里

这是本项目唯一一个"写对了却完全不工作"的 bug,值得单独记一笔。

会话作用域的槽位是按会话代际做 React key 渲染的 —— @deepseek-ai/dsh-client-ui-renderer 里是 sessionGenerationKeyOf(binding),对每个会话的 binding 返回一个新序号。所以切换会话会把整棵子树卸载、再挂一份新的。

于是第一版实现(把上一个 sessionId 存在驱动组件的 useRef 里,再在 effect 里比对新旧)必然失效:

切换会话 → 旧组件卸载、新组件挂载 → useRef 又是 null → effect 判为"首次挂载,只记录不动作" → 永远发现不了变化

日志是铁证:那段时间 50 次 driver-mounted,conversation-changed 一次都没有,而 player 是模块级的,不随组件销毁 —— 上一段对话就这么被一个"已经不存在"的组件继续念下去。

所以上一个值存在模块级的 LAST_DRIVER_SESSION(卸载重挂后仍然活着),并在 effect 里比它:

if (prev !== null && prev !== session) { LAST_DRIVER_SESSION = session; RESTART_PLUGIN("session-id-prop") }

先认领再重启:turnTail 是 list 槽位,一次切换会让 N 份副本同时检测到,先写回全局就不必靠 400ms 去重窗口来收尾。

两道保险,各自独立生效

| 保险 | 触发时机 | 作用 |
| 检测 sessionId 变化 | 切换会话 | 完整重启:清队列、清每条消息的已入队偏移、清量得的音频长度、清 CLAIMED、清 localStorage 的"已读"标记、PLUGIN_EPOCH += 1 让界面重挂 |
| 驱动卸载时收尾 | 子树被拆掉 | player.stop() 并且 pause() + 去掉 src + 丢掉元素引用 |

第二道保险是必需的,不是重复:player 在模块层,组件没了它照样在放。而且只调 stop() 不够 —— 一个被暂停但仍被引用的元素会继续占着音频输出,所以卸载时要把元素也释放掉(playing.audio === null)。

模块级状态刻意不动:audioCache(按内容摘要索引,与对话无关)、trimCache(重启时清,因为它按会话计长度)、订阅者列表(重挂后的新实例要靠它知道自己什么时候该播放)。

为什么不是"温和清空队列":那会留下"队列空了但当前这句还在念、而它的后续永远不会来"的怪状态。切换会话时新对话的优先级高于旧对话的尾巴。

自检里的两条(scripts/selfcheck.mjs):

✓ client: switching workspace or session cancels the reading
— 上一句的值在组件之外;带新会话的重挂触发了重启(丢 1 条待播),真实重启清空 1 条待播并释放 2 条认领

第一条断言会拒绝 sessionSeen.current 这种写法回来(那正是失效的判据);第二条会渲染一个驱动、取出它注册的清理函数、真跑一遍,并要求音频元素被释放。

锚点属性:data-gpt-sovits-read-aloud(朗读按钮)与data-gpt-sovits-auto-read(开关)—— 仍用于诊断与样式,不再用作切换信号(早先那套 selectSessionKey + MutationObserver 兜底已全部删除)。

16. 单位词表(UNIT_WORDS / UNIT_PATTERN / ABBREVIATION_PATTERN)

问题:通用斜杠规则把单位里的斜杠也读成「或」。

| 输入 | 修复前 | 修复后 |
| 120km/h | 120km或h ❌ | 120千米每小时 ✅ |
| 3000r/min | 3000r或min ❌ | 3000转每分钟 ✅ |
| 100MB/s | 100MB或s ❌ | 100兆字节每秒 ✅ |
| 5m/s² | 5m或s² ❌ | 5米每秒平方 ✅ |
| 2.4GHz | 2点4GHz(引擎会拼读成 G H z)❌ | 2点4吉赫兹 ✅ |

用词表而不是更聪明的正则:单位的集合是开放的,加一行就能扩展;把同样的知识编码进正则会难以阅读,而且下一个单位就会失效。

三条规则,每条都是踩过的坑:

| 规则 | 为什么 |
| 在斜杠规则之前执行 | 单位里的斜杠是「每」不是「或」 |
| 数量包含在匹配里 | 只匹配单位会留下数字,替换后重复输出成「120 120千米每小时」 |
| 斜杠内侧允许空格 | 100 km / h 是常见写法;捕获到的拼写先去空格再查表,一个键覆盖两种写法 |
| 结尾用 (?![A-Za-z0-9]) 而不是 \b | km/h 以字母结尾,\b 对后面的空格不成立,规则整个不触发 |

裸缩写(第二条规则)在扩展名规则之后、小数规则之前执行,且要求前面是数字 —— 所以 a.b.js 仍是扩展名,"minimum" 里的 min 和 "things" 里的 s 不受影响。

大小写:先按原样查,再按小写查 —— 所以 MB(兆字节)与 Mb(兆比特)可以区分,而 GHz/ghz 不必写两遍。

两个正则都由词表派生(Object.keys(UNIT_WORDS)),所以表与规则不可能不一致;自检里也是从 bundle 里提取并求值同一张表,而不是在测试文件里复制一份。

加一个单位:在 UNIT_WORDS 里加一行即可,两个正则自动跟上。

17. 表格与符号引用(tablesToProse / SYMBOL_NAMES / maskReferencedSymbols)

问题一:表格分隔行被念成减号。

回复以原始 markdown 进入管线(外壳把回复按文本存),所以表格带着竖线和 --- 一起来,引擎把两者都念出来:

输入: | 名字 | 说明 | → 修复前: 名字,说明。 A,第一项。 B,第二项。
| A | 第一项 |

tablesToProse() 在任何其他规则之前执行:

| 规则 | 实现 |
| 识别表格行 | /^\s\|.\|\s$/ |
| 丢掉分隔行 | /^\s\|(?:\s:?-{2,}:?\s\|)+\s$/ —— 它是排版,不是内容 |
| 单元格连接 | 用逗号连接(分句器把它当弱停顿),一行读成一组值 |
| 整行丢弃 | 全为空的单元格不留残渣 |

问题二:引用的符号完全没被念出来。

把 ; 也作为切分符号 以前丢掉分号 —— 引擎直接扔掉裸 ;,句子在说一个它从没念过的东西。

DSH 把行内引用画成一个方框,对应 markdown 的行内代码,所以从这里下手:

| 情形 | 处理 |
| 反引号内全是符号 | 换成名字:; → 分号、, → 逗号、。 → 句号、[ → 左方括号 |
| 反引号内是代码标识符 | 原样保留 —— 把 split 逐字念成名称是荒谬的,而且引擎读 ASCII 单词没问题 |
| 正文里当标点用的标点 | 行为不变 |

为什么名字要遮蔽:名字是普通中文,如果早替换,被引用的 。 会先变成「句号」再当句末标点,句子就会在错误的地方断开。所以借用 URL 那套遮蔽机制,最后一步才还原。自检里有专门断言: 把 。 也作为切分符号 必须是一句。

符号表覆盖 ASCII 与全角:;,。、:!?…—-_~|/\()[]{}<>《》「」""''#@$%^&+= 以及 ×÷≤≥≠≈∞→←↑↓√°℃ 等 60 余个。

18. 自检与集成测试(更新)

| | 离线自检 | 真实集成测试 |
| 项数 | 62 | 16 |
| 做法 | 静态断言 + vm 沙箱里跑真实函数 | 真 HTTP 载体 + 真引擎 |

新增断言覆盖:分句(中文标点、终止符连写、段落硬边界、停顿顺序)、段首标点、trimCache 与 audibleEnd、内存音频(禁止 writeFileSync 到音频目录、字节上限、旧文件回收)、逐句参数(三个参数进入请求、质量参数被排除、数值被夹)、会话切换(两个信号、兜底停用条件、null 不算变化)。

19. v0.3.2:四处可靠性修复(clip / turn / 诊断)

这一版全部来自读日志。四个问题都属于同一类:设计上说有上限、实际没有,或者契约写了、代码没写。

19.1 计划内的取消被上报成"播放失败"

stop() 会主动清空 audio.src 来打断正在加载的音频 —— 这是设计内的取消,但 audio.onerror 无一例外地 reject(new Error("audio playback failed"))。

一份实测日志里 53 次该报错中有 36 次紧跟 turn-reset(2 秒内);还有一条从点击到失败只有 17 毫秒,那是同步的 src 清空,不是真失败。

现在按代际区分:

audio.onerror = () => {
audio.onerror = null;
if (generation !== this.generation) { done(); return } // 计划的取消 → 静默 resolve
...
}

19.2 宿主承诺的重试根本不存在

宿主 handleAudio 在片段被淘汰时返回 clip-missing,注释里明写"客户端当作 miss 并重新请求":

/*
* ... The client treats it as a miss and re-requests, which is
* why the route answers clip-missing rather than an empty 200.
*/
sendJson(res, 404, { ok: false, error: 'clip-missing' })

而客户端里 clip-missing 0 处、404 0 处。注释描述了一个从未实现的契约。

音频从 v0.3.0 起只存内存,所以 URL 会随宿主重启或被淘汰而失效 —— 这个缺口因此变得真实。现在补上,且只重试一次:

try { await this.playClip(url, generation) }
catch (error) {
const missing = error instanceof Error && error.message === "clip-missing";
if (!missing || generation !== this.generation) throw error;
diag("clip-missing-retry", {});
url = await this.requestClip(chunks[index].text, generation, "cut5");
await this.playClip(url, generation);
}

另新增 clipErrorName(url): 只给数字 MediaError,分不清"文件没了"和"字节坏了",所以失败时用一次 HEAD 让宿主自己命名这个错误。

19.3 一个回合被重置八次

实测一次回合推进:

13:08:08.923 turn-reset turns:8 dropped:0
13:08:08.924 turn-reset turns:8 dropped:4 ← 只有这一个真丢了东西 13:08:08.925 turn-reset turns:8 dropped:0 ...

8 个驱动副本同时检测到同一回合,各自跑一遍 stop() + CLAIMED.clear()。一份日志里累计 1005 次。

这不只是噪音:每个副本都 stop(),于是刚起了个头的片段被兄弟副本打断 —— 这正是 19.1 那些误报的直接来源。

认领必须共享(重挂会重置 useRef,与会话检测器同一个坑),且在做事之前:

let LAST_RESET_TURN = null; // 模块级

if (LAST_RESET_TURN !== null && turnCount <= LAST_RESET_TURN) return;
LAST_RESET_TURN = turnCount; // 先认领
const dropped = player.pending.length;
...
player.stop(); // 输的副本根本走不到这里

两个细节:新挂载的副本继承已有认领(if (LAST_RESET_TURN === null) LAST_RESET_TURN = turnCount),否则它会重放自己带来的那个回合;切换会话时清空认领,因为 turnOrder 是全工作区的全局计数,新对话的回合可能已被旧对话认领。

19.4 诊断本身成了问题

| 症状 | 原因 |
| 页面内存约 1 MB / 会话 | 每条报告都 push 进一个模块级数组,而生产代码从不读它(只有 __test 导出引用) |
| DIAG_SEEN 涨到 362 个 key | see-<messageId> / gate-<messageId> 每条回复都是新名字,{once:true} 从未命中 |
| diag.log 超过 435 KB 仍无上限 | appendFileSync 没有大小检查、没有轮转 |

修法:删掉那个只写不读的数组;带 id 的事件名一律归约为族(see / gate / driver)再判重;diag.log 超过 2 MB 轮转为 diag.log.1;trimCache 加 512 条上限(它按内容 URL 索引,原先只有重启才清)。

实测:模拟 200 条不同 id 的报告,去重集与 POST 各只增长 2。

19.5 一条不会 settle 的 promise 会锁死整个队列

drain() 用 await this.playClip(...),而 draining 标志只在 finally 复位:

if (this.draining) return; // ← playClip 挂起后,这里永远返回

playClip 有三条正常出口(ended / error / 裁尾时手动调 ended)。若三条都不触发(后台标签页节流、解码卡住、元素被拆而无事件),整个队列永久停止,只有刷新页面能恢复。

所以加了看门狗,保证 promise 一定 settle:

| 设计 | 理由 |
| 阈值 180 秒,故意很松 | 它只要跑赢"永不";设紧了会在慢机器上切掉真实音频 |
| 在 play() 成功后才武装 | 否则每个被取消的片段都留一个定时器(切换会话时那是绝大多数) |
| setTimeout 缺失时静默跳过 | 沙箱没有定时器 —— 它当场抓到了第一版的 ReferenceError |

诚实标注:这条修复无法在离线沙箱里验证(沙箱没有定时器)。它的保证是推理 —— drain() 会 await 它,三条出口之外没有第四条路 —— 而不是实测。

19.6 顺手清掉的两处

- 一段文档注释被复制了两遍("One sentence per request while streaming…")。第二份紧挨着它描述的 blocks 计算,保留了它。
- drain(words) 的参数是死的:声明和递归调用里都有,函数体一次没读。enqueue 早已把 words 烘进每个队列项 —— 死参数靠递归调用活了下来。

20. 启动内存检查:先看内存,再决定要不要拉起引擎

引擎是 3–4 GB 的进程。在一台放不下它的机器上启动,后果不是「慢一点」,而是整机内存耗尽(实测:15.5 GB 的机器,引擎在跑、再开几个应用,触发系统级 OOM)。所以启动顺序被拆成两段:先读一次可用内存,过了红线才进入启动引擎的阶段。

判定

memoryVerdict({ freeBytes, thresholdMb, enabled })
// freeBytes 来自 os.freemem()(Windows 上是可用物理内存) // 低于红线 → gated: true

三条刻意的规则:

| 规则 | 为什么 |
| enabled: false 在测量之前短路 | 用户关掉的检测,不该被一个读数推翻 |
| 读数不是有限数字 → 放行 | 测不到内存就扣住引擎,比内存紧张更糟 |
| 等于红线也放行 | 规则是「低于」,不是「低于或等于」 |

红线默认 4096 MB(4.0 GB),设置页可在 512 MB–32 GB 之间调整;开关与阈值都存在 settings.json(不是 localStorage)——判定发生在插件挂载之前,那时还没有浏览器。

闸门只拦引擎,不停插件

内存不足时插件照常加载:设置页、引擎日志、模型列表、诊断都还在,被拦下的只有那个 3–4 GB 的子进程。这样「为什么没声音」能在设置页里直接读到,而不是先发现插件不见了、再去插件管理器里找回来。

判定每次 DSH 启动只做一次,在挂载任何东西之前:

const startupGate = { ...memoryVerdict(...), checkedAt, bypassed: false }

- 启动路径(ensureEngine('startup'))和 60 秒保活(ensureEngine('keep-alive'))都先看 gateBlocksEngineStart();被拦下就返回 note: 'memory-gate',既不探测也不拉起引擎。
- 手动请求(POST /gpt-sovits/api?action=ensure-engine,即状态卡里的「启动引擎」按钮)是用户本人的意思,放行,并把 bypassed 置为 true:本次启动内保活也不再拦它。

状态怎么读

GET /gpt-sovits/api?action=status 的 memoryGate 就是判定本身:

{ "enabled": true, "thresholdMb": 4096, "freeMb": 3120, "gated": true,
"reason": "low", "checkedAt": 1792650000, "bypassed": false }

设置页的状态卡把它翻译成一行——「内存不足,未自动启动引擎 · 3.0 GB < 4.0 GB」——旁边就是「启动引擎」按钮。判定结果同时写进 $DSH_HOME/gpt-sovits/memory-gate.json,并在 memory-gate.log 追加一行:

2026-10-21T06:04:44.100Z free 3120 MB < 4096 MB — the engine will not start; the plugin stays loaded

与 0.3.3 早期设计的关系

最初的实现是「内存不足就把整个插件自我停用」(往 profile 的 cordis.patch.yml 写 disabled: true)。实测暴露出两个问题:一是插件管理器本身还没就绪时停用会失败(日志里只剩 the entry cannot be disabled; running anyway),二是停用之后设置页一起消失,想改阈值反而得先去插件管理器把它打开。现在改成:闸门只扣住引擎,插件始终可用。
预览分享卡片长什么样 ↓91koukou/dsh-gpt-sovits
分享 · DeepSeek Harness Hub
91koukou/dsh-gpt-sovits

这是本项目的完整文档。首页的精简版见 ../README.md。

这是你的仓库?认领条目

回到顶部