Bundle
dsh-rail-equalizer
让 DSH 的「轮次导航」导轨跟着系统正在播放的声音实时律动(Windows)。宿主端走 WASAPI 回环采集,零授权、不弹窗、不碰麦克风;浏览器端只注入 CSS 变量,不改动官方组件,关闭后完全恢复默认样式。
- Source
- yuanyiHY
- License
- MIT
- Updated
- Updated 12 hours ago
Readme
# dsh-rail-equalizer
让 DSH 的**「轮次导航」导轨**(聊天右侧那根刻度条)跟着**系统正在播放的声音**实时律动。
> 放音乐、放视频、打游戏 —— 只要声音是从这台电脑的默认播放设备出来的,导轨就会跟着动。
> **不需要任何授权、不弹任何窗口、不碰麦克风、不录屏**,戴耳机照样工作。


> **仅 Windows。** 音频采集层依赖 WASAPI。英文说明见 [README.en.md](README.en.md)。
---
## 它是怎么拿到声音的
这是本插件唯一"重"的地方,值得说清楚。
浏览器的 `getDisplayMedia`(屏幕共享)**做不到"一次授权"** —— 这是 Chrome/Edge 刻意的安全设计:每次调用都必须重新选一次共享目标,权限不做持久化,没有任何 API 能绕过。而麦克风方案在**戴耳机**时完全无效。
所以本插件走的是**宿主端 WASAPI 回环**:DSH 的宿主进程(Node)通过 `koffi` 直接调用 Windows Core Audio 的 COM 接口,抓默认播放设备正在渲染的 PCM。
```
IMMDeviceEnumerator.GetDefaultAudioEndpoint(eRender)
→ IMMDevice.Activate(IAudioClient)
→ IAudioClient.Initialize(AUDCLNT_STREAMFLAGS_LOOPBACK)
→ IAudioClient.GetService(IAudioCaptureClient)
→ 轮询 GetBuffer / ReleaseBuffer
```
和 **OBS 抓桌面音频是同一个机制**。它不是"录音",是在读系统混音器已经把声音交给声卡的那份数据,所以:
- 没有任何"权限"概念需要向谁申请
- 耳机 / 音箱 / HDMI / 虚拟声卡,抓的都是**默认播放设备**(你在系统里切输出,它跟着切)
- 只读,不写入、不改变音量、不影响任何正在播放的程序
- 音频数据**只在内存里过一遍算电平,不出这台电脑、不落盘**
`koffi` 通过 `optionalDependencies`(`@koromix/koffi-win32-x64`)分发**预编译二进制**,装包即用,不需要 Visual Studio / node-gyp / 任何编译工具链。
## 它是怎么做到"不动原组件"的
官方导轨由 `@deepseek-ai/dsh-client-ui-chat` 渲染。本插件**没有** patch、没有 fork、没有替换它的任何代码或组件。
浏览器半只做四件事:
| 做什么 | 具体 |
|---|---|
| 打一个标记 | 往导轨的 `<nav>` 上加 `data-dsh-eq` 属性 |
| 注入一张样式表 | 一个 `<style>` 标签,全部靠 CSS 覆盖(两套构建的横条选择器都覆盖) |
| 写几个变量 | `document.documentElement` 上的 `--dsh-eq-*` |
| 画一个控制行 | 侧栏那一行是纯 DOM(`insertBefore` 插进文档流);展开的设置面板走官方 `shell.overlay` 插槽 |
**为什么这不可能破坏功能**:
1. 律动只作用于**可见的那根横条**,而且只叠加 `scale` / `filter` / `box-shadow` 三个属性。
官方导轨在不同 DSH 构建里画横条的方式不一样,两种都覆盖:
- **新构建**:横条是真实的 `<span class="…_tick">`,它的尺寸/透明度/配色由 React 写成**内联样式**。
内联样式优先级很高,所以绝不能去改这四个属性 —— 改用**独立的 `scale` 属性**叠加:
`scale` 与 `transform` 是两个互不覆盖的属性(最终矩阵 = translate × rotate × scale × transform),
元素原有的内联尺寸与四态差异一像素都不会动。
- **旧构建**:横条是 `button` 的 `::before` 伪元素,那里用 `transform: … scaleX() scaleY()` 叠加。
两套选择器各自只在对应构建里命中,互不干扰。
2. 无论哪种构建,都**不碰 `width` / `height` / `opacity` / `background-color`** ——
官方四态(当前 / 悬停 / 未加载 / 生成中)的尺寸与配色差异**一个都没被改**。
3. 变形从 1 起算(静止即恒等变换),`filter` 从 `brightness(1)` 起算,无辉光时 `box-shadow` 不画 ——
电平静默时视觉效果就是原样。伪元素与 `scale` 都不参与布局,也不会出现在 `getBoundingClientRect()` 里,
所以导轨的滚动、坐标定位、点击跳转在几何上完全不受影响。
4. 只叠加 `filter: brightness()` 与 `box-shadow`,不动 `background` / `color`,主题 token 原样生效。
5. 正在生成的那一轮带 `aria-busy="true"`,样式表**显式把它排除**在行波动画之外 —— 它自己那条"呼吸"表现一点没被覆盖。
6. 每个刻度的行波相位用 `nth-child` **静态生成 CSS**(设在行容器上靠继承下发),完全不往 DOM 里写东西,不会和 React 的渲染打架。
7. **关闭 = 移除属性 + 移除样式表 + 清空变量**,三件事一起做。关掉之后导轨和没见过这个插件完全一样。
## 安装
**仅 Windows。** 音频采集层依赖 WASAPI,`package.json` 里也声明了 `os: ["win32"]`,所以在 macOS / Linux 上会被包管理器直接拒装,而不是装完再崩。
```sh
dsh plugin --profile desktop add github:yuanyiHY/dsh-rail-equalizer
```
装完**重启 DSH Desktop**(宿主进程要重新加载 bundle 才会注册路由)。
### 从源码 / 本地目录安装(改代码即时生效)
```sh
git clone https://github.com/yuanyiHY/dsh-rail-equalizer
dsh plugin --profile desktop add link:<克隆到的绝对路径>
```
> ⚠️ 安装路径里**不能有空格** —— `dsh plugin` 会把参数转给 pnpm,带空格的路径会被切碎成多个包名。本地开发建议放在 `~/.dsh/local-plugins/` 这类没有空格的目录下。
> **不需要编译工具链。** `koffi` 虽然带一个 `install` 脚本,但原生二进制是打包在 optional dependency(`@koromix/koffi-win32-x64`)里的,脚本没执行也能正常加载。pnpm 若提示需要授权构建脚本,跳过不影响使用。
### 卸载
```sh
dsh plugin --profile desktop remove dsh-rail-equalizer
```
重启后宿主侧的采集与路由完全消失。浏览器侧因为插件不再加载,什么都不会注入。
也可以只点侧栏那一行的开关关掉 —— 效果等同,且立刻生效。
## 使用
侧栏里、**官方「工作区」区块正上方**会出现一行:
```
┌──────────────────────┐
│ ♪ 律动 [ ⬤ ] │ ← 点整行(开关以外)展开 / 收起设置面板
└──────────────────────┘ 点右侧开关 → 直接开 / 关
```
**这一行是插进侧栏文档流的**(`insertBefore` 到 `[class*="_regionArea"]` 之前),
用和生态里其它插件(如技能中心)一样的做法,并带自愈:React 重渲染把它挤掉就重新插回去。
> **为什么不注册进官方插槽?** 侧栏唯一位置合适的 `sidebar.workspaces` 是 **`kind: "single"`** ——
> 往里注册会**顶掉官方的工作区区块**。而侧栏其余插槽(`sidebar.settings` / `sidebar.brand.*`)也都是 single。
> 所以只能插 DOM,这也是其它侧栏插件共同的做法。
> **为什么不用浮层(`position: fixed`)?** 试过,不行 —— 浮层会**盖住**同样插在这一区域的
> 其它插件(技能中心那一行就是这么被盖没的)。插进文档流后各占各的位置,谁都不挡谁。
也没有单独的齿轮按钮了 —— 免得和 DSH 自己的设置齿轮撞脸。设置面板只在展开时出现,
定位到这一行的右侧(`--dsh-eq-pill-x/y` 由 JS 实测写入)。
设置面板里:
| 项目 | 说明 |
|---|---|
| **总开关** | 关掉后导轨完全恢复默认样式(样式表被移除,不是"置零") |
| **运动方式** | 四选一,即时生效,见下表 |
| **灵敏度** | 0.4–3.0。系统音量小、音乐本身轻的时候往上调 |
| **强度** | 0.2–2.0。整体变形幅度 |
| **低频冲击** | 0–2.0。鼓点让刻度纵向"砸"下去多少 |
| **行波** | 只在「行波」模式下有意义:关掉则波浪不跑,刻度原地起伏 |
| **波速** | 行波周期,0.46×–2.4×(滑杆值 2600–500ms) |
### 四种运动方式
用现有的 N 根刻度当素材,四种完全不同的运动模型:
| 方式 | 观感 | 驱动量 |
|---|---|---|
| **频谱**(默认) | 每根刻度长度 = 它负责的那段频率,低音→高音依次排开,像音乐播放器的频谱条 | `--dsh-eq-v`(该刻度分到的频段) |
| **脉动** | 所有刻度同相位,整排一起伸缩呼吸,最"齐" | `--dsh-eq-lvl` + 全局动画相位 |
| **摆动** | 刻度本身不变形,整条导轨左右往复摆动 | `--dsh-eq-sw` × 电平 |
| **行波** | 每根刻度错开相位,一条波沿轨道向上跑 | `--dsh-eq-lvl` + 每刻度相位 |
**频谱模式是唯一的"真频谱"**:宿主端跑 2048 点 FFT + Hann 窗,把 40Hz→16kHz 按**对数**切成 16 段(和听感一致),客户端再按**实际刻度数**把 16 段均匀铺到 N 根刻度上 —— 所以 5 根刻度也能看到从低到高的完整分布,50 根刻度就是一根根连续的频谱。
> 客户端会热重载、宿主不会,所以可能出现「客户端新版 / 宿主旧版」。此时没有频段数据,**频谱模式会自动降级成整排一起动**,而不是完全不动。自检里的 `bandsAvailable` 会告诉你当前是哪种。
面板底部有实时电平条和后端状态(设备格式 / 连接状态 / 出错原因)。设置存在浏览器 `localStorage`(键 `dsh-rail-eq:settings:v1`)。
## CPU 与省电
- 采集是**按需**的:有浏览器连上 SSE 流才开始抓,最后一个断开后 8 秒自动停。**页面没开时这个插件完全不占 CPU。**
- 实测解码开销约 **2 ms/s CPU**(48kHz 立体声 float32),可以忽略。
- 电平以 ~30Hz 经同源 SSE 推送;浏览器侧再用 `requestAnimationFrame` 平滑到屏幕刷新率。
- 尊重系统的**「减少动态效果」**(`prefers-reduced-motion`):此时只保留柔和的亮度起伏,不做变形与行波。
## 出问题怎么办
**导轨完全不动**
1. 点侧栏那一行展开设置面板,看底部状态:
- `采集失败:...` → 看下面「常见报错」
- `已连接,等待音频…` → 链路是通的,只是系统此刻没在放声音
- `连接中…` → 宿主路由没起来,八成是**没重启 DSH**
- `当前平台不支持(仅 Windows)` → 预期行为,本实现是 Windows 专用的
2. 确认声音确实是从**默认播放设备**出来的。有些播放器可以指定独立输出设备,如果它绕过了默认设备,就抓不到。
3. 确认导轨还在页面上(轮次导航是官方组件,官方改名/移除的话本插件会静默不生效,不影响其它功能)。
4. **还是没效果 → 看浏览器半的自检**。宿主侧看不到 DOM,所以浏览器半会把自己的状态回传给宿主,
读 `GET /rail-eq/status` 里的 `clientReport` 字段,能看到:
`railFound`(找没找到导轨)、`matched`(CSS 选择器命中没有)、`mode` / `tickCount`(当前模式、数到几根刻度)、
`bandsAvailable`(宿主有没有在发频段数据)、`vars`(电平变量写了多少)、
`tick.scale` / `tickRect`(横条被拉伸成多大)、`uiRendered`(标题栏按钮有没有真的被渲染)。
排查「装了但没效果」这一类问题,这个字段通常一眼就能定位。
**`Initialize(LOOPBACK) 失败 0x88890008`** —— 该设备不支持回环(少见,某些独占模式/虚拟声卡)。切一个默认输出设备再试。
**`不支持的混音格式 tag=...`** —— 设备的混音格式既不是 IEEE float32 也不是 PCM16。把默认播放设备的格式改成 16bit/24bit、44100/48000Hz 通常能解决。
**手动重启采集**(换过播放设备之后):
```sh
curl -X POST http://127.0.0.1:<DSH端口>/rail-eq/restart
```
正常情况下不需要 —— 采集出错会自动重试。
**调试用的接口**
| 路由 | 用途 |
|---|---|
| `GET /rail-eq/status` | 完整状态:是否在采集、音频格式、包数、客户端数、当前电平 |
| `GET /rail-eq/level` | 单发当前电平,**不会**启动采集 |
| `GET /rail-eq/stream` | SSE 电平流(浏览器半用的就是它) |
| `POST /rail-eq/restart` | 重启采集 |
| `POST /rail-eq/report` | 浏览器半自检回传(排「没效果」用,结果出现在 `/status` 的 `clientReport`) |
## 开发
```
lib/
index.js 宿主半:SSE 路由 + 采集生命周期(Cordis 插件)
wasapi.js WASAPI 回环采集(koffi 直调 Core Audio COM)
analyzer.js PCM → 4 个时域电平 + 16 段对数频谱(2048 点 radix-2 FFT + Hann 窗,快攻慢放包络)
client.js 浏览器半:导轨绑定 + CSS 变量 + 控制 UI
test/
host-integration.mjs 宿主端集成测试(不需要 DSH 重启)
band-probe.mjs 单点频段探针(验证某个频率落在哪一段)
make-tone.mjs 生成测试音
```
宿主半是纯 ESM,浏览器半是 `window.__ModuleLoader__.load({ id, factory })` 形态的静态 bundle,**都不需要构建步骤**。改完 `lib/*.js` 重启 DSH 生效。
跑测试(有声音在放时最有意义):
```sh
node test/make-tone.mjs 30
# 另开一个窗口播放 test/tone.wav,然后:
node test/host-integration.mjs
```
测试会覆盖:路由分发、404、按需采集(单发探活不该启动采集)、SSE 头与推流、status 补发、电平数值健康度、四频段独立性、频谱结构(最强段显著高于中位段)、断开后自动停机、dispose 清理。
### koffi 踩过的两个坑
1. **输出参数不是返回值数组。** koffi 里 `_Out_` 参数要传一个容器(`[null]`)进去,原生代码把结果写回容器,函数返回值就是 C 的返回值(这里是 HRESULT)。
2. **COM vtable 要手动解引用。** `koffi.decode(obj, 'void *')` 拿到 vtable 指针,再 `koffi.decode(vtable, index * ptrSize, 'void *')` 拿到方法指针,最后 `koffi.call(fn, proto, obj, ...)`。`proto` 的第一个参数是 `this`。
## 已知限制
- **仅 Windows。** 音频采集层依赖 WASAPI。macOS 可以用 `ScreenCaptureKit` 或虚拟声卡,没有实现。
- **抓的是默认播放设备。** 播放器如果指定了独立输出设备,抓不到。
- **导轨的定位靠语义锚点**(`nav` + `button[class*="_mark"]`,优先用 `aria-label`),不依赖中英文案也不依赖哈希类名。官方若大改导轨 DOM 结构,`findRail()` 一个函数就能修。
- 没有接入 DSH 的 locale 命名空间,界面文案是中英混合。
Install
dsh plugin --profile web add github:yuanyiHY/dsh-rail-equalizer
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-rail-equalizer from the hub
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.