dsh-tts-speakout
Verifieddsh-tts-speakout · v0.3.2 · MIT · Web UI
Speaks a short sentence out loud through the computer's speakers using the user's own cloned voice, so an agent can call the user back to the desk when a long task finishes or their input is needed. Runs a local Qwen3-TTS worker with device targeting, and
Install
dsh plugin add dsh-tts-speakout Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Published to npm without a public repository. Inspect the package contents before installing.
Tags
Readme
dsh-tts-speakout
让 agent 在你不在电脑前时,用克隆音色从扬声器喊话——"活干完了""需要你确认一下"这类通知,用声音喊出来。
一个 DeepSeek Harness(DSH)插件:装完之后,agent 就多了一个 dsh_speak 工具,
可以调用你导入的母本音色,通过明确指定的输出通道(优先扬声器而非耳机)播放 TTS 语音。
它解决什么问题
Agent 跑一个长任务(构建、训练、下载、长篇分析)要十几分钟。你离开了电脑。 现在只能回来看屏幕才知道跑完没有。这个插件让 agent 直接喊你。
三个关键设计,都是踩过坑才定下来的:
- 认准扬声器,不认耳机。 Windows 默认输出经常是耳机——可你人不在电脑前,耳机听不到。
插件默认
autoPickSpeaker,在多个同名"扬声器"端点里挑真正的物理扬声器,并把明显是虚拟声卡的 (VoiceMeeter / Steam Streaming / ToDesk / VB-Audio)排到最后。 - 告诉你"为什么没声音"。 有一种失败极其隐蔽:合成与播放全都"成功",但端点是静音的。 所以状态里会带上端点的系统音量与静音状态,设置页会用黄条明确提示 「⚠ 可能听不到声音 · 系统通道音量:0% · 已静音」。
- 音色长期有效。 母本音频导入一次就存进插件数据目录,之后每次喊话都用同一个音色。
安装
# 在 DSH 中(把路径换成你 clone 下来的目录)
plugin_manager install_bundle D:\path\to\dsh-tts-speakout
装完刷新一次 Harness 页面,设置里会出现「扬声器喊话」一页。
首次运行会自动准备环境(不需要你手工装任何东西)
第一次调用 dsh_speak 时,插件会自己完成下面两步:
- 找 Python 环境:优先复用你机器上已经装好依赖的解释器(比如自己配的 conda 环境);
找不到就在插件数据目录里建一个专属 venv,自动装上
torch(CUDA 版) /transformers/qwen-tts/soundfile/sounddevice/numpy。 自建环境不会污染你已有的 Python。 - 准备模型:优先复用本地已有的 Qwen3-TTS 权重;找不到就从 ModelScope(国内快) 或 Hugging Face 镜像下载,支持断点续传。
为什么不把 2.4GB 权重直接放进仓库? GitHub 免费 LFS 配额 1GB、单文件上限 2GB,权重放不进去(要么仓库直接推不上去,要么 clone 的人拿不到)。 所以这里是「代码与推理全在插件里 + 权重首次运行自动拉取」,效果上仍然是装完即用、零手工步骤。 如果你已经有本地权重,它一个字节都不会重下。
使用
Agent 侧:模型只有两个工具
| 工具 | 能力 |
|---|---|
dsh_speak(text, volume?, device?) |
唯一的动作工具:说一句话 |
dsh_tts_status() |
唯一的自检工具:只读报告,不做任何操作 |
它什么时候才说话(提示词里写死了触发条件,避免变成不请自来的噪音源):
只有用户明确要求语音通知、说自己离开电脑但希望有事能被喊到、或让你用"说出来"的方式提醒他任何事时, 模型才会调用。仅仅"任务跑了很久"不足以触发——没有上述明确要求时,它会静默收尾。 它也不是通用的"播放音频"工具。
四个输出槽位(模型只能说这四个名字)
device 参数只接受四个值,模型看不到、也不能传真实设备名(schema 层强制):
| 名字 | 设置页显示 | 含义 |
|---|---|---|
primary |
默认输出 | 用户没指定输出口时走这里 |
backup |
备用输出 | 默认输出不可用时改用它;两个都不可用则放弃喊话 |
speakers |
扬声器 | 用户明确要求"用扬声器"时走这里 |
headphones |
耳机 | 用户明确要求"用耳机"时走这里 |
四个槽位各自独立绑定到真实端点(也可以留空,由插件按类型自动判断)。
好处是模型的词汇表稳定:驱动名、语言、插孔怎么变,headphones 这个请求都还有意义。
路由规则(提示词里逐条写死):
| 情况 | 行为 |
|---|---|
| 用户没指定输出口(常规情况) | 留空 device → 自动用 primary,不可用则退到 backup,再不行放弃并报错 |
| 用户明确要求某个口("从耳机里说") | 传 headphones / speakers → 严格要求,不可用就报错,绝不静默换口 |
| 全都不行 | 放弃喊话并说明原因,不循环重试 |
为什么明确要求要"严格":如果模型点名耳机而实际从扬声器出声,你会以为自己听到的是耳机, 这比不说话更糟。所以提示词明确禁止用
backup顶替明确请求。
权限是刻意收窄的,因为"让 agent 能对着扬声器说话"本身就是一种有分量的能力:
| 模型可以做 | 模型不能做 |
|---|---|
| 决定说什么 | ❌ 选择音色(音色永远跟随你的设置) |
| 本次临时音量(不持久化) | ❌ 修改你的音量设置 |
| 本次点名四个槽位之一(仅当你明确要求) | ❌ 修改槽位绑定 |
| 只读查询当前音色/音量/实际通道 | ❌ 传入任何真实设备名 |
| —— | ❌ 导入、重命名、删除音色 |
三点行为约定:
dsh_speak一定等到播完才返回,所以返回成功=真的念完了,模型不会误以为已经说过。- 参数默认全部留空:
volume与device在正常情况下都不该被填,会使用你设置的值。 只有你在对话里明确要求("大声点" / "从耳机里说")时才允许本次覆盖,且不持久化。device只能取上面四个槽位名,真实设备名会被 schema 直接拒绝。 - 通道不可用会立刻报错:工具在合成之前先校验目标槽位,不可用则毫秒级返回 错误——不会让你等十几秒模型加载后才发现选错了输出口。
dsh_tts_status永不修改任何设置。你问模型"我怎么没听到",它用这一个工具就能看到 当前音色、音量、实际使用的通道,以及"端点被静音/音量为 0"这类听不到的风险。
想换音色、改槽位绑定、调音量,都只在你的设置页里做——模型没有这些入口。
"通道可用"是怎么判断的(不是看名字)
这一步做错会得出很误导的结论,所以实现上刻意严格:
列出 ≠ 可用。 PortAudio 会枚举出 Windows 其实已经标记为未激活的端点。 实测:耳机没插时,Windows 的 Core Audio ACTIVE 列表里没有耳机端点, 但 PortAudio 仍然列出一个名为
耳机 ()的 WDM-KS 设备。 只看"列出来了"就会得出"耳机可用"——错的。同名陷阱。 耳机和扬声器往往是同一张声卡的两个插孔,名字只差角色前缀:
耳机 (Realtek(R) Audio)vs扬声器 (Realtek(R) Audio)。 而且 PortAudio 有时把耳机报成耳机 ()(括号是空的),按名字匹配会漏。所以真正的判据是"能不能打开"。 对候选端点做一次极短的流开启/关闭 (不写音频、不发声):无效插孔会以
Unanticipated host error失败, 有效端点则能正常开启。实测对比:端点 未插耳机 插上耳机 耳机 (Realtek(R) Audio)不出现 / 打开失败 打开成功 → 可用 扬声器 (Realtek(R) Audio)打开成功 → 可用 打开成功 → 可用 WDM-KS 耳机 ()打开失败 → 不可用 打开失败(重复表示,不影响) 失败结果缓存很短(2 秒)。 所以插上耳机后立刻重试就能生效, 不必重启 DSH;而成功结果缓存久一些(60 秒),避免每次都去开一遍设备。
自动挑选(
autoPickSpeaker)只会落在能打开的端点上;显式请求一个打不开的通道时, 报错会区分两种情况:"不存在"(名字没匹配到)与**"存在但当前不可用"**(匹配到了但打不开, 比如插孔空着)——因为两者的解决办法完全不同。
人侧(设置页)
设置 → 扬声器喊话:
整页自动保存,没有保存按钮。 每次操作(选槽位绑定、拖音量、切默认音色…)都立即写入
$DSH_HOME/tts-speakout/config.json并推给正在运行的 worker 生效,不需要确认。 页面只显示结果(如"音量已保存为 20%")。
- 四个输出槽位:
默认输出(primary)/备用输出(backup)/扬声器(speakers)/耳机(headphones), 各自独立绑定,点卡片选设备即刻生效。- 槽位就是 agent 唯一能说的四个名字:这里绑什么,模型就只能请求什么
- 留空 = 按类型自动判断(扬声器槽找音箱类端点,耳机槽找耳机类端点)
- 端点当前不可用(插孔空着)会变暗并标注"未插/不可用"
- 音量:0–100% 滑块
- 试听:全插件唯一的试听按钮。点一下会用当前音色 + 当前音量 + 当前喊话输出通道 合成一句短话并播放,等于一次验证三项设置。
- 音色:导入母本音频、填参考文本、选
icl/xvec、设为默认、重命名、删除
「试听」第一次点会明显等一会儿:需要加载模型(约 8~10 秒,2.4GB 显存)。 之后模型常驻显存,试听只需合成时间。想连这第一次都省掉,把
preloadOnStart设为true。
试听使用短生命周期进程播放,而不是常驻 worker:实测常驻进程反复开关流后 会静默(每次调用都报告成功、但端点不出声),新进程播放同一文件到同一端点则 每次都出声。试听是用户的可靠性基准,所以选了确定性优先。
音色克隆:两种模式,怎么选
Qwen3-TTS 是零样本克隆:给一段参考音频,它就用那个音色说话。
插件不内置任何音色素材,音色需要你自己导入(见下)。导入的音色保存在插件 数据目录
$DSH_HOME/tts-speakout/voices/<id>/,只属于你本机,不会随插件分发。
| 模式 | 需要参考文本 | 效果 |
|---|---|---|
icl(推荐) |
✅ 必须逐字对应 | 音色 + 语速/语气/韵律都像 |
xvec |
❌ 不需要 | 只有音色像,韵律会被平均掉 |
⚠️ 三条硬约束(都是实测踩出来的)
- 参考文本必须与音频逐字一致。 少字、多字、改字,模型会不知道何时停止,
一路生成到 token 上限才停——表现为"卡住不动 / 生成超长噪声"。
拿不准参考文本就用
xvec。 - 母本音频 5~15 秒最佳。 <3 秒信息不足;>30 秒可能触发生成挂起。 干净人声、无背景音乐、无混响、单人说话。
- 母本音频的"风格会传染"。 耳语→气声;急促→急促;高情绪会让中性叙述不稳定。
想核对参考文本:本仓库脚本
scripts/check-reference.py可以报告时长/削波/静音/信噪比, 判断素材是否达标。
配置
在 profile 的 cordis.patch.yml 里给 dsh-tts-speakout 这一行加 config::
- insert:
- id: dsh-tts-speakout
name: dsh-tts-speakout
config:
pythonPath: D:\miniconda3\envs\qwen3-tts\python.exe
modelPath: D:\models\Qwen3-TTS-12Hz-0.6B-Base
# 四个槽位各自绑定到真实端点;留空 = 按类型自动判断。
# 值可以是设备名子串,也可以是另一个槽位名(如 primary: speakers)。
slotBindings:
primary: 扬声器 (Realtek(R) Audio)
backup: 耳机 (Realtek(R) Audio)
speakers: 扬声器 (Realtek(R) Audio)
headphones: 耳机 (Realtek(R) Audio)
device: '' # 固定钉死一个口(空 = 走上面的槽位链)
volume: 0.5 # 0~1
language: Chinese
maxNewTokens: 0 # 0 = 按文本长度自动,这是防跑飞的安全阀
autoPickSpeaker: true # 兜底:仅在四个槽位都没绑定时生效
preloadOnStart: false # true = 插件激活就预热模型(约 10 秒)
- id: dsh-tts-speakout-tools
name: dsh-tts-speakout/tools
全部字段都有默认值,只写要改的即可。配置文件没有投影出 Config schema(这是有意的:
schemastery 是 @deepseek-ai/cordis 的传递依赖而非公开依赖,声明它会让部分安装环境解析失败;
写进 config: 的字段仍会正常生效,只是不做 schema 校验)。
音色素材与版权(重要)
本插件不内置任何音色素材,使用者需要自己导入一段参考音频。这不是遗漏,是刻意的设计决定:
- 克隆母本常常来自游戏/影视角色配音,版权属于原权利人,不能随插件分发。
- 开发过程中评估过多套可自由使用的中文女声语料(FLEURS / CC BY 4.0、AISHELL-3 / Apache-2.0, 均逐条核对了许可、使用数据集自带转写而非 ASR、并按客观指标筛选), 但音色品质达不到"开箱即用的默认音色"水准——开放语料里几乎没有商业配音级的角色音。 与其塞一个勉强的默认音色,不如让使用者导入自己满意的声音。
- 你导入的音色保存在插件数据目录
$DSH_HOME/tts-speakout/voices/<id>/,不在插件包内, 因此不会被提交、也不会随插件发出去。你的音色只属于你本机。
如果你要在此基础上随插件分发默认音色,只允许使用可自由使用的素材:
| 可用 | 说明 |
|---|---|
| CC0 / 公共领域(Public Domain) | 最省事,无需署名 |
| CC-BY | 可用,但必须按许可要求署名 |
| 明确允许商用的数据集条目(如 Apache-2.0) | 需核实该条目自身的许可条款 |
不可用:任何 NC(非商用)、ND(禁止演绎)素材,以及游戏/动漫/影视角色配音。
.gitignore把*.wav/*.mp3/voices/等一律拦截且不留例外,并已实测验证: 即使把音频放进仓库根目录或新建的assets/下,也不会被提交。 若将来真要内置音色,必须同时提交许可/署名文件并显式放开规则,不能"顺手放宽"。
参考文本也同理:不要用 ASR 结果当参考文本。ASR 会稳定地听错专有名词 (实测中同一段素材,四次 ASR 都把人名/普通词听错),而错误的参考文本会让模型生成跑飞。 只使用数据集自带的 transcript 或公共领域原文等可公开查证的逐字文本。
开发与发布
node scripts/build-client.mjs # 生成 lib/client.js(改了 src/client 必须重建,产物要提交)
node scripts/build-client.mjs --check # 校验产物是否陈旧
node scripts/test-host.mjs # 宿主回归(168 项断言)+ 音频槽位路由(34 项)
node scripts/probe-preview.mjs # 试听链路(真实合成 + 真实播放)
node scripts/worker-probe.mjs # worker 端到端
node scripts/check-reference.py <wav> # 母本素材质量检查
推送到 GitHub
用 Git Data API 推送(api.github.com 通常比 git push 的 github.com:443 更易连通):
$env:GH_TOKEN = "<有 repo 权限的 token>" # 只从环境变量读,不落盘、不打印
node scripts/push-via-api.mjs "feat: 初始发布"
推送脚本有两道防线,因为这个插件会公开分发:
- 文件列表用
git ls-files枚举 ——.gitignore是唯一权威,音频拦截会被严格遵守 (不这样做的话,"遍历目录上传"会把克隆母本一起推上去) - 推送前扫描凭据(GitHub token /
sk-密钥 / 私钥 / 硬编码口令)与音频文件,命中即中止。 确认音频许可可自由再分发时,可用GH_ALLOW_AUDIO=1显式放行。
给使用者的提醒:本插件是"源码 + 首次运行自举"的分发形式。若把整个开发目录 直接拷给别人,可能连带
.git/、.test-home/、node_modules/(体积大、含本机路径)。 正规做法是 git clone,或用你发布渠道的归档包。
排障
| 现象 | 原因 | 处理 |
|---|---|---|
dsh_speak 成功但听不到 |
端点被静音 / 系统音量为 0 | dsh_tts_status 看 audibleWarning;设置页黄条会直接指出 |
| agent 说喊了,扬声器很尖/破音 | 笔记本内置小喇叭在大音量下过载 | 降低音量(设置页滑块 + Windows 音量合成器)。耳机可正常还原时即为此因 |
| 提示没有音色 | 还没导入母本 | 设置页导入,或 dsh_tts_voice_import |
| 生成卡住很久 | 参考文本与音频不一致 | 改用 xvec,或把参考文本改成逐字一致 |
| 首次调用很慢 | 正在加载模型(约 10 秒,2.4GB 显存) | 正常;可开 preloadOnStart |
SoX could not be found! |
缺 sox 可执行文件 | 无害,librosa 走 soxr 后备,音频读写正常 |
性能参考(RTX 4060 Laptop 8GB,0.6B 模型)
| 指标 | 值 |
|---|---|
| 模型加载 | 约 9~10 秒(首次;之后常驻显存) |
| 中文 RTF | 2.12.6(生成 4 秒语音约需 910 秒) |
| 常驻显存 | 约 2.4 GB |
RTF > 1 是 Qwen3-TTS 自回归逐帧生成架构决定的(每 1/12 秒音频要跑 16 次前向), 换小模型和 flash-attention 都解决不了。
结构
dsh-tts-speakout/
├── lib/
│ ├── index.js 宿主服务:worker 常驻管理、音色持久化、原生音频选择器
│ ├── tools.js agent 工具(5 个)
│ ├── contracts.js typert 线缆契约(zod)
│ └── client.js 客户端产物(已提交,无需构建即可用)
├── src/client/index.jsx 设置页 UI 源码
├── python/
│ ├── tts_worker.py 常驻推理 worker(NDJSON over stdio)
│ ├── audio_out.py 定向播放(按端点原生采样率重采样)
│ ├── volume_ctl.py 读取/设置端点音量与静音(Core Audio)
│ └── env_bootstrap.py 首次运行自举:找/建环境、装依赖、下模型
└── scripts/
├── build-client.mjs 构建客户端产物
├── test-host.mjs 宿主冒烟(168 项断言)
├── probe-preview.mjs 试听链路(真实合成 + 真实播放)
├── worker-probe.mjs worker 真机端到端
├── voice-probe.mjs 用指定母本合成并播放
├── check-reference.py 母本素材质量检查
├── forensics.py 音频取证(区分"音色不像"与"信号损坏")
└── push-via-api.mjs 经 GitHub API 推送(含凭据/音频两道审计)
许可
MIT。运行时使用的 Qwen3-TTS 权重为 Apache-2.0,见 LICENSE。