dsh-doubao-tts
把火山引擎豆包「双向流式语音合成 V3」接进 DSH Web GUI:每条已定稿的助手回答后面多一个朗读按钮,点一下就把这条回答合成成语音播出来。
# 安装(示例用 web profile;桌面版请把 profile 换成 desktop)
dsh plugin --profile web add git+https://github.com/qin-yu-1314/dsh-doubao-tts
# 本机没有 git 时,改用 Release 里的压缩包:
# dsh plugin --profile web add "https://github.com/qin-yu-1314/dsh-doubao-tts/releases/latest/download/dsh-doubao-tts-0.2.2.tgz"
| 项目 | 值 |
|---|
| 适用 profile | web / desktop(桌面版由应用内插件页管理) |
| 最低 DSH 版本 | >= 0.1.5-rc.2(需要 slot 系统与 settings.section) |
| 许可证 | MIT |
| 运行时依赖 | 无(自带 WebSocket 实现,安装期不需要 pnpm 拉任何包) |
| 前置条件 | 火山引擎「大模型语音合成」已开通,需要自己的 API Key 或 App ID + Access Key |
装好后到**侧栏「设置」→「豆包语音合成」**填密钥与音色,点「测试合成」验证。
浏览器 DSH 宿主(插件服务端) 火山引擎
┌──────────────┐ GET ┌──────────────────────────┐ WSS ┌────────────────┐
│ 朗读按钮 │ ──────► │ /dsh-doubao-tts/speak │ ────► │ bidirection v3 │
│ <audio> 播放 │ ◄────── │ ① 按 messageId 取正文 │ ◄──── │ 二进制帧 + 音频 │
└──────────────┘ 音频流 │ ② 合成 ③ 落盘缓存 │ └────────────────┘
└──────────────────────────┘
它做什么
- 客户端半边:往
conversation.chat.assistant-actions 这个 slot 注入一个朗读按钮(官方反馈插件用的是同一个 slot)。手写 __ModuleLoader__ bundle,不需要构建步骤,只 require('react'),不挑宿主版本。
- 服务端半边:注册
/dsh-doubao-tts/* 路由,按火山 v3 二进制帧协议合成音频,边收边以 chunked 流式回给浏览器;同一段文本只付费合成一次(落盘缓存)。
- 取正文:客户端只传
sessionId + messageId,服务端优先查活会话(ctx.sessions),回落读持久化日志 $DSH_HOME/sessions/<slug>/<sessionId>/session.v4.jsonl.zstd —— 并顺手做朗读清洗(丢掉代码块、去掉 Markdown 记号、URL 换成「链接」)。
安装
插件包 = 一个声明 dsh.bundle.patch 的 npm 包。本地目录直接装:
方式一(推荐,桌面版):侧栏「插件」页 → 添加插件 → 填本目录的绝对路径:
C:\Users\wllj\Documents\deepseek-harness\default-workspace\tts-plugin\dsh-doubao-tts
方式二(CLI,需要先完全退出 DeepSeek Harness Desktop):
F:\deepseekharness\resources\runtime\cli\bin\dsh.cmd plugin --profile desktop add "link:C:\Users\wllj\Documents\deepseek-harness\default-workspace\tts-plugin\dsh-doubao-tts"
方式三(应用可以开着跑,不需要 pnpm):本插件零运行时依赖,所以只差「一个目录联接 + 两处登记」。脚本默认干跑:
node tools/install-local.mjs # 只打印将要改什么
node tools/install-local.mjs --apply # 真装(改前备份,失败自动回滚)
node tools/install-local.mjs --uninstall --apply # 卸载
装完后:
| 事项 | 说明 |
|---|
| 生效方式 | 新增 bundle 由 HMR 热加载,不必重启进程;但浏览器页面要刷新一次才会拉取新的客户端 bundle |
| 改代码后 | 替换一个已加载包的 JS 需要重启进程(HMR 只监听 profile 的 manifest / cordis patch,不监听插件自身文件) |
| 页面表现 | 助手消息动作行(复制/分支那一排)最右边出现喇叭图标;未配凭据时点击显示红色提示,而不是静默失败 |
| 挂载位置 | 本仓库目录就是插件安装源(link:)。若以后清理工作区,插件会失效——把它复制到固定目录后重装即可 |
本机已按方式一装好并验证过:profiles/desktop/package.json 里 dependencies 为 link:C:/…/dsh-doubao-tts、dsh.profile.bundles 末尾含 dsh-doubao-tts,node_modules 下是 SymbolicLink;/dsh-doubao-tts/status 从装前的 404 变成 401(宿主信任栅栏拦匿名请求)、/probe 返回插件自己的 405(GET 打到了 POST 路由的分派器)——都证明服务端半边已在宿主进程里注册成功。
自检与排障
插件装载后会写一份不含任何凭据的自检报告(%USERPROFILE%\.dsh\doubao-tts-selfcheck.json),用来回答「到底装上了没有」:
| 字段 | 含义 |
|---|
phase: "loaded" | 服务端半边进了宿主、5+1 条路由注册完毕 |
phase: "status-requested" | 浏览器里的客户端 bundle 执行了(它装载时就会拉一次 /status),lastStatusRequest.host 是那次请求的 Host |
phase: "client-registered" | 客户端把按钮注册进了 slot;clientReport.status 为 registered / register-failed / no-slots-service,失败时带原因 |
fence | 用的是宿主栅栏还是插件自己的回环校验 |
configured / missing | 缺哪个凭据字段 |
按钮不出现时,这份报告能区分三种完全不同的原因:客户端 bundle 没加载(停在 loaded)、slot 没声明/注册被拒(register-failed + 具体错因)、或只是没配凭据(configured: false)。
一键核验(重启或重装后跑一次就有结论):
node tools/verify-install.mjs
它检查 profile 登记(link: 依赖 + bundles + 联接可解析)、自检报告的阶段、以及三条路由的匿名探针,并把每个状态码翻译成人话:
| 探针返回 | 含义 |
|---|
404 | 路由不存在 → 插件服务端没加载 |
401 | 路由在,但被宿主信任栅栏拦下(匿名请求本就不该通过,这是正常的) |
405 | 路由在,且打到了插件自己的方法分派器(GET 打 POST 路由) |
200 | 路由在且放行 |
早期版本会按宿主 cwd 把自检报告写在 profiles/desktop/ 目录里;以 %USERPROFILE%\.dsh\doubao-tts-selfcheck.json 为准,旧路径那份可以删掉。
配置凭据
复制 volc_tts.env.example 为 %USERPROFILE%\.dsh\volc_tts.env 并填写(或改用环境变量):
| 字段 | 说明 |
|---|
VOLC_API_KEY | 新版 API Key(与下面一对二选一) |
VOLC_APP_ID + VOLC_ACCESS_TOKEN | 老版一对密钥;也接受 VOLC_ACCESS_KEY |
VOLC_RESOURCE_ID | 必须与音色所属商品一致,否则报 55000000:合成 2.0 音色用 seed-tts-2.0,1.0 公版用 volc.service_type.10029 |
VOLC_SPEAKER | 音色 ID,控制台音色列表里复制 |
凭据在火山引擎控制台 → 语音技术 → 大模型语音合成 → 开通管理里获取。改完配置不用重启(配置有 5 秒缓存),直接验证:
Invoke-WebRequest http://127.0.0.1:19387/dsh-doubao-tts/probe -Method POST -UseBasicParsing | Select-Object -Expand Content
{"ok":true,"ms":…,"bytes":…} 就说明通道打通了;失败会回可执行的错误说明(错误码已翻译成中文提示)。
路由
| 路由 | 方法 | 用途 |
|---|
/dsh-doubao-tts/status | GET | 配置是否齐全、音色、格式、缓存统计、日志根目录、capabilities(服务端能力清单) |
/dsh-doubao-tts/settings | GET/POST | 设置界面用:GET 回显(密钥只回掩码)+ 生效值/来源/缓存;POST 原地改写 env 文件 |
/dsh-doubao-tts/speak | GET/POST | ?sessionId=&messageId= 或 ?sessionId=&seq=[&turn=] 或 ?text=;返回音频。支持 voice format clean=0 download=1 |
/dsh-doubao-tts/probe | POST | 用一句测试文本真实合成一次,验证凭据/音色/网络 |
/dsh-doubao-tts/client-report | GET | 客户端半边回执注册结果(落进自检报告) |
/dsh-doubao-tts/cache | GET | 缓存条数与占用 |
/dsh-doubao-tts/cache-clear | POST | 清空缓存 |
设置界面
侧栏「设置」→「豆包语音合成」(挂在 settings.section 槽,与「通用」「模型」并列):
| 分组 | 可改项 |
|---|
| 鉴权 | API Key / App ID / Access Key / Access Token(密钥是密码框,只显示掩码占位;留空 = 不修改) |
| 音色 | Resource ID(下拉:seed-tts-2.0 / volc.service_type.10029)、音色 ID(可手填,下拉给参考值) |
| 音频 | 格式(mp3/pcm/ogg_opus)、采样率、语速、音量 |
| 连接 | WebSocket 端点、是否先发 StartConnection |
| 按钮 | 保存 / 测试合成 / 重新读取 / 清除已保存的密钥 / 清空音频缓存 |
| 状态 | 是否已配置、缺哪些字段、缓存条数与占用、配置来源文件、以及会覆盖它的系统环境变量 |
设计要点:
- 写回原文件:改的是
%USERPROFILE%\.dsh\volc_tts.env 本身,保留注释、行序与其他键,只动对应的行(先写 .tmp 再 rename,原子写)。
- 密钥不回明文:GET 只回
{set:true, masked:"••••••••5f53"};输入框永远为空、掩码只当占位符,所以保存时不会把掩码写回文件。
- 立即生效:保存后服务端立刻清掉配置缓存,不用重启、不用刷新页面。
- 能力协商:客户端 bundle 刷新页面就更新,服务端代码要重启进程才换。
/status 的 capabilities 用来判断当前服务端支持什么,避免「新界面调旧接口」的静默失败。
为什么挂两个 slot
| slot | 位置 | 可见性 | 定位参数 |
|---|
conversation.chat.assistant-actions | 复制/分支那一排图标里 | 默认 opacity: 0,悬停才淡入(ui-chat 的 [data-actions-reveal=hover]) | messageId |
conversation.chat.turnTail | 同一节点的兄弟 slot | 常显 | seq(该助手消息的事件序号)+ turn |
只挂第一个等于把入口藏起来(实测反馈「看不到喇叭图标」);而且当某一轮正在生成时,data-actions-reveal 会退化成 hover(endsWithResponse 要求它是时间线上最后一轮),连最近一轮的动作行也要悬停才出现,甚至当前轮根本不渲染动作行。所以第二个 slot 才是真正的入口——它拿不到 messageId,只能拿 seq,服务端因此也支持按 seq 取正文。
/status 的 capabilities 用于协商:客户端 bundle 随页面刷新立刻更新,而服务端代码要重启进程才换,存在「新客户端 + 旧服务端」的窗口期;此时常显按钮会显示「需要重启一次 DSH」,而不是发一个注定 400 的请求。
错误码:400 MISSING_PARAMS、404 MESSAGE_NOT_FOUND(附已知 messageId 便于排障)、404 SESSION_NOT_FOUND、422 EMPTY_TEXT(只有 reasoning/工具调用、没有正文)、503 NO_CREDENTIALS、502 VOLC_ERROR(带火山原始错误码)、502 AUTH_FAILED(上游拒绝密钥,带服务端原话)、504 ENDPOINT_UNREACHABLE(连不上火山)。
命令行(不开 GUI):
node tools/probe.mjs # 探针:验证凭据/端点
node tools/probe.mjs --api-key bogus # 故意用假密钥,看服务端如何拒绝
node tools/speak-message.mjs --out reply.mp3 # 自动挑最近一条助手消息朗读
node tools/speak-message.mjs --session <id> --seq <n> # 走 seq 通路(常显按钮同一条路)
node tools/speak-message.mjs --text "你好" --out a.mp3
真机验证结果(本机)
| 验证项 | 证据 |
|---|
| 装进 profile | dependencies 为 link:C:/…/dsh-doubao-tts,dsh.profile.bundles 末尾含包名,node_modules 下是符号链接且 entry.js/client/doubao-tts.js 均可解析 |
| 服务端半边装载 | 装前 /dsh-doubao-tts/status 是 404,装后是 401(宿主栅栏拦匿名请求);/probe 返回插件自己的 405(GET 打到 POST 路由的分派器) |
| 客户端半边执行 | 自检报告(宿主 pid 36204)在装载后 0.06 秒就记录了来自 127.0.0.1:19387 的 /status 请求——这个请求只由客户端 bundle 发起 |
| 控件注册成功 | 自检报告 clientReport.status = "registered",slot = conversation.chat.assistant-actions,error = null |
| 真实火山端点 | 用假密钥打生产端点:HTTP 401 — {"error":"Invalid X-Api-Key"},125ms 返回 → 端点路径、DNS/TLS、WS 升级、请求头全部正确 |
| 真实语音合成 | 真密钥下:握手序列 ConnectionStarted ← SessionStarted ← TTSSentenceStart ← TTSSentenceEnd ← SessionFinished;20 字 → 11 段 / 30765 字节 / 1.5s;77 字 → 36 段 / 114285 字节 / 2.7s;产物为合法 MP3(ID3v2.4 + MPEG 帧同步) |
| 两条取正文通路一致 | messageId 与 seq 两条通路在同一会话上解析出逐字相同的正文(speak-message.mjs 内置交叉校验) |
| 未影响既有插件 | /dsh-whale/widget.js → 401、/api/qqbot-settings/read → 200,与安装前一致 |
一键复现:node tools/verify-install.mjs
没有凭据也能先跑通
tools/demo-server.mjs 起一个真实 HTTP 服务,跑的是生产代码路径,只把火山换成协议级假服务(回 lamejs 现场合成的真实 MP3):
node tools/demo-server.mjs 18081
# 然后把打印出来的 URL 粘进浏览器就能听到声音
它同时会挑一条你真实会话里的助手消息,给出可直接播放的链接——用来确认「按 messageId 取正文 → 合成 → 流式播放」整条链路。
测试
node test/run.mjs
129 项,覆盖:
- 协议层:客户端/服务端帧的字节级布局、事件与会话字段、长度前缀边界、错误帧;
- 火山客户端:对着独立的
ws 实现当服务端跑完整会话,断言拿到的音频字节与下行帧 payload 逐字节一致、音频是分帧流式到达、长文本按句切分、错误码翻译、两种鉴权头、跳过 StartConnection 的部署;
- 取正文:多帧 zstd 会话日志(真实持久化格式)、跳过 reasoning、只有思考的消息、Markdown 清洗,并直接读本机真实会话日志验证;
- 客户端 bundle:在 Node 里用假 React/Audio/fetch 跑 bundle,断言注册到两个 slot、常显控件带文字标签、点击请求正确 URL(messageId 与 seq 两条)、能力协商、缺服务时不抛异常、注册回执;
- slot 契约:接宿主真实的
SlotCore(@deepseek-ai/dsh-client-ui-slots)搭出与真机一致的 slot 树(conversation.chat.node#turn-tail 声明的 list/session 子槽),验证我的注册被接受、与 order=10 的官方反馈条目按序共存、inject(sessionId) 约定、disposer 语义、未声明槽会被拒;
- 插件路由:真实 handler + 假 webServer + 假火山,覆盖取文本、流式输出、缓存命中/清空、错误码映射、自检报告、以及回环/同源/跨站请求策略;
- 设置存储:原地改 env 文件保留注释/行序/无关键、新键追加、
null 删除、原子写(无 .tmp 残留)、掩码只留尾 4 位且不泄露前半段。
- 设置路由:GET 掩码不泄密、POST 立即生效、密钥留空不写回、
clearSecrets 删除、未知字段/空提交 400、落进自检报告。
- 设置面板:回显 /settings、密钥占位是掩码而非值、保存只提交用户填的字段、清除密钥、测试合成、「完成」接
props.close。
- 本地安装器:干跑不改文件、真装写对两处登记并能解析联接、幂等、格式不预期时拒绝改动、出错回滚、卸载不误删插件源目录。
- 一体化:真实客户端 bundle 的
fetch 桥接到真实服务端 handler,跑完整条 装载自检 → 拉 /status → 注册 slot → 回执 /client-report → 点击按钮 → 真合成 → 缓存命中 的链路。
- 配置:env 文件优先级、按实例隔离的缓存(回归:两个实例不得互相偷配置)、
startConnection 开关、凭据服务兜底。
- 自检报告:里程碑单调、字段累积(回归:高里程碑写入不得抹掉
clientReport)、换装载时重置。
- 错误分类:握手 401/403 归为
AUTH_FAILED 并带出服务端原话(响应体分两次 write 也不丢),非鉴权失败不误判,端点不可达归为 ENDPOINT_UNREACHABLE。
安全
DSH 的 web server 不做认证与来源策略,文档要求 route owner 自己实现,因此本插件自带:只接受回环 Host(或 DSH_DOUBAO_TTS_TRUSTED_HOSTS 列出的)、拒绝 Sec-Fetch-Site: cross-site、带 Origin 时必须与 Host 同源;写操作(POST)只允许本机(或 DSH_DOUBAO_TTS_ADMIN_HOSTS);可用时一并委托宿主的 connection.requestRejection。
已知限制
- 文本是整段送进 TTS(按 600 字切包),还没做「LLM 边出字边推 TTS」的增量输入;协议层已经预留(一次会话内多次
TaskRequest)。
- 合成失败若发生在已经发出部分音频之后,浏览器侧只会表现为播放中断(HTTP 状态码已无法更改);日志里会有 warning。
- 没有做逐句音频时间轴,因此没有「朗读到哪一句」的高亮。
- 未做系统朗读回退:本机 SAPI 的 voice token 缺
CLSID 注册(Speak 报 0x80045040/E_ACCESSDENIED),所以离线回退不可用;无凭据时按钮会明确提示缺少凭据而不是静默失败。
与最初那份 doubao_tts.py 的差异
原稿用纯 JSON 文本帧实现,跑不通。正确做法是 v3 二进制帧:
| 原稿 | 实际协议 |
|---|
{"event":"StartConnection"} 文本帧 | 4 字节头 + event(4B 数字 1) + payload(4B 长度 + JSON) |
if isinstance(raw, bytes): yield raw | 下行全是 bytes;音频藏在 messageType=0b1011 的 TTSResponse(352) 帧 payload 里,不能把整帧当音频 |
| 没有 session_id | StartSession/TaskRequest/FinishSession 必须带同一 session id |
没有 namespace/req_params 结构 | body = {user,event,namespace:"BidirectionalTTS",req_params:{text,speaker,audio_params,additions}} |
字符串事件名等 SessionFinished | 数字事件:100/150/200/352/102/152,失败走 ErrorInformation(0b1111) |
X-Api-Key 一种 | 老版一对密钥 + X-Api-Resource-Id;Resource-Id 与音色不匹配会报 55000000 |
上架到插件市场
市场(dshmarket)的列表来自精选列表 awesome-dsh-plugin,
只允许安装该列表内的来源;上架方式就是往那个仓库提一个文件:
data/plugins/<owner>__dsh-doubao-tts.yml
本包提供了生成器(查重名 / 生成投稿文件 / 生成自建目录):
node tools/market.mjs check dsh-doubao-tts # 查目录里有没有重名、看同类条目
node tools/market.mjs entry --owner <你的 GitHub 用户名> # 打印可直接提交的 YAML
node tools/market.mjs json --owner <owner> --out plugins.json # 自建目录(配合 DSHM_REGISTRY_URL)
提交前确认(后面这条是最常见的被拒原因):
- 仓库
package.json 必须声明 dsh.bundle(只声明 dsh.client 不算可安装)—— 本包已声明,仓库根也有 cordis.patch.yml;
- README 第一屏要有:可复制的安装命令、适用 profile、许可证、最低 DSH 版本 —— 本 README 顶部已满足;
- 不要在 YAML 里手写
npm: 字段,映射由 CI 从 registry 自动采集,手写会被校验拒绝。
许可
MIT。