dsh-rail-equalizer
让 DSH 的**「轮次导航」导轨**(聊天右侧那根刻度条)跟着系统正在播放的声音实时律动。
放音乐、放视频、打游戏 —— 只要声音是从这台电脑的默认播放设备出来的,导轨就会跟着动。 不需要任何授权、不弹任何窗口、不碰麦克风、不录屏,戴耳机照样工作。


仅 Windows。 音频采集层依赖 WASAPI。英文说明见 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 插槽 |
为什么这不可能破坏功能:
- 律动只作用于可见的那根横条,而且只叠加
scale/filter/box-shadow三个属性。 官方导轨在不同 DSH 构建里画横条的方式不一样,两种都覆盖:- 新构建:横条是真实的
<span class="…_tick">,它的尺寸/透明度/配色由 React 写成内联样式。 内联样式优先级很高,所以绝不能去改这四个属性 —— 改用独立的scale属性叠加:scale与transform是两个互不覆盖的属性(最终矩阵 = translate × rotate × scale × transform), 元素原有的内联尺寸与四态差异一像素都不会动。 - 旧构建:横条是
button的::before伪元素,那里用transform: … scaleX() scaleY()叠加。 两套选择器各自只在对应构建里命中,互不干扰。
- 新构建:横条是真实的
- 无论哪种构建,都不碰
width/height/opacity/background-color—— 官方四态(当前 / 悬停 / 未加载 / 生成中)的尺寸与配色差异一个都没被改。 - 变形从 1 起算(静止即恒等变换),
filter从brightness(1)起算,无辉光时box-shadow不画 —— 电平静默时视觉效果就是原样。伪元素与scale都不参与布局,也不会出现在getBoundingClientRect()里, 所以导轨的滚动、坐标定位、点击跳转在几何上完全不受影响。 - 只叠加
filter: brightness()与box-shadow,不动background/color,主题 token 原样生效。 - 正在生成的那一轮带
aria-busy="true",样式表显式把它排除在行波动画之外 —— 它自己那条"呼吸"表现一点没被覆盖。 - 每个刻度的行波相位用
nth-child静态生成 CSS(设在行容器上靠继承下发),完全不往 DOM 里写东西,不会和 React 的渲染打架。 - 关闭 = 移除属性 + 移除样式表 + 清空变量,三件事一起做。关掉之后导轨和没见过这个插件完全一样。
安装
仅 Windows。 音频采集层依赖 WASAPI,package.json 里也声明了 os: ["win32"],所以在 macOS / Linux 上会被包管理器直接拒装,而不是装完再崩。
dsh plugin --profile desktop add github:yuanyiHY/dsh-rail-equalizer
装完重启 DSH Desktop(宿主进程要重新加载 bundle 才会注册路由)。
从源码 / 本地目录安装(改代码即时生效)
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 若提示需要授权构建脚本,跳过不影响使用。
卸载
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):此时只保留柔和的亮度起伏,不做变形与行波。
出问题怎么办
导轨完全不动
- 点侧栏那一行展开设置面板,看底部状态:
采集失败:...→ 看下面「常见报错」已连接,等待音频…→ 链路是通的,只是系统此刻没在放声音连接中…→ 宿主路由没起来,八成是没重启 DSH当前平台不支持(仅 Windows)→ 预期行为,本实现是 Windows 专用的
- 确认声音确实是从默认播放设备出来的。有些播放器可以指定独立输出设备,如果它绕过了默认设备,就抓不到。
- 确认导轨还在页面上(轮次导航是官方组件,官方改名/移除的话本插件会静默不生效,不影响其它功能)。
- 还是没效果 → 看浏览器半的自检。宿主侧看不到 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 通常能解决。
手动重启采集(换过播放设备之后):
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 生效。
跑测试(有声音在放时最有意义):
node test/make-tone.mjs 30
# 另开一个窗口播放 test/tone.wav,然后:
node test/host-integration.mjs
测试会覆盖:路由分发、404、按需采集(单发探活不该启动采集)、SSE 头与推流、status 补发、电平数值健康度、四频段独立性、频谱结构(最强段显著高于中位段)、断开后自动停机、dispose 清理。
koffi 踩过的两个坑
- 输出参数不是返回值数组。 koffi 里
_Out_参数要传一个容器([null])进去,原生代码把结果写回容器,函数返回值就是 C 的返回值(这里是 HRESULT)。 - 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 命名空间,界面文案是中英混合。
No comments yet. Be the first to write one.