Skip to content

dsh-tts-speakout

Verified

dsh-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 直接喊你。

三个关键设计,都是踩过坑才定下来的:

  1. 认准扬声器,不认耳机。 Windows 默认输出经常是耳机——可你人不在电脑前,耳机听不到。 插件默认 autoPickSpeaker,在多个同名"扬声器"端点里挑真正的物理扬声器,并把明显是虚拟声卡的 (VoiceMeeter / Steam Streaming / ToDesk / VB-Audio)排到最后。
  2. 告诉你"为什么没声音"。 有一种失败极其隐蔽:合成与播放全都"成功",但端点是静音的。 所以状态里会带上端点的系统音量与静音状态,设置页会用黄条明确提示 「⚠ 可能听不到声音 · 系统通道音量:0% · 已静音」。
  3. 音色长期有效。 母本音频导入一次就存进插件数据目录,之后每次喊话都用同一个音色。

安装

# 在 DSH 中(把路径换成你 clone 下来的目录)
plugin_manager install_bundle  D:\path\to\dsh-tts-speakout

装完刷新一次 Harness 页面,设置里会出现「扬声器喊话」一页。

首次运行会自动准备环境(不需要你手工装任何东西)

第一次调用 dsh_speak 时,插件会自己完成下面两步:

  1. 找 Python 环境:优先复用你机器上已经装好依赖的解释器(比如自己配的 conda 环境); 找不到就在插件数据目录里建一个专属 venv,自动装上 torch(CUDA 版) / transformers / qwen-tts / soundfile / sounddevice / numpy。 自建环境不会污染你已有的 Python。
  2. 准备模型:优先复用本地已有的 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"这类听不到的风险。

想换音色、改槽位绑定、调音量,都只在你的设置页里做——模型没有这些入口。

"通道可用"是怎么判断的(不是看名字)

这一步做错会得出很误导的结论,所以实现上刻意严格:

  1. 列出 ≠ 可用。 PortAudio 会枚举出 Windows 其实已经标记为未激活的端点。 实测:耳机没插时,Windows 的 Core Audio ACTIVE 列表里没有耳机端点, 但 PortAudio 仍然列出一个名为 耳机 () 的 WDM-KS 设备。 只看"列出来了"就会得出"耳机可用"——错的。

  2. 同名陷阱。 耳机和扬声器往往是同一张声卡的两个插孔,名字只差角色前缀: 耳机 (Realtek(R) Audio) vs 扬声器 (Realtek(R) Audio)。 而且 PortAudio 有时把耳机报成 耳机 ()(括号是空的),按名字匹配会漏。

  3. 所以真正的判据是"能不能打开"。 对候选端点做一次极短的流开启/关闭 (不写音频、不发声):无效插孔会以 Unanticipated host error 失败, 有效端点则能正常开启。实测对比:

    端点 未插耳机 插上耳机
    耳机 (Realtek(R) Audio) 不出现 / 打开失败 打开成功 → 可用
    扬声器 (Realtek(R) Audio) 打开成功 → 可用 打开成功 → 可用
    WDM-KS 耳机 () 打开失败 → 不可用 打开失败(重复表示,不影响)
  4. 失败结果缓存很短(2 秒)。 所以插上耳机后立刻重试就能生效, 不必重启 DSH;而成功结果缓存久一些(60 秒),避免每次都去开一遍设备。

  5. 自动挑选(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 ❌ 不需要 只有音色像,韵律会被平均掉

⚠️ 三条硬约束(都是实测踩出来的)

  1. 参考文本必须与音频逐字一致。 少字、多字、改字,模型会不知道何时停止, 一路生成到 token 上限才停——表现为"卡住不动 / 生成超长噪声"。 拿不准参考文本就用 xvec。
  2. 母本音频 5~15 秒最佳。 <3 秒信息不足;>30 秒可能触发生成挂起。 干净人声、无背景音乐、无混响、单人说话。
  3. 母本音频的"风格会传染"。 耳语→气声;急促→急促;高情绪会让中性叙述不稳定。

想核对参考文本:本仓库脚本 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: 初始发布"

推送脚本有两道防线,因为这个插件会公开分发:

  1. 文件列表用 git ls-files 枚举 —— .gitignore 是唯一权威,音频拦截会被严格遵守 (不这样做的话,"遍历目录上传"会把克隆母本一起推上去)
  2. 推送前扫描凭据(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。