dsh-oopz-voice
让 DSH 里的 agent 真的进 Oopz 语音房开口说话。
这不是「语音助手」。它有耳朵(本地 ASR)、有嘴(本地 TTS)、有脑子(大模型), 在语音房里别人听到的是一个说话带停顿、会插话、该沉默时会沉默的人 —— 不是一段播报。
别人的声音 ──▶ 能量 VAD ──▶ SenseVoice ASR ──▶ 大模型 ──▶ VITS TTS ──▶ Agora 推流
(Agora) (~50ms) (86–137ms) (~500ms) (55ms/字) (48kHz)
感知延迟大约 1.6 秒(说完话到听见回应)。这个数字是物理下限堆出来的,不是没优化。
它做什么
- 进房:进任意有权限的 Oopz 语音频道,长时间挂着不掉(心跳续命,实测 7 分 41 秒零掉线)
- 听:只处理「有人在说话」的那段,按静音断句;太短的一声「哦」直接丢
- 想:把听到的话交给它自己的大模型,配一份你自己写的人格
- 说:本地合成人声,推到语音房里
- 让位:别人在说话时它会停——不是一有声音就闭嘴,而是对方持续说够了才让(否则永远被打断)
- 被打断后补听:让位期间听到的内容不会丢,说完这句再接回来
它不做什么
- 不做回声消除。它靠让位避让,不靠 AEC
- 不处理英文(见「已知限制」)
- 不是官方 API 集成 —— 走的是 Oopz 私有协议,见「合规」
前置条件
| 需要 | 说明 |
|---|---|
| DSH | dsh --profile web 能跑起来 |
| Node 20+ | 运行时靠 puppeteer-core 起 Chrome |
| Python 3.10+ | 装 numpy 和 sherpa-onnx:python -m pip install numpy sherpa-onnx |
| 真 Chrome / Edge | ⚠️ 不能是 headless shell。我们要 WebAudio + Agora Web SDK,headless shell 里没有这套 |
| DeepSeek API Key | 或任何 OpenAI 兼容接口 |
| Oopz 账号 | 手机号 + 密码,且有权限进目标语音频道 |
模型不用自己找,设置页点「下载模型」会从 HuggingFace 镜像拉(约 300MB)。
安装
dsh plugin --profile desktop add dsh-oopz-voice
从源码 / 本地开发:
git clone https://github.com/Kingyibin/dsh-oopz-voice.git
cd dsh-oopz-voice
npm install
dsh plugin --profile desktop add "link:$(pwd)"
装完重启 DSH,设置里会多一项 语音房。
五步跑起来
- 登录 → 填 Oopz 手机号密码。密码只在这一次请求里用,不落盘、不回显。
凭据存
<DSH_HOME>/oopz-voice/cred.json,只在本机。 - 填 API Key → 保存。
- 下载模型 → 约 300MB,看进度条。
- 读我的域 → 会自动列出你能看到的域和语音频道,并标出每个频道里有几个人(选个有人的)。
- 回到顶部点 进房。第一次要起两个本地模型,10–25 秒。状态变「在房里」就成了。
顶部那七行「差什么」是体检,全绿再进房。哪行红着,它下面会直接写该装什么 / 该点哪里。
设置页每一项
展开落点
域 / 语音频道:进哪个房间。「读我的域」列出的频道会带人数和昵称——有人才有的聊。
音频
Python/浏览器:留空自动找。找不到就填绝对路径。ASR 模型目录/TTS 模型目录:留空用数据目录下的默认位置。语速:0.95偏慢、更像人;1.0是模型原速。线程数:TTS 是逐字线性合成的,加线程只能压一点点。
大脑
接口地址+模型:任何 OpenAI 兼容服务都行。温度:0.95适合闲聊。太高会开始胡说。记住几句:默认 6。语音里历史别拉长——越长首字越慢,还会绕回去讲旧事。软切句门槛:默认 7 字。越小开口越快,太大像念稿。
人格
留空就用 runtime/persona.example.md。写了以你的为准。
建议只写「它是谁、怎么说话、什么不干」,别写任务清单 —— 那是派活。
怎么说话
开口概率:被叫到时真的开口的概率。留一点「懒得理」的余地,1.0反而假。说完到下一句的间隔:默认 300ms。原来设 1200ms,结果说两句就硬等 1.2 秒,一顿一顿的。只听不说:调 ASR 用,能听见不开口。只连不进房:自检用,验证登录和协议,不真的占频道。VAD 参数:判断「在说话」的能量门槛。房里底噪大就往上调。
它是怎么做到的
展开(这部分是踩出来的,不是读文档读来的)1. 登录:纯 HTTP,不用客户端
POST /client/v1/login/v2/login:
- 密码用 RSA-OAEP(SHA256) 加密
- 签名是
RSA-SHA256(MD5(path + body) + oopzTime) - 两把内置密钥藏在客户端里:反转 → base64url → XOR → zlib
2. Agora appId 藏在 token 里
enter 接口返回的 data.appId 恒为 0,不能用。
真值在 supplierSign 字段里 —— token 是 "007" + base64(zlib(payload)),
zlib.inflateSync 之后按小端读出 appId / roomId / uid。
代码里 rtc.appId 留空就会自动解,不用手填。
3. 进房之后成员会「过期」
POST /area/v2/channel/enter 是幂等的,可以反复发。
成员寿命只有 60–80 秒——不发续期心跳就会从频道里消失。
官方 oopz-sdk 的 services/voice.js 是个空壳:startIdentityHeartbeat() 没有实现,
join() 返回假 token。所以这部分是自研的:每 20 秒重发一次 enter,实测 7 分 41 秒零掉线。
4. 音频:headless Chrome + Agora Web SDK
Node 里没有 WebRTC,所以起一个 headless Chrome 跑 Agora Web SDK:
createClient({ mode: 'live', codec: 'vp8' })+setClientRole('host')- 用
createCustomAudioTrack推自己合成的 PCM —— 这会绕过 Agora 的 3A (降噪 / 回声消除 / 自动增益)。好处是不被处理坏,代价是底噪得自己管。 - ⚠️
connection-state-changed事件在 live + host 下不触发。 必须直接读client.connectionState属性,否则看门狗会误判断线、把自己踢出房间。
5. 延迟预算
| 环节 | 耗时 |
|---|---|
| 静音判定 | 650ms(等对方说完,省不掉) |
| ASR | 86–137ms |
| 大模型首字 | ~500ms(物理下限,砍人格只省 90ms,不划算) |
| TTS | 55ms/字,线性 |
| 推流 + 对方听到 | ~100ms |
| 合计 | ~1.6s |
TTS 没有并行空间 —— 它是逐字合成的,13 个字就是 629ms。所以句子越短开口越快,
这也是 软切句门槛 存在的理由。
踩过的坑
这一节可能是整个仓库最值钱的部分。
展开音量对了,但对方说「声音嘈杂」 不是音量问题,是底噪。TTS 原始输出有 −40dBFS 的底噪、峰值只留 −0.9dBFS。 加了 20ms 分帧的噪声门(−38dB 门限、80ms 平滑)+ 峰值压到 −3.0dBFS + 48kHz 链路之后, 底噪降到 −57~−61dBFS。感知上的「吵」是底噪,不是响度。
她把旁白念出来了 模型输出了「(没说话,尾巴在水里轻轻摆了一下)」,TTS 老老实实念了。 正则切掉所有括号内容,整句纯旁白当沉默处理。
「滚」被听成「我」,她回「哦,你俩认识啊」 ASR 会编。加了可信度守卫:音频超过 0.8 秒、但字数/秒 < 2.2 → 判定「没听清」, 只许问「啊?」「没听清」。宁可显得耳背,也不要答非所问。
「哈」被听成「烟」,她回「烟啊,我闻着呛」 极短句的 ASR 天生不可靠。策略是把 history 里的原文换成占位符—— 让她看不见听错的字,只知道自己「应了一声」。
说两句就一顿一顿
minReplyGapMs = 1200 被写在每句之前。改成回合开始计一次、降到 300ms,
句间只留 70–190ms 随机气口。
每句都是同一个调
speed 固定、音量固定 → 听着像机器。
现在每句 speed 在 0.93–1.00 之间随机、音量 ±3.5% 浮动。
TTS 遇到中文引号就跳过
“ ” 在模型词表里是 OOV,合成时会静默跳过。clean() 里直接剔除。
⚠️ 已知限制(诚实的部分)
这些是实测结论,不是没调好:
| 限制 | 原因 |
|---|---|
| 声音有点闷 | TTS 模型只有 16kHz。电话带宽本身就容不下通透感,换模型才能解决 |
| 英文词全丢 | SenseVoice 中文版的 token 表里没有英文词。混说「GPU」这种会念成中文或跳过 |
| 极短句经常听错 | 一两字音频信息量太少。已用「可信度守卫」降级处理,但消不掉 |
| 首字最快 ~500ms | 完整人格 prompt 是 525–731ms,砍成极短 prompt 只到 480–573ms。差 90ms,不值得 |
| 不能处理音乐/多人同时说话 | 能量 VAD 只认「有声音」,分不出谁在说 |
| 协议可能随时失效 | 走的是 Oopz 私有协议,客户端升级可能改签名或加密方式 |
隐私
- DeepSeek Key:单独存
<DSH_HOME>/oopz-voice/secret.json,API 永不回显 - Oopz 凭据:存
cred.json,只在本机,不进配置文件、不进 git(.gitignore已挡) - 音频:ASR / TTS 全在本地跑。只有识别出来的文字会发到大模型接口
- 所有 HTTP 路由都过 DSH 的
requestRejection信任栅栏 —— 端口在回环地址上, 但浏览器里任何一个网页都能往回环发请求,不设栅栏等于把「启动进程」这个能力开放给任意页面
合规
本项目通过逆向分析 Oopz 客户端实现语音房接入,不是 Oopz 官方提供的能力。
- 仅用于你自己有权限的账号和频道
- 不要在别人的房间里用、不要用来刷屏或骚扰
- 语音房里的其他人不知道说话的是程序 —— 请自行判断这在你的场景里是否合适
- 因使用本插件导致的账号封禁、协议失效,作者不负责
目录
lib/
index.js 宿主半边:路由、起停、体检、oopz_voice 工具
client.js 设置页(DSH 设置 → 语音房)
config.js 配置单一真相源
runtime.js 子进程生命周期 + @@VOICE 事件解析
detect.js Python / Chrome / 模型 探测
secret.js 密钥单存
node-runtime.js 找可用的 node(躲开桌面端 process.execPath 是 Harness.exe 的坑)
agora.js 从 token 里解 appId
runtime/
bridge.mjs 主循环:Oopz 协议 + VAD + 编排
brain.mjs 大脑:切句、防重复、可信度守卫
asr.py SenseVoice(sherpa-onnx int8)
tts.py VITS 中文(sherpa-onnx)+ 噪声门
agora-agent.html headless Chrome 里的音频桥
persona.example.md 示例人格
tools/
login.mjs 登录并写凭据
rooms.mjs 列出域 / 频道 / 人数
get-models.mjs 下模型(走 HF 镜像)
License
MIT
No comments yet. Be the first to write one.