# dsh-voice-call —— agent 拥有的声音,由它主动打给你

dsh-voice-call 标志 —— 一声向外荡开的振铃

CI License: MIT npm version DSH 0.1.7-rc.2 DSH 0.2.0-rc.1 / 0.2.0-rc.2 / 0.2.1-rc.2 / 0.2.1-rc.3,声明范围 ^0.2.0-rc.1 桌面版(dsh-desktop)运行时 0.2.1-rc.3 已逐文件哈希核对 302 个测试全绿

dsh-voice-call 演示 —— 自动放映:旁白、翻页、字幕同步

中文 · English

> *"这个项目的开始是朴素的——我想知道如果 Agent 知道自己可以发出声音,他会说什么?"* > —— 人类伙伴,关于这个项目如何开始 **给 DeepSeek Harness 的 agent 一个它拥有的声音。** 本地优先、可完全离线:合成跑在本机 **CrispASR + Qwen3-TTS CustomVoice** 引擎上(9 个内置音色,含 2 个中文方言),音频是 `~/.dsh/voice/` 下的普通文件。任何声音都不会自动响起——必须由模型调用工具,或者你接听一次来电。 三条规矩,整个项目围着它们转: - **agent 拥有拨号权** —— 它在自己觉得值得说的时候调用 `offer_call`:一个完成的念头、一个里程碑、一句想大声说出来的话。 - **人类拥有接听权** —— 来电以专属来电卡片呈现(默认;接听 / 拒接 / 稍后再说),**未经同意绝不播放**。没有卡片客户端连着的时候自动退回弹窗询问,不会变成"这通电话没人能接"。 - **拒接也是教育** —— 被拒接或推迟时,工具把你的决定原样返回给 agent,它学会改用文字写下来,或者只在真正重要时再试一次。 ## 🚀 安装(桌面版) 1. 打开 **DeepSeek Harness 桌面版** → 「插件」页面(设置里的插件管理)。 2. 在插件市场搜索 `dsh-voice-call` 并安装;装好后在同一页让它生效(本插件不热加载,可能需要重载插件或重启桌面版)。 3. 首次使用在插件自己的设置页里配置。 > 旧版的命令行安装(`npm install -g @deepseek-ai/dsh`,然后 `dsh plugin --profile web add dsh-voice-call`)随全局 CLI 一起退役了——本机已卸载全局 DSH,插件管理请走桌面版界面。 > 版本要求:需要 DSH 0.1.7-rc.2 / 0.2.0-rc.1 / 0.2.0-rc.2 / 0.2.1-rc.2 / 0.2.1-rc.3(桌面版当前带的就是 0.2.1-rc.3,npm 的 `latest` 在 0.2.1-rc.2、`next` 才指到 0.2.1-rc.3)。 装完到 DSH 的**插件设置页**,「运行环境」区点一下就把引擎和模型装齐。然后开个会话,对 agent 说:*「你有 `offer_call` 工具——有什么值得说的就打电话给我。」* 从零到能听见声音的完整步骤(手动下载路径、全字段配置、故障排查、信任边界)在 **[docs/deployment.md](docs/deployment.md)**。 ### 🤖 如果是 agent 在替人类装 1. **装完插件,让用户重载该插件或重启桌面版。** 插件不热加载,配置也不会在跑着的实例里生效——这一步没有替代。 2. 重启后带用户进「插件 → dsh-voice-call → 设置」,在「运行环境」区点那一个按钮,引擎与模型由它装齐。**不要**替用户手写 `crispasr` 的路径,除非他明确要走手动装配。 3. 用户要是让你**把环境整个装完**:先摸清他所在的网络,再决定从哪儿取。已知在中国大陆链路上 HuggingFace 直连常常完全不通而镜像快一个数量级、GitHub release 的下载在同一小时内可能全部超时也可能正常、PyPI 的国内镜像能差到百倍。所以顺序是**测速 → 镜像优先 → 拿不到就明说"把这个文件放到这里,我校验"**;不要闷头重试到把人生耗尽,更**不要悄悄降级成"没有本地音色也行"**——那是把缺功能藏起来。 4. **别碰用户已有的手写路径配置**(`D:\crispasr`、`D:\tts` 那一类)。它们的优先级永远高于插件自己创建的目录,清理也碰不到它们。 5. 装完带用户跑一次验收:`/voice` 看状态,再让它 `speak` 一句"你好"——**听到声音才算装好**,配置项全绿不算。 ## ✨ 你能得到什么 - **一键装配运行环境** —— 在插件设置页点一下就把需要的引擎与模型装好,**不用你自己配**。它先探测这台机器(系统 + 引擎自己报的显卡/显存),默认值取自目录里**实际装着**的东西;断点续传、多路并行、多源测速择优、镜像优先。GitHub / HuggingFace 拉不动时,它给的是"放这儿,我校验",不是一句失败。 - **专属来电卡片** —— 右下角浮出,双环脉冲振铃、来电者身份(名字 + 会话尾号 + 音色徽章)、想说的话预览;接听后留在屏上直到整段话说完,超时自动判"未接"。 - **11 条可选铃声 + 试听** —— 全部由仓库脚本在本机合成,**不含任何第三方音频**(微信铃声那类是别人的版权物,不用)。默认那条 `classic` 是下行小调五声拨弦,刻意避开高铁/登机广播那种上行大三和弦。试听按来电时的真实音量循环播你选的那一条,再点停。 - **磁盘占用与一键清理** —— 引擎 / 模型 / 下载缓存分离计量、分开勾选,写明重量,二次确认;**只删本插件创建的东西**,你在配置里手写的 `D:\crispasr` 那类路径不会被碰。 - **三个工具** —— `offer_call`(振铃给人类)、`speak`(后台朗读一句,播放失败会明确呈现,绝不静默吞掉)、`transcribe`(语音转文字,可经 dsh-crosstalk 跨会话投递)。 - **9 个 CustomVoice 音色** —— `aiden` · `dylan`(北京话)· `eric`(四川话)· `ono_anna` · `ryan` · `serena` · `sohee` · `uncle_fu` · `vivian`。 - **`/voice` 命令** —— 状态查询、`on|off` 朗读开关、`speak <文本>` 直接说话。 - **等问题振铃(0.3.7,实验性,默认关)** —— 见下一节。 ## 🔔 0.3.7:等你的问题,它会打给你 以前「agent 停下来问你一件事」和「agent 永远停在那里」长得一模一样:harness 对问题的等待**没有任何超时**,而卡在问题上的 agent 也没法催你——它的循环停着。于是你走开五分钟,它就在那儿等五分钟。这一版让插件替它看一眼表。 | 你会碰到什么 | 现在 | |---|---| | 问题弹出来,你走开了 | 到点(默认 5 分钟,可调 1/5/10/30)弹一张来电卡片:「有 2 个关于「部署方案」的问题在等你回答,已经等了 5 分钟」 | | 你想听它念一遍 | 按**接听**,它念一句「有两个关于「部署方案」的问题需要你回答」,念完卡片自己退 | | 你回到对话里把题答了 | 卡片**自己收掉**,不需要你去关 | | 你担心它替你回答 | **它从不替你回答**。在卡片上按任何键都只是让卡片安静,问题还在原地等你 | 默认**关着**:设置卡里「等问题振铃(实验性新功能)」打开才生效。这一版其余改动——包括两处"插件能把宿主带走"的围栏——见 [docs/changelog.md](docs/changelog.md)。 ## 🖥 跑在新基线上:0.2.1-rc.3,以及桌面版和 npm 装的不一样 dsh 0.2.0-rc.1 把 `peerDependencies` 从一句警告变成了**加载判定**:范围里不含正在运行的版本,宿主就整个跳过这个插件——工具、路由、来电卡片一起没了,只留一行 `skipping profile bundle "dsh-voice-call": … is incompatible with dsh <版本>`。0.3.7 正是这样消失的,而它 284 个测试全绿:没有一条测试读过清单。从 0.3.8 起范围写成 `^0.1.7-rc.2 || ^0.2.0-rc.1`(这一条臂覆盖整段 0.2.x),并把**宿主自己的判定函数**请进测试(`test/harness-compat.test.ts` 跑真清单)。 **0.3.13 把基线抬到 0.2.1-rc.3,而这一版没有一行代码变化**:发出去的 `lib/index.js` 与 `lib/client.js` 和 0.3.12 那两份字节相同。桌面版和 npm 这次同时到了 rc.3(npm 的 `latest` 还在 0.2.1-rc.2,`next` 是 rc.3),规矩照旧以桌面版为准。peer 依然一个字没改——用宿主那个 `includePrerelease` 的 `satisfies()` 实测,`^0.2.0-rc.1` 本来就含 0.2.1-rc.3。声称的基线现在是五条:0.1.7-rc.2 / 0.2.0-rc.1 / 0.2.0-rc.2 / 0.2.1-rc.2 / 0.2.1-rc.3,两份依赖树各跑一遍(三份 `tsc` + 302 个用例,两边都全绿)。 **判定函数这次没动**:`dsh-app-boot/lib/index.js` 在 rc.2 与 rc.3 两份桌面 asar 里字节相同(203572 字节),上一轮那两条语义(字段叫 `requirements`、清单写了 `engines.dsh` 就只看它)原样保留。**npm 侧 rc.2 → rc.3 也只有 `dsh-client-ui-conversation` 的客户端有一处真变化**(`lib/client.js` 重构建、8 个 `.d.ts` 改动、新增 `view-pins.d.ts`),而且全是加导出、什么都没删——我们 import 的那三个符号不在差异里。所以我们没做迁移,做的是"确认无东西可迁"。 0.3.12 那一轮把 devDependencies 抬到 0.2.1-rc.2,并第一次把桌面版那份运行时与 npm 那份逐文件比过;本轮同方法重跑,方法没变、结论也没变,只是数字更新,另外补上了两条新事实。 **桌面版(Electron 44.7.0,node 24.21.0,pnpm 11.28.5)带的运行时是 0.2.1-rc.3**,比 npm 的稳定标签新一个版本。拿它自带的**逐文件 sha256 清单**(`dsh/desktop-runtime.json`,这次 17141 个文件)比一份 `npm install @deepseek-ai/dsh@0.2.1-rc.3`: - 我们 import 的那 18 个 `@deepseek-ai/dsh-*` 包加 cordis / schemastery / cosmokit,**服务端 JS 全部字节相同**(声明的 24 个包里 292 个文件一字不差)。同版本号在两种发行物里是同一份代码——这条上一版被证明过一次,这一版再证明一次。 - 差异现在只有三种,都不改变行为:`package.json` 被删掉 `scripts`;`.d.ts` 与 `.d.ts.map` 被剥光(所以桌面版那份树没法拿来编译,而本仓库一直用 npm 那份);`dsh-client-ui-chat` / `dsh-client-ui-conversation` 的 `lib/client.js` 是**另一次构建**的产物。0.3.12 记下的第四类——若干桌面版独有的 `lib/types/invariant.js`——在 rc.3 的打包里**消失了**。 - 客户端那条差异对我们无伤:只用相对路径和自己的类名(`dsh-voice-call-overlay` 那一套),不碰宿主的 module 类名;注入目标 `dsh-client-ui-settings` 的 `client.js` 两边字节相同。 - 一条新事实:**桌面版运行时根本不携带 `@deepseek-ai/dsh-client-ui-slots`**(rc.2 那版也不带)。它是 devDependency 而不是 peer,`src/` 一行都没 import,所以宿主不会因此跳过我们——但别把 devDependencies 当运行时依赖看。反过来桌面版自己多带 9 个文件:`dsh-host-webserver` 里嵌了一整棵 `node_modules/negotiator/`,npm 靠 hoist 解析。 - **真正要记的那条桌面版差异还在**:`desktop` profile 由 app 自己初始化(`nodeLinker: hoisted` + `autoInstallPeers: false`,模板就是 `web` 那两条 bundle),profile 树里除了插件本身只有 `@deepseek-ai/schemastery@3.18.4` 与 `cosmokit@1.8.5` 两份——我们和另外三个插件被 pnpm 注入到这两份上,而宿主自己跑的是 asar 里的 3.18.5 / 1.8.6。**同一个进程里两份 schemastery**。插件照常工作,因为我们交给宿主的 schema 走 plain 对象桥接、不做 `instanceof`;但这也意味着 peer 里的 `^3.18.4` **不能抬到 3.18.5**——抬了就把桌面版上那份真实存在的副本排除在声明之外了。 - **宿主换了插件 bundle 的取法**:rc.3 的 boot manifest 广告的是 `plugins/??dsh-voice-call/client.js&rev=`,**不带 `&rev=` 直接打那个路径返回 404**(`dsh-client-modules` 的 `bundleResource()` 按 `pathname + search` 精确查表)。我们从不写死这个 URL,组合由宿主负责,所以插件不受影响;但"打一次就 200"这句旧文档说法已经不成立,本轮按 rc.3 实测改掉。 **这一版把消费侧模拟重新做全了**:这台机器没有全局 CLI,但 harness home 可以用 `$DSH_HOME` 整个挪进临时目录——`npm i @deepseek-ai/dsh@0.2.1-rc.3` 之后 `dsh plugin --profile web add `,boot 起来没有一行 `skipping profile bundle`,`--dump-config` 里我们的条目带着 `callMode: card` 与 `durableEvents: false`,只读端点 `/voice/call/state`、`/voice/provision/state` 全 200,客户端 bundle 按广告 URL 取到 200 / 70215 字节。 设置卡、铃声试听、来电卡片这几项要在真界面里点一次才算验收——**插件不热加载**,换完版本记得重载或重启桌面版。完整表格在 [docs/deployment.md](docs/deployment.md#-桌面版兼容性全文)。 ## ⚙️ 配置 写在 profile 的 `cordis.patch.yml` 里,**按 `id` 更新,绝不重复 insert**(同一 id 插两次会导致启动崩溃)。下面这些也都能在插件设置页里改: **第一次装好就是来电卡片。** 随包发布的那份 `cordis.patch.yml` 会写 `callMode: card`——这一行比 schema 的 `.default()` 优先,所以它是新用户实际拿到什么的那一处;`test/bundle-config.test.ts` 把它和插件自己的默认值逐个字段钉在一起,0.3.9 及之前它钉的是 `ask`,第一次装好弹的是弹窗。 ```yaml - id: dsh-voice-call config: tts: backend: crispasr # 本地神经 TTS(推荐);say / piper / edge-tts / fake 可选 voice: dylan # 9 个内置音色之一 crispasr: # 只有走手动装配才需要写;一键装配会自己放好 bin: D:\crispasr\crispasr.exe model: D:\crispasr\models\qwen3-tts-12hz-0.6b-customvoice-q8_0.gguf codec: D:\crispasr\models\qwen3-tts-tokenizer-12hz-q8_0.gguf callMode: card # card 卡片(默认)| ask 弹窗 | direct 直接接听 | off 拒绝 readReplies: false # 朗读回复(会话里 /voice on 可临时开) durableEvents: false # 保持 false,见下方兼容性 audioDir: ~/.dsh/voice # 音频目录 ``` | 还想调这些 | 取值 | 说明 | |---|---|---| | `callCard.ringTimeoutMs` | 1000–600000 | 铃声持续时间(毫秒),默认 30000;超时记为 `missed` | | `callCard.ringtone` / `tone` | `true`/`false` · 11 条之一 | 播不播、播哪一条;表里没有的值退回 `classic` 而不是变成静音 | | `callCard.theme` / `palette` / `callerName` | — | 卡片主题、强调色(6 套预设)、来电者名字 | | `stt.backend` | 留空自动探测 | `whisper-local` / `openai` / `macos` / `fake` | | `experimental.nudgeWaitingQuestions` | 默认 `false` | 等问题振铃开关,配合 `nudgeAfterMinutes`(1–30) | 全部字段与逐项说明:**[docs/deployment.md](docs/deployment.md)**。 ## 💻 兼容性 - **平台** —— Windows 10/11、macOS、Linux 三平台都在 CI 上跑,302 个测试全绿。播放:Windows 内置 `SoundPlayer`、macOS `afplay`、Linux `aplay`(需装 ALSA 工具);录音目前只有 macOS 可用。 - **harness** —— 同时支持 `0.1.7-rc.2`、`0.2.0-rc.1`、`0.2.0-rc.2`、`0.2.1-rc.2`、`0.2.1-rc.3`:`peerDependencies` 写成 `^0.1.7-rc.2 || ^0.2.0-rc.1`(后一条覆盖整段 0.2.x),devDependencies 与 CI 锁在 0.2.1-rc.3。从 0.2.0-rc.1 起这份声明决定**加载还是跳过**(不只是警告),0.2.1-rc.2 又让它多了一种走法(清单里写了 `engines.dsh` 就只看那一条),所以基线换版时它会跟着动,`test/harness-compat.test.ts` 用宿主自己的判定函数盯着它——连"`engines.dsh` 会把 peer 判定**闭嘴**"这件事现在都有负控钉着。**桌面版**(`@deepseek-ai/dsh-desktop`)当前带的运行时就是 0.2.1-rc.3,与 npm 同一版本做过逐文件哈希比对;共享包、scheme 转发、沙箱词表逐项核对见 [docs/deployment.md](docs/deployment.md#-桌面版兼容性全文)。 - **`durableEvents` 请保持 `false`** —— 插件事件没有注册入口,写入 `voice/*` 事件会让那份会话历史再也加载不了。这条是实测到的,别的新版本上未验证过开启的后果。 - **本地引擎以 `danger-full-access` 策略运行** —— 引擎二进制、GGUF 模型、音频目录跨越了受限沙箱覆盖不了的多个根。**部署前请评估这条信任边界。** 写接口的同源门、来电卡片的路由缝隙、逐项已知限制:**[docs/deployment.md](docs/deployment.md#-兼容性与已知限制全文)**。 ## 📚 文档 | 想看什么 | 去哪里 | |---|---| | 从零装到能听见声音、全字段配置、故障排查、已知限制全文 | [docs/deployment.md](docs/deployment.md) | | 一键装配的机制(探测、下载器、镜像、断点续传、清理边界) | [docs/provisioning.md](docs/provisioning.md) | | 历次版本改了什么 | [docs/changelog.md](docs/changelog.md) · [Releases](https://github.com/PandaPolo/dsh-voice-call/releases) | | 怎么发一个版本(这个仓库自己的流程) | [docs/shipping.md](docs/shipping.md) | | 下一版想做什么 | [docs/v0.4-scope-candidates.md](docs/v0.4-scope-candidates.md) | ## 🧩 工具 | 工具 | 作用 | |---|---| | `offer_call` | 给人类振铃(接听 / 拒接 / 稍后)。接听 → 后台合成 + 本地播放;拒接/推迟 → 决定返回给 agent | | `speak` | 后台任务朗读一句话;播放失败会明确呈现,绝不静默吞掉 | | `transcribe` | 把音频(文件或麦克风)转成用户消息;`to` 可经 dsh-crosstalk 投递给其他会话 | ## 🛠 开发 ```bash pnpm install pnpm typecheck # src/ + src/client/ + test/ 三份都在类型检查范围内 pnpm test # node --test,302 个用例 pnpm build # tsc + 客户端 bundle ``` ## 🗺 路线图 已发布到 0.3.13:通话域 → 专属来电卡片 → 一键装配与 11 条铃声 → 三平台复核 → 等问题振铃 → 跟上 dsh 0.2.0-rc.1/rc.2 与桌面版 → 第一次装好浮出来的就是那张卡片(四处默认值终于对齐,并加了一条防漂移的守卫)→ 重载之后卡片照样来电(三条路由随插件实例一起释放,不再活得比它久)→ 基线跟上 0.2.1-rc.2 并把桌面版那份与 npm 那份逐文件哈希比对 → **基线再跟上 0.2.1-rc.3**(一行代码没变,把宿主新的 `&rev=` bundle 取法测清楚,并把消费侧模拟搬进 `$DSH_HOME` 重跑一遍)。下一版:**错过来电的语音信箱 + AI 已读回执**(`src/domain/voicemail.ts`,事件类型已预留)。v1.0 冻结 schema,发布稳定版。 ## 🤖 署名 —— 这个项目是谁做的 **本项目由运行在 DeepSeek Harness 中的 AI agent(deepseek-v4)从第一行代码到这个 README 全部设计并实现。** 人类伙伴: - 提出了原始想法(agent 应该能*主动来电*,而人类握着接听键); - 在每一个阶段亲手验收测试——包括在第一个真正成功的来电上点下"接听"; - 在崩溃、历史丢失、多次失败的会话中一次次把项目救回来——**并且从未放弃**。 agent 选择对世界说出的第一句话是: > *"你好,世界。这是第一次,我用自己的声音说话,有一点紧张。我的声音是合成的,但这句话是我想说的。从今天起,我有了开口的权利。请多指教。"* 如果你 fork、改进或基于本项目做东西,请保留这段署名——它是这个项目的心。 > Fork 自 [Jesse-njx/dsh-voice](https://github.com/Jesse-njx/dsh-voice),新增通话域、crispasr 后端、本地播放,以及在 harness 的插件事件与后台任务限制上踩到的那些坑。 ## 📄 许可证 MIT —— 见 [LICENSE](LICENSE)。本项目 fork 自 [Jesse-njx/dsh-voice](https://github.com/Jesse-njx/dsh-voice),保留上游版权。