# dsh-pinpu · 频谱律动 > 接管 DSH 对话右侧那条**轮次刻度条本身**,让它跟着系统正在播放的声音律动;同时按 Codex 的做法改掉高亮行为——默认不再高亮最新一条,鼠标移上去的那条才高亮,点击跳转照旧。 ![律动中](docs/preview-live.png) 上图不是示意图:它是 `tools/verify/rail-preview.mjs` 在真实 Chromium 里、用**从 DSH 自己的 `dsh-client-ui-chat` 安装包里抽出来的刻度条样式表 + 真实类名**搭出刻度条,再加载**真实的 `lib/client.js`** 跑出来的(只有 `/api/dsh-pinpu/spectrum` 的数据是桩)。刻度长度的差异就是频谱:低频对应靠下的刻度(靠近最新一轮),高频在上。 鼠标悬停(同一次运行的第三张截图)只让指针下那一条变亮变满,其余保持暗色: ![悬停高亮](docs/preview-hover.png) ## 安装 - **从本仓库装**:在 DSH 的插件页用它安装 GitHub 仓库 `sqslm/dsh-pinpu`(和装其它 GitHub 插件一样),或 - **本地打包装**:`node tools/install-profile.mjs`(`pnpm pack` → 写进 profile 的 `dsh.profile.bundles`,可 `--uninstall` 撤销、`--dry-run` 只看计划)。 - 首次装载后**需要重启一次 DSH**:宿主半边(抓音频、注册路由)在进程启动时装载,浏览器半边随页面加载。 - 运行时无需安装任何依赖:原生采集模块随包分发(见 [THIRD-PARTY.md](THIRD-PARTY.md)),但需要一个 **CPython 3.12** 解释器——DSH 自带的那个就是,插件会自己去找。 - 平台:目前只支持 **Windows**(WASAPI 回环)。Linux 下 `getUserMedia` 的 monitor 源或 PulseAudio 的 `.monitor` 需要另做,尚未实现。 ## 它改了什么 DSH 的刻度条有自己的状态类(`mark` / `markActive` / `markPreview` / `markBusy` / `markUnloaded`,名字是构建期哈希过的 `xpvNua_*`)。插件**不读类名**,而是: 1. **按几何识别**:在 `[data-conversation-scroll]` 里找出「同一父节点下、尺寸一致、右边缘对齐、垂直等距(≈10px)的一列按钮」——那就是刻度条; 2. **给它们加插件自己的属性**(`data-pinpu-rail` / `data-pinpu-tick="i"`,i 从最下面一条开始数 0); 3. **用一张 `!important` 的样式表覆盖状态**: - 所有刻度统一为暗色 + 统一长度 → **内置的“最新一条高亮”被关掉**(不需要知道哪个类表示高亮); - `:hover` 的那条变亮、拉满 → **只有鼠标在刻度条上才高亮**; - 播放时每条长度 = 它那一段频段的电平(`transform: scaleX(var(--pinpu-scale))`),频段按 log 从 45Hz 铺到 16kHz; - 正在生成的那条(`markBusy`,靠 `::before` 上是否有 animation 判断)**不接管**,保留它自己的脉冲动画。 点击跳转不动:插件的样式表只改 `::before` 伪元素,命中区域仍是刻度条原来的按钮,app 自己的点击监听照旧收到事件(预览工具里实测 `clicks delivered to the app handler: ["7"]`)。 实测(同一次预览运行): | 状态 | 最后一条刻度(原本是内置高亮) | 悬停的那条 | 未被悬停的 | | --- | --- | --- | --- | | 接管前 | `rgb(249,250,251)`、`scaleX(1)`(亮、满) | — | 暗 | | 接管后(播放中) | `rgba(255,255,255,.2)`、`scaleX(0.87)` | `rgb(249,250,251)`、`scaleX(1)` | 暗、`scaleX(0.49)` | ## 音源:直接读系统输出(网易云之类) ``` 网易云 / 浏览器 / 任何程序 → 默认输出设备 → WASAPI 回环 → 频谱 → 刻度条 ``` - 采集**默认输出设备的回环**,也就是「你现在听到的声音」,不需要任何虚拟声卡,也不挑播放器。 - 想固定到某个输出设备(例如插了耳机但想抓主板声卡),在设置卡片的设备下拉里选;留空 = 跟随系统默认输出。 - 采集在**宿主半边**(Node)里做:它用随 DSH 分发的 Python 跑 `vendor/loopback.py`,后者用**随插件一起分发的 PyAudioWPatch**(≈290KB,PortAudio 的回环构建)打开 `[Loopback]` 输入,把单声道 float32 PCM 从 stdout 流出来。宿主做 FFT 与频段映射,浏览器半边只负责画。 - 因此**不需要麦克风权限、不需要用户手势**,浏览器端一行采集代码都没有。 为什么不走 `getUserMedia`:它只能开「采集端点」。本机实测——注册表里 `立体声混音 (Realtek USB Audio)` 存在但处于禁用态(`DeviceState=0x10000001`,而 Chromium 会列出的端点都是 `0x1`),`navigator.mediaDevices` 里根本看不到它;更要紧的是**立体声混音只镜像某一个输出设备**,而本机的默认输出是耳机,所以就算启用了它,也听不到你在听的声音。回环抓默认输出才是正解。 ## 设置项(设置 → 频谱律动) 宿主管**信号**,浏览器管**呈现**,所以下面分成两类。全部 `.volatile()`,改完立即生效(首次装载插件仍需重启一次)。 | 字段 | 默认 | 属于 | 说明 | | --- | --- | --- | --- | | `enabled` | `true` | 宿主 | 总开关;关掉后连采集进程都不再启动 | | `device` | `''` | 宿主 | 空 = 跟随默认输出;也可选具体 `[Loopback]` 设备 | | `fftSize` | `1024` | 宿主 | FFT 窗口,≈21ms @48kHz | | `smoothing` | `0.78` | 宿主 | 帧间平滑,越大越稳 | | `gain` | `1` | 宿主 | 曲线后的整体增益 | | `autoGain` | `true` | 宿主 | 按最响频段归一(见下) | | `gate` | `0.04` | 宿主 | 低于它算静音 | | `minHz` / `maxHz` | `45` / `16000` | 宿主 | 频谱范围 | | `punch` | `0.55` | 宿主 | 打击感:某个频段**瞬间高过它自己最近的峰值**时按超出比例放大,鼓点是跳一下而不是"稍快一点" | | `flutter` | `0.08` | 宿主 | 灵动:每个频段一条**慢速随机游走**,乘在该频段电平上(相邻刻度不再整体一起动,安静时自动消失) | | `disableActiveHighlight` | `true` | 浏览器 | 关掉内置的“最新一条高亮” | | `hoverHighlight` | `true` | 浏览器 | 鼠标移到刻度上才高亮 | | `restoreOnSilence` | `true` | 浏览器 | 静音一段时间后停止律动(刻度停在基准长度) | | `minScale` / `maxScale` | `0.4` / `1` | 浏览器 | 最短 / 最长刻度(20px 的比例;0.4 就是内置“未加载”刻度的长度) | | `invert` | `false` | 浏览器 | 低频改到最上面 | | `side` | `right` | 浏览器 | 刻度条所在的那一侧:`right` = 应用自己的位置,`left` = 镜像到左侧(刻度朝向和悬停预览卡片一起镜像) | | `colorMode` | `theme` | 浏览器 | `theme` 用刻度条自己的颜色;`spectrum` 按频段给色;`custom` 用自定色 | | `customColor` | `#4d93f8` | 浏览器 | `custom` 的颜色 | | `glow` | `true` | 浏览器 | 亮度层次:每段的亮度跟着自己的电平走(短=暗、命中=长且亮),悬停时仍是满亮 | | `hotScale` | `0.85` | 浏览器 | **长度阈值高亮**:某段长度达到满长的这个比例就点亮它(`0` = 关闭) | | `spark` | `1.5` | 浏览器 | **随机高亮**:每秒期望点亮几次(每次随机挑一段亮 160–420ms;`0` = 关闭,静音时不亮) | | `demo` | `false` | 浏览器 | 无采集时用合成波形演示(默认关闭——会动的假波形容易骗人) | ### 两种高亮 都是在**已有刻度条**上点亮,不新增任何元素:被点亮的段用悬停时那支亮色 + 满亮度,长度仍是频谱自己的值。 - **阈值高亮**(`hotScale`):判据是**看得见的长度**,不是电平——拿写进 `--pinpu-scale` 的那个值直接比较,所以一个参数只被解释一次。默认 `0.85`:高频段满长时亮,`1.05` 就等于关掉(频谱到不了),`0` 也等于关。 - **随机高亮**(`spark`):每秒期望次数(`1.5` = 大约每 0.7 秒一次)。每一次随机挑一个 *没有* 正在跳动、也没被阈值点亮的段,亮 160–420ms 后自行熄灭。**静音时不闪**,所以正常听歌是"偶尔点缀"而不是一直闪。 - 正在生成的那条(`markBusy`)两种高亮都不碰,保留它自己的脉冲动画;鼠标悬停仍然优先于高亮。 真浏览器实测(`tools/verify/rail-preview.mjs`,一次运行采样 28 帧): ``` samples with a highlighted mark: 23 of 28 (threshold 20, random 3) a highlighted mark, computed: {"reason":"spark","background":"rgb(249, 250, 251)","opacity":"1","transform":"matrix(0.693…)","scale":"0.723"} ``` 即:被随机点亮的段确实是**亮色 + 满亮度**,而长度仍是它自己的 `0.723`。 ### 律动是怎么算出来的(v0.3.0 重写) 旧实现的机械感来自四个具体原因,逐条对应:**64 个频段共用同一个一阶平滑系数**(所有段时间常数相同,只能整体一起动);同一个系数同时管上升和下降(鼓点被抹成缓慢起伏);没有瞬态强调与惯性保持;只有长度会变、亮度恒定。现在每个频段各自走一套: 1. **快起慢落,且每段速率不同**:上升瞬时到位,回落用该频段自己的系数(低频拖尾 ~150ms、高频干脆 ~85ms 半衰期),所以同一次鼓点在不同段上是不同性格,不再形成一条"扫过去"的波。 2. **Peak Hold + 惯性**:命中后保持 4 帧,然后按**该频段自己的**速率衰减(这里踩过一次坑:用统一衰减速率会把刚做的分频段差异完全盖掉)。 3. **瞬态强调 `punch`**:判据是"比这个频段**最近的峰值**高出多少",而不是"比缓慢均值高多少"——后者对持续音也成立,会把整块主体一起抬起来(实测对比度反而从 1.63× 掉到 1.17×)。改判据后同一段音频:`2.13×`(开)对 `1.74×`(关)。 4. **`flutter` 微动**:每个频段一条低通随机游走,乘在电平上。它没有周期(正弦式的固定周期正是"循环扫描感"的来源),而且安静时会自动归零,不会让安静的房间里刻度还在抖。 5. **静音自然衰减**:静音判定只看原始频谱能量(抖动与强调都不能把安静弄活),但包络继续按各自的速率下落,浏览器侧在落的过程中照常绘制——停顿/切歌时是**渐落**,落完(约 700ms)才交还刻度条。 6. **亮度层次 `glow`**:每段同时写长度和亮度,亮度用同一个电平的凹曲线(`0.18 + 0.82·level^0.6`),所以是"层次"而不是第二套动画。 实测(`tools/verify/dynamics-profile.mjs`,合成"底鼓+旋律+噪声+静音"喂真实分析器): | 指标 | 结果 | 意义 | | --- | --- | --- | | 最响频段同向连续移动的最长帧数 | `2` 帧(43ms),方向反转 23 次 | 没有固定方向扫描(机械扫频会是几十帧同向) | | 低频 / 高频衰减半衰期 | `171ms` / `43ms` | 有快有慢 | | 相邻频段变化的相关性 | `0.48` | 不是整体同步(1.0 = 完全同步) | | 鼓点对比度(关 / 开 `punch`) | `1.74×` → `2.13×` | 鼓点确实跳(同一段音频的 A/B) | | 音乐停止后 | 峰值 `0.008`,下行过程 0 次上跳 | 安静时自然减弱,不闪烁 | ### 为什么需要自动增益 预览工装实测:不开归一化时,24 个频段只有 `0.51~0.76` 的跨度,37 条里只有 6 种长度,画出来像一堵墙;打开后同一信号得到 `0.18~1.0`、11 种长度。另一个实测结论是 Chromium 默认的 `-100..-30 dB` 分析窗口会把正常响度的内容整排顶格,所以窗口上沿抬到 `-25 dB`。 ### 左右两侧 `刻度条位置` 选 `左侧` 时,插件不是另画一条,而是把**应用自己的那条刻度条的定位容器**(那层 `position:absolute` 的 frame)从 `right:12px` 改成 `left:12px`,同时把刻度的生长方向镜像过来(原本挂在右边缘、向左生长,改成挂在左边缘、向右生长),连悬停预览卡片也一起镜像,不然它会跑到屏幕外。真浏览器里实测:`frame left` 从 ~1160 变成 203(即对话左边缘),预览卡片由「开在左侧」变成「开在右侧」。 ## 验证状态 | 手段 | 覆盖 | 结果 | | --- | --- | --- | | `node --test test/*.mjs`(75 项) | 清单/补丁契约、三处默认值一致、真实 `schemastery` 校验(每个字段都解析成 volatile 引用)、路由的鉴权与响应、采集的启停、**PCM 环形缓冲的窗口随时间前进**(用可辨认的样本值验证,含绕回之后)、FFT 对拍(与朴素 DFT 逐频段比对)、频段/重采样/电平契约、**律动契约**(快起慢落、低频比高频拖尾长、瞬态强调、微动存在但不闪烁且不能把安静弄活)、按几何识别刻度条的判别、左右镜像、亮度层次与不扫描的 CSS 规则、以及引擎的完整接管行为(发现→打标→轮询→写长度→写亮度→静音渐落→归位→错误停靠→禁用清理) | 全绿 | | `node tools/verify/dynamics-profile.mjs` | 律动"性格"的量化:合成底鼓+旋律+噪声+静音喂真实分析器,测同向连续帧数、分频段半衰期、相邻频段相关性、鼓点对比度 A/B、停止后的下行过程 | 6/6 通过(数字见上表) | | `node tools/verify/host-smoke.mjs` | 真实宿主链路:`apply()` → 采集进程 → 回环 PCM → FFT → 路由载荷 | 实测 `status: live`、设备 `Speakers (Realtek Audio) [Loopback]`、6 秒内峰值 `0.34~0.88`、23 种不同频段签名、12 种条长;非本机 Host 被 403 拒绝 | | `node tools/verify/rail-preview.mjs` | 真浏览器 + 真刻度条样式/类名 + 真 `lib/client.js`:内置高亮被关、悬停高亮、点击仍触发 app 的处理器、长度随频谱变化 | `VERDICT: the shipped rail was taken over and dances` | | `node tools/verify/payload-contract.mjs --installed` | 跨半边契约:把**正在运行的宿主**真实返回的载荷喂给**窗口里正在跑的那份**客户端代码,看它会画出什么(无需重启) | 修复前 `distinct scales: 1`(每个刻度同长),修复后 4 种长度 | | 插件页与设置卡在运行中的应用里注册 | **未验证** | 需要重启一次 DSH(本会话就跑在那个进程里,重启会杀掉它) | 重启后的验收清单: 1. 「插件」页出现 **dsh-pinpu / 频谱律动**且为启用; 2. 设置 → **频谱律动**能读到配置,状态行显示 `采集中` 与设备名(说明宿主采集起来了); 3. 放音乐(网易云即可)——对话右侧刻度条开始变长变短,**最新一条不再常亮**;把鼠标移到刻度条上,指针下那条变亮变满;点击仍能跳到对应轮次。若状态是 `未找到 Python 运行时` 或 `错误`,卡片里会给出原因。 4. 判"律动是否自然"看三点:鼓点处**跳一下再回落**(不是缓慢起伏)、**相邻刻度不同步**(不会整齐划一地动)、**停歌后渐落**而不是硬切。嫌太跳就把 `打击感` 调低,嫌太死就把 `灵动` 调高(两者都在设置卡的"响应"区,改完立即生效,无需重启)。 ### 排查:是哪个半边没起来 插件有两个半边,由两套不同机制装载:宿主半边(Node:抓音频、提供路由、有日志)和浏览器半边(接管刻度条、渲染设置卡片)。「没反应」可能只是其中一半,而浏览器半边平时不留痕迹——所以宿主会记录它的上报: ```powershell curl.exe "http://127.0.0.1:19387/api/dsh-pinpu/spectrum?diag=1&ticks=4" # {"ok":true,"status":"live",...,"client":{"event":"heartbeat","detail":"live","ageMs":1200,"count":18,"errors":[]}, # "python":"…\\python.exe","bands":64} ``` `client.event` 为 `none` = 浏览器半边从未运行(宿主半边是好的);`error` 会带上出错的那一步和原因。另外 `node tools/diag-client-scan.mjs` 能离线回答「这个包会不会被客户端模块系统认成浏览器半边」(把包名换成 `dsh-plugin-proxy` 可作对照)。 ## 安装 / 更新 / 卸载 ```powershell cd path\to\dsh-pinpu node --test test/*.mjs # 先跑测试 node tools/verify/host-smoke.mjs # 想确认能抓到系统声音 node tools/install-profile.mjs # 打包 + 备份 + 改 profile + pnpm install --offline # 然后重启 DSH(组合包一层需要一次重启才会装载) ``` - **为什么装 tarball 而不是 `link:`**:`link:` 依赖会被解析到 profile 之外的源码目录,宿主半边 `import z from '@deepseek-ai/schemastery'` 会从源码目录往外找,而核心包在 profile 的 `node_modules` 里——找不到。`dsh-purge` 的注释记过同一个坑。装成 tarball(含 `vendor/`)后解析路径与 harness 自身一致。 - **改了源码要重新打包并重装**,并且**改一下版本号**(同一版本号重打包,pnpm 可能认为“已是最新”而保留旧副本)。 - **卸载**:`node tools/install-profile.mjs --uninstall` 后重启;或直接用 `.bak-pinpu-*` 备份覆盖 `package.json` / `pnpm-lock.yaml`。 ## 已知限制 - 接管依赖刻度条的**几何形状**(一列等距、右对齐的按钮)。如果 DSH 以后改了刻度条的布局,插件可能认不出来——那时它不会乱改页面,只是不生效;设置卡片里的状态会显示刻度数为 0。 - 采集的是**你选的那个输出设备**的回环;系统默认输出换设备后,留空会自动跟随,但钉住某个设备时不会。 - 频段数由刻度条数量决定(一轮一条),不是自定义条数;`minScale`/`maxScale` 只能调长度范围。 - 内置的“忙碌脉冲”那条刻度不参与律动(保留它自己的动画)。 - 回环采集需要 Windows 上的 PortAudio 回环构建(`pyaudiowpatch` 的 Windows 轮子已随包分发);其它平台要另装或改用虚拟声卡。 ## 许可 MIT。