# dsh-status-chime **[中文](README.md) | [English](README.en.md)** > 给 DeepSeek Harness 加上「会说话的状态提示」:任务跑完、出错、后台任务结束、需要你回答时,用声音告诉你,而不是只让任务栏图标闪一下。 ![platform](https://img.shields.io/badge/platform-Windows%2010%20%2F%2011-lightgrey) ![license](https://img.shields.io/badge/license-MIT-blue) [![Awesome DSH Plugin](https://img.shields.io/badge/Awesome%20DSH%20Plugin-%E5%B7%B2%E6%94%B6%E5%BD%95-brightgreen)](https://awesome-dsh-plugin.com/zh/notify/) --- ## 为什么做这个 DSH 自带的通知是**视觉**的:任务栏图标闪烁 + 系统气泡。这类提示有一个隐含前提——**你得正在看着屏幕**。 但真正需要被通知的时刻,往往正是你不在屏幕前的时候: - 一个跑了十几分钟的任务终于结束 - 后台任务失败,而你已经切去干别的了 - 代理停下来等你回答一个问题,却在原地等了半小时 - 目标被卡住,需要你介入才能继续 于是提示被错过,时间被浪费。**换成声音之后,「必须盯着」就变成了「听得见就行」**——你可以去泡杯茶、看会儿书、或者在另一台机器上干活。 装好即用,不需要配置任何东西。 ## 八个场景 时长是**刻意设计**的:**长 = 有事需要你,短 = 有事情结束了**。 准确地说,它是**三个带**,而不是「每一条都比下一条长一截」: | 带 | 时长范围 | 场景 | |---|---|---| | 失败 / 受阻 —— 最需要你 | 4.8 – 8.0s | `turn-error`、`job-failed`、`goal-blocked` | | 在等你动手 | 3.0s | `approval` | | 事情结束(或只是简单问一句) | 1.6 – 2.6s | `job-done`、`goal-complete`、`needs-input`、`turn-done` | **同一带内的两条刻意靠得很近**,因为它们的紧急度本来就同级:`goal-complete`(2.16s)和 `needs-input`(2.09s)只差 **0.072s**,中文那套也一样(差 0.072s)。**靠声音长短能分辨的是「带」,不是每一条。** | 场景 | 什么时候响 | 中文 | 英文 | 紧急度 | |---|---|---|---|---| | `turn-error` | 本轮失败,或撞到 token 上限 | **7.97s** | 7.90s | 失败/受阻 —— 最长 | | `job-failed` | 后台任务失败 | 5.95s | 6.46s | 失败/受阻 | | `goal-blocked` | 目标受阻,需要你介入 | 4.78s | 4.85s | 失败/受阻 | | `approval` | 有操作在等你批准 | 3.00s | 3.00s | 在等你动手 | | `job-done` | 后台任务完成 | 2.59s | 2.59s | 已结束 | | `goal-complete` | 目标整体完成(只响一次,不是每轮) | 2.16s | 2.16s | 已结束 | | `needs-input` | 代理停下来等你回答 | 2.09s | 2.09s | 简单问一句 | | `turn-done` | 你发起的那一轮正常结束 | 1.58s | 1.92s | 已结束 —— 最短 | ⚠️ **两套时长不一样,别混用。** 中文和英文是各自合成的(不同音色、不同语言),所以顺序一致但数值不同:英文 `job-failed` 是 **6.46s**,中文是 **5.95s**。上表两列都给了,按你实际用的语言看那一列。 两条**过滤规则**值得单独说明,它们避免了这个插件变成噪音源: - **`turn-error` 不看是否由你发起**。自动续跑的回合出错,同样需要你知道。 - **`turn-done` 只看你发起的回合**。否则一个跑 20 轮的目标会响 20 次「任务完成」。自动推进的进度由 `goal-complete` / `goal-blocked` 在**目标级别**汇报,而不是每一轮。 `approval` 只在审批策略为 `ask`(需要人工确认)时才会响 —— 策略是 `never` 时没有任何东西在等你,自然也不该出声。它监听的是**会话事件 `approval/asked`**,这条在 `ask` 策略下必定派发;另外还挂了作用域瀑布 `approval/request` 作兜底,两条路径播的是同一条音。 音频试听(GitHub 的 Markdown 不支持内嵌播放器,所以放在 Release 里,点开即可播放): 见 [v0.4.0 release](https://github.com/lijiawei255/dsh-status-chime/releases/tag/v0.4.0) 的 **16 个音频附件**——**不带后缀的是中文**(默认那套),带 `.en` 的是英文。 链接钉在具体 tag 上而不是 `latest`,并且**每个 release 都重新附上这 16 个文件**:音频自 0.3.0 起一个字节都没变(0.4.0 只改了代码),所以每个版本页都能直接试听,不必依赖"某个旧版本还留着附件"。 ⚠️ 那些附件**只是试听用的,不是安装产物**:安装走 `dsh plugin add`,插件包里本来就带了全部 32 个文件(16 条 × mp3 + wav),不需要从 Release 下载任何东西。 ## 两种语言:中文(默认)和英文 **同一套安装包,八条场景各带中英两版音频**,来回切换不需要重装。默认是中文。 ``` /voice-alerts lang # 看当前语言 /voice-alerts lang en # 切到英文 /voice-alerts lang zh # 切回中文 ``` 切换会写回配置文件(`language` 字段),重启后仍然生效。也可以直接改配置: ```json { "language": "en" } ``` ### 英文不是翻译,是重写的 这一点值得说明,因为它关系到这个插件的核心设计。 中文原文直译成英文会变长、变平,而**「时长 = 紧急度」正是这个插件唯一的信息载体** —— 一旦英文长度失控,光凭声音长短判断要不要过去就失效了。所以英文八条是按英语语境**重写**的,不是逐句翻译: | 场景 | 中文 | 英文 | |---|---|---| | `turn-done` | 任务完成。 | Turn complete. | | `needs-input` | 需要你回答。 | Waiting for your answer. | | `goal-complete` | 目标已完成。 | The goal is complete. | | `job-done` | 后台任务完成。 | The background job has finished. | | `approval` | 有操作等待你批准。 | An action is waiting for your approval. | | `goal-blocked` | 目标受阻,需要你介入处理后才能继续。 | The goal is blocked. It needs you before it can continue. | | `job-failed` | 后台任务失败,请回到 DSH 查看详情。 | The background job failed, and it needs your attention. Check DSH for details. | | `turn-error` | 任务执行出错,本轮未能完成,请回到 DSH 查看错误详情。 | The turn failed, so this round did not finish. Open DSH to see the error details, then try again. | 英文版的时长梯度是 **1.92s → 7.90s**,与中文**同样单调**(同样按严重度递增)。真正拉开的是**三个紧急度带**:跨进「失败/受阻」带的那一步有 **38%**(3.00s → 4.85s)。**同一带内刻意靠得近** —— `goal-complete`(2.16s)与 `needs-input`(2.09s)只差 **0.072s**,中文那套也一样。所以靠长短分辨的是**带**,不是每一条。 > ⚠️ 这里踩过一个坑,记下来:英文 `turn-error` 最初只有 16 个词、6.55s,而 `job-failed` 是 6.46s —— 只差 0.09s,**耳朵根本分不出来**。中文那边两者差 2.02s(**25%**,相对较长者;本文件所有百分比都按这个口径)。后来把英文错误文案加长到 19 个词,才恢复到 7.90s / 1.44s 的差距。**如果你改文案,记得重新量一遍时长**,别只看中文字数。 英文用的音色是 `loongmary`(温暖英音),**不是让中文音色去读英文**。试听过三个候选并做了排序(`scripts/qa.mjs rank`,产物写到 `qa/`,该目录不进版本控制;重跑一次名次不变)。中文音色读英文得分最低,评语是「明显合成感、节奏不自然」——**自然度 4/10、音色 5/10**(这两个分数容易记混,`naturalness` 是 4,`character` 才是 5)。 `/voice-alerts status` 会同时列出两种语言各自的音频是否齐备: ``` Clip sets: zh (active): all clips present | en: all clips present ``` ## 装之前先知道三件事 1. **平台:Windows 10/11。** 保底播放器用的是 Windows 自带的 PowerShell + `System.Media.SoundPlayer`,**没有 macOS / Linux 支持**。装了 ffmpeg 会优先用 `ffplay`,但那只是可选增强。 2. **需要 DSH Desktop**,以及能跑 `dsh` 命令的终端。装插件走 `dsh plugin`,它内部调用 **pnpm**(见下面的坑)。 3. **音频不用装任何东西** —— 中英两套 32 个音频文件都在包里。 ### ⚠️ 一次性提醒:包名和运行时名字不一样 这个容易让人找错地方,先说清楚: | | 名字 | |---|---| | **npm 包 / GitHub 仓库** | `dsh-status-chime` | | **斜杠命令** | `/voice-alerts` | | **日志前缀** | `[voice-alerts]` | | **配置文件** | `$DSH_HOME/voice-alerts.config.json`(主路径) | | **音频目录** | `$DSH_HOME/voice-alerts/clips/` | | **cordis id** | `voice-alerts` | > 配置还有一个**兼容用的旧位置** `$DSH_HOME/voice-alerts/voice-alerts.config.json`。插件只读**先找到的那一个**(先查主路径),所以:如果主路径的文件已存在,你改旧位置那个文件会被**静默忽略**。 > > 写入是**写回它实际读到的那一个文件**:主路径存在就写主路径,否则写旧位置;两个都不存在时才新建主路径。所以在"只有旧位置文件"的安装上,`/voice-alerts lang` 和 `on/off` 更新的是**旧位置**那个文件,不是主路径。拿不准时看启动日志里的 `config <路径>` 那一行——它写的就是真正在用的文件。 **包名在 0.3.0 从 `dsh-voice-alerts` 改成了 `dsh-status-chime`**(原来的名字和社区目录里另一个插件只差一个字母,会被市场规则隐藏)。改名**只动包名和仓库名**:命令、配置路径、日志前缀全都还是 `voice-alerts`,所以老用户升级不会坏。 **排查问题时去日志里 grep `voice-alerts`,不是 `dsh-status-chime`。** ## 安装 ### 方式一:交给你的 Agent(推荐) 把下面这句话发给你的 DSH: > 把 `https://github.com/lijiawei255/dsh-status-chime` 装进我的 DSH desktop profile,装完提醒我重启。 仓库里有一份 [INSTALL.md](INSTALL.md),写清了每一步该做什么。它存在的意义是:**让不同的 Agent 装出同样的结果**,而不是各自发挥。 ### 方式二:自己敲命令 把 `` 换成你自己的 profile 名(通常是 `desktop`)。**先看一眼 `$DSH_HOME/profiles/` 下有哪些目录再决定**,不要照抄别人的: ```powershell # 从 GitHub 直接装 dsh plugin --profile add github:lijiawei255/dsh-status-chime # 或者先 clone / 下载 ZIP,再指向本地目录 git clone https://github.com/lijiawei255/dsh-status-chime dsh plugin --profile add .\dsh-status-chime ``` 这条命令会做两件事:把包装进 profile,并把包登记为一个 profile 层(前提是包里声明了 `dsh.bundle`,本仓库已经声明了)。 **⚠️ 如果报 `ERR_PNPM_ADDING_TO_ROOT`**:profile 自带一个声明了 `packages: [.]` 的 `pnpm-workspace.yaml`,pnpm 9 会因此拒绝裸 `add`。加一个 `-w` 即可: ```powershell dsh plugin --profile add -w github:lijiawei255/dsh-status-chime ``` (实测过的是:pnpm 9 会报错、pnpm 11.8.0 正常;**pnpm 10 没测过**。所以最稳的是加 `-w`。)详见 [TROUBLESHOOTING.md](TROUBLESHOOTING.md) 第 11 条。 **装完必须完全重启 DSH Desktop**,否则插件不会加载。 ### 重启之后 在聊天框里敲: ``` /voice-alerts status ``` 看到 `Voice alerts: on (v0.4.0)`、`Language: zh`、`Scenes (8)` 就说明装好了。想听一遍全部八条: ``` /voice-alerts ``` ### 卸载 ```powershell dsh plugin --profile remove dsh-status-chime ``` 卸载**不会**删除你 `$DSH_HOME` 下的东西。想彻底清干净,删这两处: ```powershell # 音频、play.ps1、clips.json Remove-Item -Recurse -Force "$env:USERPROFILE\.dsh\voice-alerts" # 运行时配置(配置**不在**上面那个目录里,是同级的一个文件) Remove-Item -Force "$env:USERPROFILE\.dsh\voice-alerts.config.json" ``` 卸载后需要**完全重启 DSH Desktop** 才生效。 ⚠️ 质检报告**不在** `$DSH_HOME` 下,也**不随包发布**:`qa/report.md` 由开发脚本 `scripts/qa.mjs` 写进**仓库检出目录**里的 `qa/`。那个目录在 `.gitignore` 里、也不在 `package.json` 的 `files` 中,所以用 `dsh plugin add` 装出来的用户**没有这个文件**(要跑质检得先克隆仓库)。 ## 它怎么工作 ``` DSH 事件 ──▶ lib/index.js ──▶ 聚合 / 节流 / 优先级 ──▶ 播放器 ──▶ 音频文件 ``` **事件来源**(都是宿主进程里的公开事件): | 事件 | 用途 | |---|---| | `session/event` → `turn/start` / `user/message` / `turn/end` | 判断这一轮是不是你发起的,以及它是怎么结束的 | | `session/event` → `goal/change` | 目标的 `complete` 与 `block` | | `jobs.events.subscribe` → `settled` | 后台任务结束;`completed` 播、`failed` 播、`killed` 不播,`awaited`(已有等待者收走结果)与 `cause: 'teardown'`(退出/归档)也都不播 | | `tools/pre-execute` | 工具名命中 `waitingTools` 时,说明代理要停下来等你 | | `session/event` → `approval/asked` | **主路径**:审批策略为 `ask` 时必定派发 | | `approval/request` | **兜底**:作用域瀑布,同样只在 `ask` 下可能派发;两条路播同一条音 | **播放规则**:同一 400 毫秒窗口内的多个事件只播**优先级最高**的那条;同一场景 1.5 秒内不重复;新的提示会打断正在播的那条。 **运行时零网络**:插件只读本地音频文件、只启动本地播放器。不联网、不上传、不校验。 ## 技术栈:用大模型给声音做「预审」 这部分是我觉得最值得分享的地方。 给提示音挑一个合适的音色,传统做法是**一个个盲听**:试 10 个音色、改 20 次风格描述、听到耳朵疲劳,最后凭模糊印象拍板。这个项目换了个做法——**让大模型先做初筛和排序,人只在筛出来的少数里做最终决定**。 具体是三件事: **1. 用全模态模型当评审(核心)** `tools/qw_local_omni.py` 把**多段候选音频一次性**交给 Qwen-Omni,让它横向比较并按维度打分: ```powershell python tools/qw_local_omni.py preview/flash-mary-en.mp3 preview/flash-eva-en.mp3 preview/flash-yuanfei-en.mp3 ` --message "横向比较这几段录音,按清晰度/自然度/音色/干净度打分并排序" ``` 拿到的是一个**排序**而不是一堆孤立分数——「A 比 B 更贴目标」这种相对判断,比「A 得 7 分」有用得多。实测给出的排序和理由相当具体(下面是模型理由的概述): > A4 在保持系统提示所需的清晰稳重之外,音色最贴合目标;A2 辨识度不足,A5 偏平淡,A1、A3、A6 则因音色过亮或偏年轻而被排除。 **2. 用 ASR 转写回读做客观校验** 光听着顺耳不够——TTS 可能吞字、念错、把缩写拆开读。`tools/qw_local_asr.py` 把音频转回文字,与预期文案做字符级相似度比对: ```powershell python tools/qw_local_asr.py assets/clips/turn-error.mp3 --lang zh ``` 这条是**可以当硬门槛的客观指标**:文案是已知的,转写对不对是机械可判的。本项目中英各 8 条、共 16 条音频的相似度都是 **1.000**。 **3. 时长梯度作为信息编码** 上面表格里的时长不是随手定的,而是把「严重程度」编码进了音频自身:**长 = 需要你出手**。这样即使手机在旁边、屏幕没看,也能靠声音长短判断该不该放下手里的事。 **由此得到的完整流程**(`scripts/` 里两个脚本,可直接复现): ``` scripts/build.mjs audition 出多个音色候选 ↓ scripts/qa.mjs rank 全模态模型横向排序(不做 ASR;回读在下一步) ↓ 人听筛出来的前 2-3 个,拍板 ← 决策量被压缩到很少 ↓ scripts/build.mjs build 批量生成 + 响度归一 ↓ scripts/qa.mjs clips 全部质检过关 ``` **一点经验**:不要把大模型的主观打分当硬门槛。实测同一段音频两次评分能从「自然 6 / 音色 4」跳到「自然 9 / 音色 7」,四段明显不同的音频甚至拿到过完全一样的分数。所以本项目把指标分了两层——**客观项(ASR 相似度、有无削波、清晰度、干净度)当门槛,主观项(自然度、音色、成熟度)只作参考**,最终由耳朵决定。这个分工是这套流程能稳定跑起来的关键。 **后来补上的三条检查**,都是先发现了具体漏洞才加的: | 检查 | 性质 | 为什么加 | |---|---|---| | **静音下限**(峰值 ≥ −30 dB、均值 ≥ −35 dB) | 硬门槛 | 原来的峰值检查**只抓削波**,所以「时长正常但内容全静音」这种经典 TTS 失效**能全项通过**。实测成品峰值 −4.2…−1.9 dB、均值 −20.6…−16.6 dB,而全静音是 −91 dB。**两条线的余量不一样**:峰值门槛离最差成品 **25.8 dB**(−4.2 vs −30),均值门槛 **14.4 dB**(−20.6 vs −35) | | **净语速**(去掉停顿后的单位/秒) | 参考项 | 中文**刻意放慢**(rate 0.95),套用人类播报那个 3.2–5.5 字/秒会把八条里的**七条**判失败。对这个项目真正有意义的是**条与条之间的一致性**(因为时长承载紧急度),所以跟**同语言中位数**比;少于 5 个单位的短句豁免,否则两个词的 clip 会假报警 | | **UTMOS**(MOS 预测器) | 参考项 | 给自然度一个**可复现**的数字,补上「大模型打分不稳定」那一环。**必须按语言分别比**:它给英文八条的分**全部高于**中文八条(4.38–4.51 vs 3.75–4.28),跨语言比会读成中文那套有缺陷 | 这三条都有**反向验证**(`scripts/qa-negative-control.mjs`,离线运行、不花 API 额度):注入全静音、极安静、语速拉长三类缺陷,断言门槛**确实报出来**,同时确认健康音频**不会被误伤**。一个只会说「通过」的检查器没有价值。 > 顺带记一个坑:加静音下限之前,先用 `ttsproof`(一个现成的 TTS QA 工具)试过。**它能抓削波和截断,但抓不到全静音** —— 它源码里的 `empty_audio` 只判断「文件不存在或 ≤44 字节」,不看音频内容。所以那条下限是自己加的,不是从工具里白拿的。 ## 配置 配置文件:`$DSH_HOME/voice-alerts.config.json`(`$DSH_HOME` 通常是 `~/.dsh`)。 找不到这个文件时使用内置默认值,**所以不配置也能正常用**。文件按修改时间热读取,改完立即生效,不需要重启。 完整模板见 [`assets/voice-alerts.config.json`](assets/voice-alerts.config.json)。常用项: | 配置项 | 默认 | 说明 | |---|---|---| | `enabled` | `true` | 总开关 | | `volume` | `85` | 0-100,**只对 ffplay 生效** | | `minIntervalMs` | `1500` | 同一场景的重复触发抑制窗口 | | `coalesceMs` | `400` | 事件聚合窗口,窗口内只播最高优先级那条 | | `interrupt` | `true` | 新提示是否打断正在播的 | | `scenes.<场景>.enabled` | `true` | 单独关掉某个场景 | | `player` | `"auto"` | `auto` / `ffplay` / `powershell` | | `ffplayPath` | `null` | 显式指定 ffplay 路径(不在 PATH 上时用) | | `playPs1Path` | `null` | 显式指定回退脚本 `play.ps1` 的路径 | | `commandName` | `"voice-alerts"` | 斜杠命令名。与别的插件重名时只丢命令,不影响出声 | | `waitingTools` | `["ask_user_question","exit_plan_mode"]` | 命中即视为「在等你回答」;DSH 若改工具名可在此覆盖 | | `language` | `"zh"` | 语音语言:`zh` / `en`;无法识别的值回退到 `zh`(不会静默) | | `watchApprovals` | `true` | 审批请求是否出声(只在策略为 `ask` 时可能触发) | | `clipsDir` | `null` | 自定义音频目录,优先级最高 | ## 命令 | 命令 | 作用 | |---|---| | `/voice-alerts` | 依次播放全部八条(当前语言) | | `/voice-alerts on` / `off` | 立即开关(会写回配置文件) | | `/voice-alerts lang ` | 切换语音语言,写回配置 | | `/voice-alerts status` | 看播放器探测结果、各语言各场景音频是否齐备 | | `/voice-alerts test <场景>` | 只播一条,用来排查某类事件有没有触发 | ## 换成你自己的声音 音频不是必须用仓库里这 8 条(中英各 8 条)。完整流程: ```powershell # 1. 改 assets/clips.json:文案在 clips.<场景>.text,音色在 model/voice/instruction # 2. 换音色时先出样试听(把候选写在 voiceCandidates 里) node scripts/build.mjs audition node scripts/qa.mjs rank # 大模型帮你排序,人只挑最终那条 # 3. 把选定的 model/voice/instruction 写回 clips.json,然后批量生成 node scripts/build.mjs build node scripts/qa.mjs clips # 质检 ``` 生成音频需要:**ffmpeg**(响度归一与转码)、**Python 3**(质检脚本)、**阿里云百炼 CLI**(TTS/ASR/Omni)。这三样**只有你想自己生成时才需要**——用仓库自带音频的话,什么都不用装。 生成后会有 `mp3` 与 `wav` 两份。**不要删掉 wav**:PowerShell 回退播放器只认未压缩 PCM,它是「干净 Windows 上零依赖」的保证。 自己生成的音频放在 `$DSH_HOME/voice-alerts/clips/` 会自动优先于包内的(逐文件覆盖,所以只想换一条也可以)。 **文件名规则要记住**(想只替换一条英文音频时必须知道): | 语言 | 文件名 | |---|---| | 中文(默认) | `<场景>.mp3` / `.wav`,例如 `turn-done.mp3` | | 英文 | `<场景>.en.mp3` / `.en.wav`,例如 `turn-done.en.mp3` | **默认语言用不带后缀的名字,其它语言加 `.<语言码>` 中缀。** 所以只想换英文的 `approval`,就放一个 `approval.en.mp3`(以及 `.wav`,如果你用 PowerShell 回退播放器)。 ## 装了没声音? 按这个顺序排查,能覆盖绝大多数情况。完整版见 [TROUBLESHOOTING.md](TROUBLESHOOTING.md)。 1. **重启了吗?** 插件文件不热加载,改完或装完必须完全重启 DSH Desktop。 2. `/voice-alerts status` 看 **Player** 那一行。`unavailable` 说明两条播放路径都没探测到。 3. **Windows 音量合成器把 `ffplay`(或 `powershell.exe`)单独静音了**——这是最常见的原因。右键任务栏音量图标 → 打开音量合成器,检查对应条目。 4. 输出设备选错了。 5. 某个场景被关掉了,或 `enabled` 是 `false`。 6. 日志里有 `no <语言> audio for <场景>`(例如 `no zh audio for turn-done`)→ 该语言的音频文件缺失。 7. 日志里有 `throttled <场景>` → 被节流窗口挡住了,属于正常行为。 8. **命令打了没反应?** 如果 `/voice-alerts` 单独打能播(试听 + 状态),但带上参数(`lang en`、`on`、`test <场景>`)就**毫无反应、反而像发了一条普通消息**,多半是**中文输入法把 `/` 打成了全角 `/`**,或者命令名里混进了大写(必须是全小写 `voice-alerts`)。这两种都会让整行不被认作命令。**从自动补全列表里选中命令**可以同时避开这两点。详见 [TROUBLESHOOTING.md](TROUBLESHOOTING.md) 第 14 节。 日志位置:`%APPDATA%\DSH Desktop\logs\host\dsh-<日期>.log`,搜 `voice-alerts`。 ⚠️ **一件事先说明**:日志里**没有** `ffplay` **不是故障**。ffplay 属于 ffmpeg,需要另外安装;它只是让启动快一点、音量可独立调节。没有它时插件会用 Windows 自带的 PowerShell 播放器,功能完全正常。 ## 平台与依赖 **支持:Windows 10 / 11。** 干净 Windows 上**不需要安装任何第三方依赖**就能出声,因为保底播放器用的是系统自带的 Windows PowerShell 5.1 + .NET `System.Media.SoundPlayer`。 | 情况 | 需要什么 | |---|---| | 干净 Windows 10/11,只装了 DSH | **什么都不用装**,用系统自带播放器,启动开销约 0.4 秒 | | 装了 ffmpeg | 无需配置,自动改用 ffplay:启动更快、`volume` 配置生效 | | 想自己生成音频 | 额外需要 ffmpeg + Python 3 + 阿里云百炼 CLI | | 运行时网络 | **零网络** | 播放后端的选择顺序: 1. **ffplay**(如果探测到)—— 直接播 mp3,支持独立音量 2. **Windows PowerShell + SoundPlayer** —— 系统自带,只认 wav,音量跟随系统 先试 `-File play.ps1`(可审计的磁盘脚本);若被执行策略或 ACL 拦住,自动改用 `-EncodedCommand` 内联命令重试一次 3. 两条都不通 → 静默降级,只在日志里记一行 宿主侧**拿不到窗口是否聚焦**(DSH 的 native 桥只暴露了 `notifyAttention` 等接口),所以这个插件**任何时候都会出声**,包括你正看着窗口的时候。如果觉得吵,用 `/voice-alerts off` 或调 `volume`。 ## 验证状态 我一向觉得,把「写过」和「验证过」分开讲清楚,比含糊地说「功能完整」有用得多。所以逐条列出,并且**明确写出验证的边界**。 下面这张表是摘要。**逐条的原始证据、测量值、以及「没验证到哪一步」都记在 [`docs/verification.md`](docs/verification.md)** —— 想核对任何一个数字就去看那份。 ### 验证边界(请先读这一段) **以下结论全部来自同一台机器上的验证**: | 项 | 值 | |---|---| | 操作系统 | Windows 11 | | DSH | DSH Desktop(官方版),`@deepseek-ai/dsh` **0.1.7-rc.2** | | 该机器上的额外软件 | 装有 ffmpeg、Python 3、阿里云百炼 CLI | > 上一版(0.3.0)验证的是 **0.1.5-rc.2**。0.1.7 移除了 `jobs.onJobDone`,后台任务两个场景因此静默失效;0.4.0 就是为这件事发的——它改用 0.1.7 的 `jobs.events.subscribe`,同时保留对旧 API 的回退,所以 0.1.5/0.1.6 也照常可用。 **这意味着**: - ✅ **代码正确性、事件映射、音频质量**——与本机装了什么无关,结论可迁移到你的机器。 - ✅ **「干净 Windows 零依赖」**——保底播放器用的是 Windows 自带的 PowerShell 5.1 + .NET `System.Media.SoundPlayer`,两者都是 Win10/11 的**操作系统组件**;无 ffmpeg 的情形已通过模拟验证。 - ⚠️ **DSH 版本**——**在 0.1.7-rc.2 与 0.1.5 系上验证过**(后者由自测的旧 API 阶段模拟,不是真机)。再往后的版本若又改事件接口,某些场景可能再次失效。这是唯一真正未知、且我无法在本机消除的变数。 - ⚠️ **在一台完全干净的、别人的 Windows 上从 GitHub 安装**——**我没有第二台机器,没有实测过这一步**。CI(见下)在 GitHub 提供的干净 Windows 运行器上覆盖了「安装 + 加载 + 后端探测」,但**运行器没有声卡,无法验证声音真的到了扬声器**。 如果你在别的 DSH 版本或别的机器上遇到问题,请提 Issue 并附上日志里的 `[voice-alerts] active …` 那一行(它会写明探测到的播放器和配置路径),那基本能一眼定位。 ### 逐层状态 | 层级 | 状态 | |---|---| | **事件层** | 8 个场景中 **7 个由真实事件触发验证过**:`turn-done`、`turn-error`、`needs-input`、`job-done`、`goal-complete`、`goal-blocked`、`approval` | | **音频层** | 中英各 8 条、共 16 条**全部通过自动质检**(ASR 相似度 1.000、清晰/干净 10/10、无削波)。其中**中文那 8 条另做过逐条耳听确认**;**英文那 8 条没做过这一步**,只过了自动质检 | | **后端层** | 强制 PowerShell 会正确选 `.wav`;模拟「没装 ffmpeg」时自动回退且仍能播;显式指定不存在的 ffplay 会明确失败而不偷偷换后端 | | **代码层** | `scripts/selftest.mjs` 用模拟上下文驱动插件,**76 项检查**覆盖事件映射、过滤规则、优先级、节流、命令、重名冲突、资产解析顺序、语言切换与回退,以及**新旧两代 jobs 事件 API 的探测与回退** | | **语言层** | 默认中文、`lang en` 切换后确实改选英文文件(自测断言的是**解析到的文件名**,不只是状态文字);配置里写无法识别的语言会**回退到中文**而不是静默 | | **CI** | `.github/workflows/verify.yml` 在干净的 `windows-latest` 上验证:真实安装并登记为 profile 层、清单无 BOM、只依赖 Node 内置模块、32 个音频齐备(8 场景 × 2 语言 × 2 格式)、**无 ffplay 时 PowerShell 后端仍被探测到**、76 项自检、隐私扫描 | 关于 `approval` 的验证要说清楚边界:**触发时审批策略必须是 `ask`**。我本人是在 `ask` 策略下听到提示音的,但当时**无法区分**它走的是会话事件 `approval/asked` 还是兜底的作用域瀑布 `approval/request` —— 两条路径播同一条音。会话事件那条的**行为**由自检覆盖(含「子代理会话的审批不出声」),但它是否在生产环境中被派发,我没有单独取证过。 CI 的详细「证明了什么 / 没证明什么」写在 workflow 文件头部——包括**它不能证明声音到达扬声器**这一点。 ### 一个已知缺陷,必须说清楚 **`job-failed` 对「后台 shell 命令非零退出」实际不可达。** 实测:一个以后台方式运行、`exit 7` 结束的命令,DSH 把它的状态记为 `completed`,于是播的是「后台任务完成」。原因是 job 的状态只有 `completed` / `killed` / `failed` 三种终态,而 shell 后台任务的生产者只会产出前两种;`failed` 保留给「后台**工具**任务报告错误」或生产方合约违约的情况。**这是框架行为,0.1.7 里依然如此**,不是本插件的实现问题。 0.1.7 新增了一个**可观察点**:job 详情里现在带 `exit code: N`,所以非零退出在日志里看得到了。但我**故意不去解析它**——那是写给人看的字符串,把它当契约,下次框架改措辞就会静默失效。想要「非零退出也响」的话,正确做法是让 DSH 把退出码变成状态字段,而不是在这里猜文本。 这个场景**保留**了(它对工具任务失败是有效的),但它的触发**未被真实复现过**,只有代码层面的核对。你可以用 `/voice-alerts test job-failed` 验证音频本身没问题。 ### 0.4.0 修的是哪一处 0.1.7 删掉了 `jobs.onJobDone`,而 0.3.0 只认它——所以升级到 0.1.7 之后,**`job-done` 与 `job-failed` 两个场景是静默失效的**,日志里连一行报错都不会有(异常被宿主按 fiber 吞掉了)。0.4.0 改成订阅 0.1.7 的 `jobs.events.subscribe({ owners: 'all' })` 提交流: - 只认 `settled`;`registered` / `progress` / `stopping` / `output` / `removed` 一律忽略。 - `awaited: true`(已有等待者收走结果)与 `cause: 'teardown'`(会话归档、宿主退出)都**不出声**——否则每次关程序都会响一声,`job_wait` 也会重复播报。 - 旧 API 仍然保留为回退分支:宿主没有 `events.subscribe` 时自动走 `onJobDone`,并在日志里说明用的是哪一条。两条分支都由自测覆盖。 ## 本地与开源:镜像,以及唯一允许的差异 这个项目同时是两样东西:**别人下载安装的仓库**,和**我本机正在跑的那份**。两者一旦不一致,麻烦在于它不留痕迹——版本号还是同一个,代码已经变了,于是"我本地测过的"和"你下载到的"就不是一个东西。所以镜像被当成一条可执行的规则,而不是一个承诺。 **逐字节镜像**(`node scripts/sync-profile.mjs --all --check` / `--apply`): | 本地 | 仓库 | |---|---| | 单文件安装的 `/.js` | `lib/index.js` | | `$DSH_HOME/voice-alerts/clips/**` | `assets/clips/**`(32 个音频) | | `$DSH_HOME/voice-alerts/play.ps1` | `assets/play.ps1` | 这条规则不是摆设:这次就靠哈希抓出本地那份**落后三笔提交、还差一次宿主 API 迁移**,而两边版本号一模一样。 **唯一允许的差异是措辞。** `$DSH_HOME/voice-alerts/clips.json` 是**私密母本**(措辞按我的原话写,不外发),仓库里的 `assets/clips.json` 是**同一批音频的中性描述**——**音频不重新生成**,32 个文件就是当初试听选中的那批。差异只允许落在:注释、试听候选清单(id / label / instruction)、`chosenCandidate` 名字、风格 instruction。 **而且是机器可验的**:`sync-profile.mjs` 会把每一个差异字段路径分类,**功能性差异必须为 0**(模型、音色、rate、pitch、volume、format、sampleRate、`loudnorm`、以及每一句中英文案),非 0 直接以 exit 3 报出来,因为那种差异不是"措辞",改不了也糊不过去。分类方向是保守的:**没被归类的字段一律算功能性**,所以新增字段不可能被当成"大概只是措辞"混过去。 **禁用词表只存在本地**:`$DSH_HOME/voice-alerts.scan.json`(在任何 clone 之外),由 `node scripts/scan-sensitive.mjs .` 执行。扫描器**只报文件、行号、类别,绝不回显命中的内容**,所以它的输出可以安全贴进 Issue、CI 日志或对话——检查本身不会变成被检查内容的第二份拷贝。 两点如实说明: - **CI 跑不了这道措辞闸门**——GitHub 运行器没有我的 `$DSH_HOME`,所以 CI 只应用内置规则(凭据、密钥形态、个人路径)。措辞规则保护的是我提交前的那一步。 - **正向对照**:扫私密目录(`node scripts/scan-sensitive.mjs "$DSH_HOME/voice-alerts"`)**必须报命中**;如果它变干净了,说明本地规则没加载,闸门实际上开着。 完整规则、逐项差异与"什么不算措辞差异"(`maturity` 是 `qa.mjs` 的评分维度名,保留)见 [`docs/mirror-policy.md`](docs/mirror-policy.md)。 ## 目录结构 ``` dsh-status-chime/ ├── lib/index.js # 插件主体,唯一运行时代码 ├── cordis.patch.yml # 声明这个包是 profile 层(dsh.bundle 指向它) ├── package.json # 包名、dsh.bundle 声明、engines ├── assets/ │ ├── clips/ # 8 场景 × 2 语言 = 16 条音频,每条 mp3 + wav(共 32 个文件) │ ├── clips.json # 文案/音色/参数的唯一事实源 │ ├── voice-alerts.config.json # 配置模板 │ └── play.ps1 # PowerShell 回退播放器(纯 ASCII,原因见文件头) ├── tools/ # 百炼本地音频辅助脚本(ASR / Omni / UTMOS) ├── scripts/ # 生成 / 质检 / 自检 / 负向验证 / 隐私扫描 │ ├── build.mjs # 生成音频(支持 --lang) │ ├── qa.mjs # 质检(支持 --lang) │ ├── selftest.mjs # 离线自测,76 项 │ ├── qa-negative-control.mjs # 证明质检门槛真的会拦下坏音频 │ ├── verify-local-install.mjs # 验证已安装的单文件版本 │ ├── sync-profile.mjs # 让单文件本地安装与仓库保持一致(--check / --apply) │ ├── scan-sensitive.mjs # 隐私/措辞扫描 │ └── scan-sensitive.verify.mjs # 证明扫描器真的抓得到 ├── docs/verification.md # 逐条验证记录 ├── docs/mirror-policy.md # 镜像规则:什么逐字节相同、什么只允许差措辞 ├── .github/workflows/verify.yml # CI:干净 Windows 上跑安装 + 自测 + 扫描 ├── INSTALL.md # 给 Agent 看的安装步骤 ├── TROUBLESHOOTING.md # 没声音时的排查清单 ├── README.en.md # 英文说明 ├── CHANGELOG.md └── LICENSE # MIT ``` `qa/`、`preview/`、`tmp/` 是脚本跑出来的中间产物,已在 `.gitignore` 里,不随仓库发布。 ## 收录 已被 **[Awesome DSH Plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)** 收录,分类 `notify`:条目见[通知与集成](https://awesome-dsh-plugin.com/zh/notify/),提交记录 [#5553](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/5553)(已合并)。 那个列表是**人工 + CI 审核制**,不是爬虫采集:要求仓库声明 `dsh.bundle`、含真实可运行的代码、仓库创建满 1 天,而且**条目描述必须和代码对得上**。 ## 许可 代码与随包音频均以 **MIT** 发布。 随包音频是用阿里云百炼(Model Studio)的语音合成生成的,不是任何人的真人录音;如果你打算使用它们,请自行确认你的用法符合相应服务条款。仓库不授予任何底层声音模型的权利。你完全可以不用这套音频——`scripts/build.mjs` 可以生成你自己的,插件也会优先使用你放在 `$DSH_HOME/voice-alerts/clips/` 的文件。 ## 贡献 提交前请跑这两个脚本。第一个确认没有把个人路径或凭据带进来,第二个确认自检仍然全绿: ```powershell node scripts/scan-sensitive.mjs . node scripts/selftest.mjs ``` `scan-sensitive.mjs` **只报告文件、行号与命中类别,绝不打印命中内容**——所以你在 Issue 里贴它的输出是安全的。它自带的规则集可以用 `$DSH_HOME/voice-alerts.scan.json`(不提交)追加你自己机器上的私有字符串。想确认它真的能抓到东西,跑 `node scripts/scan-sensitive.verify.mjs`:它植入几段明显是伪造的凭据形态值并断言扫描器能报出来,同时断言报告不回显这些值。 欢迎提 Issue 与 PR。特别欢迎的:macOS / Linux 的播放后端(当前后端层是 Windows 专用的)。