dsh-rail-tones
会话导航条音效 —— 为 DeepSeek Harness Web GUI / DSH Desktop 复刻「在会话导航条上竖向滑动或点击刻度时,播放音阶提示音」这一功能。
- 音高 = 位置:导轨内容位置 0→1 映射到大调五声音阶 2 个八度(A3…F#5,10 个音级),越高越靠后。
- 四种触发:光标沿导轨竖向划过(不需要按键)、按住拖动、点击刻度、在导轨上滚轮翻阅。
- 零素材、零依赖、零宿主服务:Web Audio 实时合成;不 require 任何
@deepseek-ai/* 客户端包;宿主半边为空。
- 只读观察:只用
document 上的捕获阶段 + passive 委托监听,不写 DOM、不 preventDefault、不 stopPropagation,官方跳转 / 悬停预览 / 键盘聚焦行为完全不变。
当前版本 v0.2.7;版本历史见 CHANGELOG.md。
安装
# <DSH 安装目录> = DeepSeek Harness 的安装位置,CLI 在其 resources\runtime\cli\bin\dsh.cmd
# (该 bin 已在 PATH 里的话,直接写 dsh 即可)
# <插件目录> = 本仓库在你机器上的绝对路径
& '<DSH 安装目录>\resources\runtime\cli\bin\dsh.cmd' plugin --profile desktop add link:<插件目录>
安装后重启 DSH Desktop(插件名单在实例启动时读取)。
使用
- 打开任意会话(真实提问 ≥ 2 轮才会出现官方导轨)。
- 光标沿导航条竖向划过(不按键)→ 随位置发声;按住拖动同理。
- 点击某个刻度 → 发出该刻度对应的音(跳转行为不变)。
- 指针停在导轨上滚动滚轮 → 刻度滚动经过指针时发声。
- 设置 → 导航条音效:开关、音量(0–100%,0% 无声、100% 最大)、音效选择(点击展开列表选择音色:正弦音 / 钢琴 / 清音 / MiMo)、试听。开关关闭后立即生效(试听同样静音),音效选择点选即生效、刷新后保持。
音符以「刻度」为单位:事件先吸附到指针所在(或最近)刻度的中线,再换算音高。
因此同一个刻度上,光标划过、按住滑动、点击、滚轮得到的是同一个音。
同一个音级只响一次:音高把整场会话分成 10 档(2 个八度五声音阶)。只要指针还在
同一档里 —— 哪怕手抖出几十个 pointermove、哪怕宿主在重渲染、哪怕刻度节点被虚拟
滚动回收 —— 都不会再发声;只有音级变化、或做出明确手势(按下 / 键盘回车激活)
才会再响一声。指针离开导轨超过 0.3 秒后再次划入同一音级,会重新发声。
两道防抖保险(v0.1.9):
- 刻度吸附滞回:相邻刻度必须「明显更近」(4px 余量)才切换,指针在相邻刻度分界线
上抖动不会来回跳档;
- 离开延迟确认:任何「指针离开导轨」的信号(包括宿主重渲染导致的瞬时异常目标、
蹭到悬停预览气泡)都要持续满 300ms 才清空去重锚点 —— 瞬时抖动不再被误判成
「离开后又进入」而重放同一音。
这也意味着:会话很长时,一个音级覆盖很多个刻度,同一档内滑动是安静的 —— 这是
「音高表示会话位置」的必然结果,不是丢音。
音频上下文需要一次用户手势才会启动:第一次点击/按键之后,纯划过(悬停)才有声音;
在此之前划过是静音的(浏览器自动播放策略,非故障)。
改动后如何生效
client.js 是手写的客户端产物,没有构建步骤。宿主在实例启动时对每个 client.js
做快照并按下发版本号(mtime/ctime/size)提供;HMR 只在这一行被重新发布后才换 URL。
所以改完 client.js 之后:
- 先刷新页面试试;
- 没变化就重启 DSH Desktop(最可靠);
- 用 设置 → 导航条音效 卡片右下角的
dsh-rail-tones vX.Y.Z,或
__dshRailTones.status().version 确认浏览器里真正跑的是哪一版 —— 版本号没变就说明
新产物还没下发,此时讨论行为没有意义。
验证
# 语法与单元测试(纯逻辑 + 清单契约)
node --check client.js
node --check index.js
node --test test/
# 宿主是否已收录该客户端行:`dsh plugin` 是否写入了依赖与 bundle
Select-String -Path "$env:USERPROFILE\.dsh\profiles\desktop\package.json" -Pattern 'dsh-rail-tones'
# 插件自报(宿主半边注册的只读路由,无需 DevTools、无需页面)
curl.exe -s http://127.0.0.1:19387/rail-tones/status
Get-Content "$env:USERPROFILE\.dsh\rail-tones-report.json" -Raw
/rail-tones/status 的返回直接回答「重启后插件到底有没有起来」:
- 404 —— 这一行在启动时根本没有被组装(插件没加载,跟卡片注册无关);
- 200 +
report: null —— 宿主半边起来了,但浏览器半边还没上报(客户端没挂载);
- 200 +
report —— 浏览器半边挂载了,看这几个字段:
settingsFailure —— registerSettingsSection() 整体抛异常时的异常原文(含栈顶三行);
settings.react / settings.reactError —— 取 React 是否成功、失败原文;
settings.slots / settings.error —— ctx.slots 是否就绪 / 具体错误;
settings.injected / settings.registered —— 槽位注入与卡片注册的结果;
settings.retriesLeft —— 还在重试时剩余次数;
report.payload.reason —— 这次上报的触发点:mount / settings-registered /
settings-threw(注册链抛错,仅首次失败报一次)/ settings-failed / settings-gave-up(重试耗尽)/ manual;
rails —— 当时识别到的导轨(未接触过导轨时为空数组)。
三者互斥地指向不同的修复方向:settingsFailure 非空 = 注册流程本身炸了;
settings.react === false = 拿不到 React;settings.slots === false = 服务还没就绪;
injected: true, registered: false = 槽位在但注册被拒。
浏览器半边挂载时、以及槽位注册成功/失败时都会自动上报一次;
__dshRailTones.report('手动') 可随时再报一次。
这两条路由只读、只在本机回环上、只服务于诊断;~/.dsh/rail-tones-report.json 删掉也无影响。
注意:dsh web 对未认证请求返回 401(“dsh web authentication required”)的路由是页面与
应用接口;插件自己注册的路由不在那道栅栏内,可以直接 curl。
浏览器 DevTools 控制台:
__dshRailTones.status()
// { version, instances, enabled, volume, voice, notesPlayed, mutedCount, lastNoteHz, lastTick,
// railSeen, audioState,
// settings: { registered, attempts, error },
// dedup: { accepted, skippedByDegree, lastDegree, lastTickIndex,
// lastPosition, lastSkip, resets, lastReset, offRailPending, railKnown } }
__dshRailTones.log(10) // 最近 10 次调度:{ at, hz, source, tick, degree, played, egg, strum }
__dshRailTones.rails() // 页面上每个「含刻度按钮的 nav」→ { used, ticks, width, height }
__dshRailTones.play(0.5) // 手动发一声(会话中段)
__dshRailTones.setEnabled(false)
__dshRailTones.setVolume(0.3)
__dshRailTones.setVoice('mimo') // 注册表内任意 id('sine' | 'piano' | 'chime' | 'mimo'),返回生效值
排查时看这几处:
version / 设置卡片右下角的版本号 —— 不是最新版就说明新产物还没下发(重启 DSH Desktop);
instances —— 正常为 1(挂载时会主动销毁上一份残留实例);
settings.registered —— 设置卡片没出现时看这里:false + error 就是槽位注册失败的原因;
mutedCount / log() 里的 played: false —— 「没声音」先看这里:调度被音频层丢弃
(音量为 0、上下文不可用或还卡在 suspended)。mutedCount 持续增长而扬声器无声
→ 查 audioState 是否为 suspended(做一次点击/按键手势即可唤醒);
dedup —— 抖动时 skippedByDegree 应持续增长而 notesPlayed 不动;resets 应保持为 0
(>0 说明去重锚点被清过,lastReset.reason 指明是哪条路径清的)。
railSeen: false 说明还没命中过导轨(会话轮次不足或指针未接触导轨)。
rails() 里 used: false 的条目是被排除的 nav(例如恰好也是 <nav> 的外层包裹元素)——
它们不会发声;如果这里出现了两条 used: true,说明界面上确实有两条导轨(例如同屏两个会话视图)。
实现要点
| 关注点 | 做法 |
|---|
| 刻度识别 | nav 内含 button[data-index](官方 TurnMark),不依赖任何哈希 CSS 类名 |
| 槽位服务时序 | 客户端这一行在组合脚本里排在前面,apply 执行时 ctx.slots 可能是 undefined(模块级 inject: ['slots'] 在客户端这条路径上不保证等到服务就绪);ctx.get('locale') 未就绪时返回的也是 undefined 而不是 null,所有服务判定必须同时挡住两者(v0.1.9 只判 !== null,2026-10-06 在真实会话中炸过一次)。因此用 ctx.inject(['slots'], cb) 作用域注入等服务就绪,且任何失败(slots === false 或整条抛错)都按 400ms × 最多 60 次有界重试,放弃时报 settings-gave-up |
| 导轨唯一性 | 只认直接拥有刻度的 nav(tick.closest('nav') === nav):外壳恰好也是 <nav> 时不会把它当成第二条导轨,避免整块面板乱发声 |
| 刻度吸附 | 所有触发都先 tickAt(nav, y) 取指针所在/最近的刻度,用刻度中线换算位置 → 同一刻度上四种手势得到同一个音;另加 4px 滞回:另一个刻度明显更近才切换,分界线抖动不跳档 |
| 触发方式 | pointermove:光标划过(hover,不需要按键)与按住拖动都发声;pointerdown:点击发声;click.detail === 0:键盘激活发声;wheel:滚动停止 90ms 后取离指针最近的刻度 |
| 预览气泡 | hover 只在导轨滚动条区域内发声(scroller.contains(target)),鼠标移到刻度预览气泡上时不发声;且「离开导轨」统一走 300ms 延迟确认才清去重锚点,重渲染瞬时异常 / 蹭气泡不会导致重放 |
| 滚动容器缓存 | WeakMap<nav, scroller>:命中一次后复用,避免高额 pointermove 触发 getComputedStyle 遍历 |
| 位置换算 | 刻度祖先中唯一的可滚动元素(overflow-y:auto 且 scrollHeight > clientHeight)作为虚拟滚动容器,(y - rectTop + scrollTop) / scrollHeight |
| 虚拟滚动 | 不缓存刻度节点,每次事件重新查询;滚动时 data-index 只用于宿主自身渲染 |
| 音高映射 | 默认(含 sine/piano/chime):degree = round(position × 9),hz = 220 × 2^((SCALE[d%5] + 12·⌊d/5⌋)/12),SCALE = [0,2,4,7,9]。MiMo:音色自带 pitchAt(context)(注册表条目里的映射优先),按原文 s(d,m) —— d = 刻度下标(data-index)、m = 刻度总数,C = round(d/max(1,m-1)×18)、f = NOTES[C%5] × 2^(⌊C/5⌋−1),NOTES = [261.63,293.66,329.63,392,440](C4 D4 E4 G4 A4) |
|
MiMo 音色(v0.2.6:逐行移植,含彩蛋与刮弦音)
移植自小米 MiMo 桌面端(resources/app.asar → out/renderer/assets/SessionSpine-*.js 里那个返回
{hover, hit, endEngagement, resetEgg} 的 IIFE)。v0.2.4 的「复刻」有 5 处偏差导致听感相差甚远
(音高被压进最低 3 个半音、速度门槛方向写反、点击走了弱音档、响度放大 2.93×、按 10 档音级去重),
v0.2.5 全部按原文改正:
| 要素 | mimo 原文 | 本项目(v0.2.6) |
|---|
| 波形 | F.type = "triangle" | 同 |
| 包络 | setValueAtTime(1e-4) → exponentialRamp(峰值, +8ms) → exponentialRamp(1e-4, +衰减) | 同 |
| 弱音档(hover) | 峰值 0.13、衰减 0.42s、停振 +0.46s | 时长同;峰值 v0.2.7 起 = 0.6 × 音量(响度适配,见「响度」行) |
| 强音档(hit) | 峰值 0.3、衰减 0.9s、停振 +0.95s | 时长同;峰值 = 0.88 × 音量(与 sine 等峰)—— 点击刻度 / 键盘激活 / 试听走这一档 |
| 音高表 | [261.63,293.66,329.63,392,440](C4 D4 E4 G4 A4) | 同 |
| 档位 | C = round(d / max(1, m-1) × 18),d = 刻度下标、m = 刻度总数,f = NOTES[C%5] × 2^(⌊C/5⌋−1) | 同(mimoFrequencyAt(step, count))—— 5 刻度时 130.8 / 261.6 / 440 / 880 / 1568 Hz,整条 18 档阶梯铺满导轨 |
| 滑动门槛 | 进入导轨 <100ms 静音;取样窗口 80ms;窗口内速度 < 600px/s(慢)才放行,快速甩动静音 | 同(createMimoGate,常量 100 / 80 / 600);v0.2.6 增强:快速甩动不再静音,改播单频刮弦音(见下) |
| 去重粒度 | 每个刻度各响一声(c !== w.current) | 同(dedupKeyFor:mimo 按刻度下标,其它音色仍按 10 档音级) |
| 响度 | 绝对增益 0.13 / 0.3(mimo 本体没有音量滑杆) | v0.2.7 响度适配:生效峰值抬到与其它音色接近 —— 强音档 = sine 的 PEAK_FACTOR(0.88)、弱音档 0.6(≈ 其 7 成),单频/刮弦/彩蛋/快速甩动全路径一致;两档仍保留「点击比划过响」的层级(约 +3.4dB)。音色/包络/音高不变,只抬电平 |
点击为什么是强音:原文 hit 里被 H = () => !1 封掉的只是彩蛋短句分支,紧随其后的
p(s(d,m), !0) 是正常路径 —— 点击刻度本来就该响 0.9s 的强音。v0.2.4 误判成「点击也是弱音」,
v0.2.5 已改正:按下 / 键盘激活 / 试听 → 强音档;划过 / 拖拽移动 / 滚轮 → 弱音档。
刻度总数在虚拟滚动下的估计:原文用消息数组长度(s.length)。本项目从 DOM 取刻度总数,
虚拟滚动时只有已渲染的刻度在场,于是用 max(渲染刻度数, 最大 data-index + 1) 作为 m ——
渲染窗口与宿主一致,指针滑到窗口底部时能落到与之匹配的档位。
彩蛋旋律(v0.2.6,逐字移植原文 R/x/Y/k/j 与 S/U/V)
划过导轨时掷骰:每轮互动只掷一次(离开导轨重新获得资格,原文 endEngagement 复位 k)、
冷却 180s(上次彩蛋结束或被打断起算)、概率 5% —— 命中后从 5 首曲子里随机选一首,
整曲由后续手势逐音奏出(原文语义:每次划过 / 点击推进一个音符,不是后台自动播放),
播完整曲自动结束并重计冷却;离开导轨不打断进行中的曲子,卸载(刷新/禁用)才 resetEgg。
__dshRailTones.status().egg 可看 {active, songIndex, noteIndex, loops, rolled, lastFinishedAt}。
| # | 曲子 | bpm | 音数 | 时长 |
|---|
| 1 | 致爱丽丝(开头 E5 ♯D5 E5 ♯D5 E5 B4 D5 C5 A4) | 76 | 40 | 19.7s |
| 2 | F# 小调练习曲(含 2 个三音和弦) | 150 | 77 | 20.4s |
| 3 | 卡农式主题(原文 g/P 两段交错组合) | 120 | 105 | 20.0s |
| 4 | 波莱罗片段 | 72 | 34 | 20.0s |
| 5 | 欢乐颂变奏 | 72 | 82 | 20.2s |
刮弦合成器与「快速滑动」增强(v0.2.6)
playMimoStrum 逐字移植原文 i(freqs, strong, ring):每个频率叠 5 个分音
(基频三角波 + 2/3/4/6 次正弦泛音,增益 [1, .42, .22, .12, .06]),master 峰值按
1/√频率数 归一(和弦不削波)并串 lowpass(4200Hz 在 0.85F 处指数扫到 1400Hz),
总时长 F = clamp(0.25 + ring, 0.4, 2.6),首分音失谐 1.5 音分。彩蛋激活期间由它奏出旋律音。
与原文的一处有意差异:mimo 对「窗口内速度 ≥600px/s」的划过是静音的;本项目把快速
甩动改派为单频刮弦音(ring 用原文默认值 1.1、弱音档形状经 lowpass 扫频),
飞速翻阅导轨时是一串刮弦而非死寂 —— 属于用户明确要求的增强,不想要可把 createMimoGate
的快速分支改回 'mute'。快速帧不掷骰也不推进彩蛋(原文根本不会调 hover(),彩蛋语义不变)。
依旧不移植:原文被 H = () => !1 封死的 q/w() 短句路径 —— shipped 代码不可达,移植它反而偏离真实行为。
设置与存储
设置项存 localStorage['dsh-rail-tones:v1'] = {"enabled":true,"volume":0.5,"voice":"sine"}(每个浏览器各自一份;隐私模式下退化为仅本次会话有效)。存储不可用或内容损坏时回退到默认值,绝不抛错。
音量范围:0 … 100%(MAX_VOLUME = 1;0% 为无声,100% 为最大声音。v0.1.13 起由 0–200% 收窄为 0–100%)。
读取、写入、__dshRailTones.setVolume()、滑杆四处都按同一上限钳制,存量里越界的旧值读取时会被钳到 100%。
包络峰值为 音量 × 0.88,因此 100% 时是 0.88(较此前 0–200% 区间翻倍:旧 200% 峰值为 0.44),仍在满幅之内、不会削波。
音色(v0.2.0 起):voice ∈ {"sine","piano","chime","mimo"},默认 "sine"。白名单派生自模块里的 VOICE_OPTIONS 注册表
(每项 {id, labelKey, descKey, synth},条目直接持有合成器;可选 pitchAt 覆盖音高映射),读取与写入都按注册表钳制,非法/缺失回退默认;
存量数据没有 voice 字段时自动落默认音色(向后兼容)。
设置页「音效选择」点击展开候选列表(v0.2.1 起,遍历注册表渲染),__dshRailTones.setVoice('piano') 切换同一状态。
新增音效 = 注册表加一条 + 中英文案各一条,UI 与存储代码零改动(v0.2.4 起注册表为单一来源,条目自带合成器);
查不到合成器的未知 id 在引擎里兜底默认音色、绝不抛错 —— 这是给后续自定义音效预留的扩展点。
卸载
& '<DSH 安装目录>\resources\runtime\cli\bin\dsh.cmd' plugin --profile desktop remove dsh-rail-tones
删除后重启 DSH Desktop 即完全恢复原状(无残留文件、无宿主配置)。
项目结构
| 文件 | 说明 |
|---|
client.js | 全部功能:手写客户端产物,无构建步骤 |
index.js | 宿主半边:只做两条只读诊断路由(/rail-tones/status、/rail-tones/report) |
test/rail-tones.test.mjs | node --test 用例:纯逻辑、清单契约、设置注册、监听层与 apply 级接线 |
package.json | dsh.bundle.patch + dsh.client{platform:'web', immediately, inject} + ./client 导出;零依赖 |
cordis.patch.yml | 向 profile 的配置树插入本插件那一行 |
CHANGELOG.md | 版本历史 |
改动 client.js 后如何生效,见上文 改动后如何生效。
已知边界
- 开发与验证环境为 DSH Desktop
0.2.0-rc.2(Electron 44 / Chromium 152);其它宿主版本不保证。
- 会话真实提问少于 2 轮时官方不渲染导轨,此时不会有任何声音(设计如此)。
- 滚轮发声在音频上下文尚未被用户手势激活时会静默跳过(浏览器自动播放策略;滚轮事件本身会尝试唤醒上下文,唤醒后正常)。
- 音色有正弦音 / 钢琴 / 清音 / MiMo 四种,设置页可切换;如需新增音色,按
client.js 里 VOICE_OPTIONS 注册表上方的注释两步走即可(加条目 + 文案)。音色条目还可自带 pitchAt(context) 覆盖音高映射(MiMo 就是这么做的)。
- 与官方导轨的耦合点是「
nav + button[data-index] + 可滚动祖先」这三个语义特征;DSH 未来大改版会让插件无声降级(不报错)。
许可
MIT,见 LICENSE。