DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

yuanyiHY /

yuanyiHY/dsh-rail-equalizer

Verified

Make DSH's turn-navigator rail react to system audio in real time (Windows). WASAPI loopback capture via koffi - no permission prompt, no microphone, no screen recording; the browser half injects one stylesheet and CSS variables and never patches the official component.

★ 0 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@0edd9e1f

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 插槽

为什么这不可能破坏功能:

  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 上会被包管理器直接拒装,而不是装完再崩。

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):此时只保留柔和的亮度起伏,不做变形与行波。

出问题怎么办

导轨完全不动

  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 通常能解决。

手动重启采集(换过播放设备之后):

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 踩过的两个坑

  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 命名空间,界面文案是中英混合。
—/ 5

No ratings yet

Verified DSH bundle

Commit 0edd9e1f6efd

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout