dsh-scroll-piano 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-piano contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,63 @@
1
- # Temporary Holding Version
1
+ # dsh-scroll-piano · 滚动钢琴
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 鼠标滑过 DSH 界面**右侧边缘的竖条区域**(聊天区滚动条/把手那一条)时,按鼠标的**纵向位置**发出钢琴音:**越靠上音越高、越靠下音越低**(特雷门琴式)。
4
+
5
+ ## 效果
6
+
7
+ - 右侧边缘一条细彩色竖条(开启时淡淡发光),鼠标进入该区间即触发。
8
+ - 鼠标纵向移动 = 连续刮奏;竖条上有个白点跟随鼠标位置。
9
+ - 鼠标旁显示当前音名(如 `C4 · 60`)。
10
+ - 左下角一枚 `🎵 滚动钢琴 ON/OFF` 胶囊:点开关切换、右侧滑块调音量(设置存 `localStorage`)。
11
+ - 默认音域 C3–C6(3 个八度),只按半音触发(同音不重复触发)。
12
+
13
+ ## 为什么按坐标而不是抓 DOM
14
+
15
+ 那根滚动条是浏览器**原生滚动条**——不在 DOM 里,且周围容器类名带 hash(`ST7X_W_scrollBody` 这类),抓不到也没法定制。所以插件用全局 `pointermove` + `clientX` 判定「是否落在窗口右侧竖条区间」、`clientY` 映射音高,稳且不依赖任何内部类名。
16
+
17
+ ## 可调参数
18
+
19
+ 代码内 `DEFAULTS`(`lib/client.js`):
20
+
21
+ | 键 | 默认 | 说明 |
22
+ |---|---|---|
23
+ | `on` | `true` | 开关 |
24
+ | `volume` | `0.25` | 音量 |
25
+ | `stripWidth` | `30` | 右侧竖条宽度(px) |
26
+ | `lowNote` / `highNote` | `48` / `84` | 音域(MIDI,C3–C6) |
27
+ | `inset` | `56` | 上下留白(px),让最高/最低音够得着 |
28
+
29
+ ## 技术要点
30
+
31
+ | 项 | 做法 |
32
+ |---|---|
33
+ | 挂载槽位 | `shell.overlay` |
34
+ | 触发 | `document.addEventListener("pointermove")`,`clientX >= innerWidth - stripWidth` 才生效 |
35
+ | 音高 | `midi = highNote - t*(highNote-lowNote)`,`t` 为纵向归一化位置 |
36
+ | 音频 | Web Audio 合成(泛音 + 快速起音 + 指数衰减 + 噪声锤击瞬态 + 低通 + 压缩器) |
37
+ | 客户端形态 | 手写 `window.__ModuleLoader__.load` 零构建 bundle,`platform: web` |
38
+
39
+ ## 安装(本地)
40
+
41
+ ```sh
42
+ NODE=~/.dsh/dsh-runtimes/dsh-primary-runtime/dependencies/node/bin/node
43
+ PNPM=~/.dsh/dsh-runtimes/dsh-primary-runtime/dependencies/pnpm/bin/pnpm.mjs
44
+ # profile/package.json 的 dependencies 加: "dsh-scroll-piano": "link:<本目录>"
45
+ # profile/cordis.patch.yml 加:
46
+ # - insert:
47
+ # - id: dsh-scroll-piano
48
+ # name: dsh-scroll-piano
49
+ cd ~/.dsh/profiles/desktop && "$NODE" "$PNPM" install --no-frozen-lockfile
50
+ ```
51
+
52
+ > 顺序很重要:**先 pnpm install 建软链,再触发 cordis.patch.yml 热载入**,否则 loader 首次 require 找不到包、之后不会自动重试。
53
+
54
+ ## 开发
55
+
56
+ ```sh
57
+ npm test # node test/bundle-check.mjs
58
+ ```
59
+
60
+ ## 说明
61
+
62
+ - 首次出声需要一次用户手势(点开关或首次滑动即自动解锁 AudioContext)。
63
+ - 与 `dsh-piano`(滑动钢琴键盘)是两个独立插件,可同时装。
@@ -0,0 +1,4 @@
1
+ # dsh-scroll-piano:往插件树里插一行(纯客户端插件,服务器半无配置)。
2
+ - insert:
3
+ - id: dsh-scroll-piano
4
+ name: dsh-scroll-piano
package/lib/client.js ADDED
@@ -0,0 +1,297 @@
1
+ /**
2
+ * dsh-scroll-piano — browser half.
3
+ *
4
+ * 鼠标滑过 DSH 界面**右侧边缘的竖条区域**(聊天区滚动条/把手那一条)时,
5
+ * 按鼠标的**纵向位置**发出钢琴音:越靠上音越高、越靠下音越低(特雷门琴式)。
6
+ *
7
+ * 为什么按「窗口右侧竖条 + 坐标」而不是抓某个 DOM 元素:那根滚动条是浏览器
8
+ * **原生滚动条**(不在 DOM 里,类名还带 hash),抓不到也没法定制;用一个全局
9
+ * `pointermove` + `clientX` 落到竖条区间、`clientY` 映射音高,最稳。
10
+ *
11
+ * 声音沿用 dsh-piano 的 Web Audio 实时合成(泛音 + 快速起音 + 指数衰减 +
12
+ * 噪声锤击瞬态 + 低通/压缩),零采样、零网络、零依赖。
13
+ */
14
+ window.__ModuleLoader__.load({
15
+ id: "dsh-scroll-piano",
16
+ factory: (require) => {
17
+ var module = { exports: {} };
18
+ var exports = module.exports;
19
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
20
+ const React = require("react");
21
+ const { useEffect, useRef, useState, useCallback } = React;
22
+ const h = React.createElement;
23
+
24
+ //#region styles
25
+ const CSS = [
26
+ ".dshscp_pill{box-sizing:border-box;position:fixed;left:132px;bottom:16px;z-index:2147483000;height:32px;display:inline-flex;align-items:center;gap:10px;padding:0 10px;border-radius:16px;background:var(--dsw-alias-bg-elevated,#1f2430);color:var(--dsw-alias-label-primary,#e6e8ee);box-shadow:0 4px 18px rgba(0,0,0,.35);font-size:12px;line-height:16px}",
27
+ ".dshscp_pill button{box-sizing:border-box;border:none;background:transparent;color:inherit;font:inherit;cursor:pointer;padding:0 2px;display:inline-flex;align-items:center;gap:6px}",
28
+ ".dshscp_dot{flex:none;width:8px;height:8px;border-radius:50%;background:var(--dsw-alias-label-tertiary,#6b7280)}",
29
+ ".dshscp_dotOn{background:#35c46a;box-shadow:0 0 6px rgba(53,196,106,.9)}",
30
+ ".dshscp_vol{width:76px;accent-color:var(--dsw-alias-state-business-primary,#5b8def)}",
31
+ ".dshscp_strip{position:fixed;top:0;right:0;width:3px;height:100%;z-index:2147482000;pointer-events:none;background:linear-gradient(180deg,rgba(91,141,239,.85),rgba(160,120,240,.85));opacity:0;transition:opacity .15s ease}",
32
+ ".dshscp_stripOn{opacity:.55}",
33
+ ".dshscp_marker{position:fixed;right:0;width:7px;height:7px;border-radius:50%;z-index:2147482001;pointer-events:none;background:#fff;box-shadow:0 0 8px rgba(160,120,240,.95);opacity:0;transition:opacity .1s ease}",
34
+ ".dshscp_markerOn{opacity:1}",
35
+ ".dshscp_hud{position:fixed;z-index:2147482002;pointer-events:none;padding:2px 7px;border-radius:6px;background:rgba(20,24,32,.92);color:#e6e8ee;font-size:11px;line-height:16px;font-variant-numeric:tabular-nums;opacity:0;transition:opacity .1s ease;white-space:nowrap}",
36
+ ".dshscp_hudOn{opacity:1}"
37
+ ].join("");
38
+ const tagId = "dsh-scroll-piano/scroll-piano.css";
39
+ if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
40
+ const style = document.createElement("style");
41
+ style.setAttribute("data-plugin-css", tagId);
42
+ style.textContent = CSS;
43
+ document.head.appendChild(style);
44
+ }
45
+
46
+ //#region audio engine
47
+ let _ctx = null;
48
+ let _out = null;
49
+ let _noiseBuf = null;
50
+ function audioCtx() {
51
+ if (!_ctx) {
52
+ const AC = window.AudioContext || window.webkitAudioContext;
53
+ if (!AC) return null;
54
+ _ctx = new AC();
55
+ }
56
+ if (_ctx.state === "suspended") _ctx.resume().catch(() => {});
57
+ return _ctx;
58
+ }
59
+ function midiToFreq(m) {
60
+ return 440 * Math.pow(2, (m - 69) / 12);
61
+ }
62
+ /**
63
+ * 共享输出链(只建一次):总线 → 低通 → 压缩器。
64
+ * 所有音共用一个压缩器,滑奏时多音叠加由它统一压住:既防削波,
65
+ * 也避免每个音都重建一条 master/lowpass/compressor 链。
66
+ */
67
+ function outputChain(ctx) {
68
+ if (_out) return _out;
69
+ const master = ctx.createGain();
70
+ master.gain.value = 0.85;
71
+ const lp = ctx.createBiquadFilter();
72
+ lp.type = "lowpass";
73
+ lp.frequency.value = 6500;
74
+ lp.Q.value = 0.3;
75
+ const comp = ctx.createDynamicsCompressor();
76
+ comp.threshold.value = -22;
77
+ comp.knee.value = 24;
78
+ comp.ratio.value = 12;
79
+ comp.attack.value = 0.004;
80
+ comp.release.value = 0.18;
81
+ master.connect(lp);
82
+ lp.connect(comp);
83
+ comp.connect(ctx.destination);
84
+ _out = master;
85
+ return master;
86
+ }
87
+ /**
88
+ * 缓存的锤击噪声缓冲(只建一次)。
89
+ * 上一版每个音都新建一个 AudioBuffer,快速滑动时分配抖动会引入杂音。
90
+ */
91
+ function noiseBuffer(ctx) {
92
+ if (_noiseBuf) return _noiseBuf;
93
+ const len = Math.max(1, Math.floor(ctx.sampleRate * 0.02));
94
+ const buf = ctx.createBuffer(1, len, ctx.sampleRate);
95
+ const data = buf.getChannelData(0);
96
+ for (let i = 0; i < len; i++) data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, 3);
97
+ _noiseBuf = buf;
98
+ return buf;
99
+ }
100
+ /**
101
+ * 合成一枚「电钢」音:基音 + 3 枚泛音(各带指数衰减)+ 极轻的击弦瞬态。
102
+ * 泛音改成更少更柔的正弦、瞬态降到 0.05 倍,叠加时靠共享压缩器兜底。
103
+ */
104
+ function playNote(midi, volume) {
105
+ if (volume <= 0.01) return;
106
+ const ctx = audioCtx();
107
+ if (!ctx) return;
108
+ const out = outputChain(ctx);
109
+ const freq = midiToFreq(midi);
110
+ const now = ctx.currentTime;
111
+ const v = Math.max(0.02, Math.min(1, volume));
112
+
113
+ const partials = [
114
+ { r: 1, a: 0.85, d: 2.0, type: "sine" },
115
+ { r: 2, a: 0.28, d: 1.1, type: "sine" },
116
+ { r: 3, a: 0.11, d: 0.6, type: "sine" },
117
+ { r: 4, a: 0.045, d: 0.4, type: "sine" }
118
+ ];
119
+ for (const p of partials) {
120
+ const osc = ctx.createOscillator();
121
+ osc.type = p.type;
122
+ osc.frequency.value = freq * p.r;
123
+ osc.detune.value = (Math.random() - 0.5) * 3;
124
+ const g = ctx.createGain();
125
+ g.gain.setValueAtTime(0.0001, now);
126
+ g.gain.linearRampToValueAtTime(p.a * v, now + 0.006);
127
+ g.gain.exponentialRampToValueAtTime(0.0001, now + p.d);
128
+ osc.connect(g);
129
+ g.connect(out);
130
+ osc.start(now);
131
+ osc.stop(now + p.d + 0.08);
132
+ }
133
+
134
+ // 极轻的击弦瞬态:只留一点触键质感,不再是明显嘶声
135
+ const noise = ctx.createBufferSource();
136
+ noise.buffer = noiseBuffer(ctx);
137
+ const hp = ctx.createBiquadFilter();
138
+ hp.type = "highpass";
139
+ hp.frequency.value = 2500;
140
+ const ng = ctx.createGain();
141
+ ng.gain.value = v * 0.05;
142
+ noise.connect(hp);
143
+ hp.connect(ng);
144
+ ng.connect(out);
145
+ noise.start(now);
146
+ }
147
+
148
+ //#region note naming
149
+ const NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
150
+ function noteLabel(midi) {
151
+ return NOTE_NAMES[((midi % 12) + 12) % 12] + (Math.floor(midi / 12) - 1);
152
+ }
153
+
154
+ //#region settings
155
+ const LS_KEY = "dsh-scroll-piano:settings";
156
+ const DEFAULTS = { on: true, volume: 0.25, stripWidth: 30, lowNote: 48, highNote: 84, inset: 56 };
157
+ function loadSettings() {
158
+ try {
159
+ return { ...DEFAULTS, ...(JSON.parse(localStorage.getItem(LS_KEY) || "{}")) };
160
+ } catch {
161
+ return { ...DEFAULTS };
162
+ }
163
+ }
164
+ function saveSettings(s) {
165
+ try { localStorage.setItem(LS_KEY, JSON.stringify(s)); } catch {}
166
+ }
167
+
168
+ //#region component
169
+ function ScrollPiano() {
170
+ const [settings, setSettings] = useState(loadSettings);
171
+ const [label, setLabel] = useState("");
172
+ const settingsRef = useRef(settings);
173
+ settingsRef.current = settings;
174
+ const lastMidiRef = useRef(null);
175
+ const lastPlayRef = useRef(0);
176
+ const stripRef = useRef(null);
177
+ const markerRef = useRef(null);
178
+ const hudRef = useRef(null);
179
+
180
+ const setOn = useCallback((on) => {
181
+ setSettings((s) => {
182
+ const next = { ...s, on };
183
+ saveSettings(next);
184
+ return next;
185
+ });
186
+ if (on) audioCtx();
187
+ }, []);
188
+ const setVolume = useCallback((volume) => {
189
+ setSettings((s) => {
190
+ const next = { ...s, volume };
191
+ saveSettings(next);
192
+ return next;
193
+ });
194
+ }, []);
195
+
196
+ useEffect(() => {
197
+ const showMarker = (y) => {
198
+ const m = markerRef.current;
199
+ if (m) m.style.top = Math.round(y - 3.5) + "px";
200
+ };
201
+ const showHud = (y, text) => {
202
+ const el = hudRef.current;
203
+ if (!el) return;
204
+ el.style.top = Math.round(y - 10) + "px";
205
+ el.style.right = "16px";
206
+ el.textContent = text;
207
+ };
208
+
209
+ const onMove = (e) => {
210
+ const s = settingsRef.current;
211
+ if (!s.on) return;
212
+ const W = window.innerWidth;
213
+ const H = window.innerHeight;
214
+ if (e.clientX < W - s.stripWidth) {
215
+ lastMidiRef.current = null;
216
+ if (markerRef.current) markerRef.current.classList.remove("dshscp_markerOn");
217
+ if (hudRef.current) hudRef.current.classList.remove("dshscp_hudOn");
218
+ return;
219
+ }
220
+ const span = Math.max(1, H - s.inset * 2);
221
+ const t = Math.min(1, Math.max(0, (e.clientY - s.inset) / span));
222
+ // t=0(顶部)→ highNote;t=1(底部)→ lowNote
223
+ const midi = Math.round(s.highNote - t * (s.highNote - s.lowNote));
224
+ showMarker(e.clientY);
225
+ showHud(e.clientY, noteLabel(midi) + " · " + midi);
226
+ if (markerRef.current) markerRef.current.classList.add("dshscp_markerOn");
227
+ if (hudRef.current) hudRef.current.classList.add("dshscp_hudOn");
228
+ if (midi !== lastMidiRef.current) {
229
+ lastMidiRef.current = midi;
230
+ // 限速:滑太快时不要瞬间堆几十个音(会糊成一片杂音)
231
+ const nowMs = performance.now();
232
+ if (nowMs - lastPlayRef.current >= 24) {
233
+ lastPlayRef.current = nowMs;
234
+ playNote(midi, s.volume);
235
+ }
236
+ setLabel(noteLabel(midi));
237
+ }
238
+ };
239
+ const onLeave = () => {
240
+ lastMidiRef.current = null;
241
+ if (markerRef.current) markerRef.current.classList.remove("dshscp_markerOn");
242
+ if (hudRef.current) hudRef.current.classList.remove("dshscp_hudOn");
243
+ };
244
+ document.addEventListener("pointermove", onMove, { passive: true });
245
+ document.addEventListener("pointerleave", onLeave);
246
+ window.addEventListener("blur", onLeave);
247
+ return () => {
248
+ document.removeEventListener("pointermove", onMove);
249
+ document.removeEventListener("pointerleave", onLeave);
250
+ window.removeEventListener("blur", onLeave);
251
+ };
252
+ }, []);
253
+
254
+ // 竖条指示 + 位置圆点 + 音符 HUD(纯视觉,不拦事件)
255
+ return h(React.Fragment, null,
256
+ h("div", { ref: stripRef, className: "dshscp_strip" + (settings.on ? " dshscp_stripOn" : "") }),
257
+ h("div", { ref: markerRef, className: "dshscp_marker" }),
258
+ h("div", { ref: hudRef, className: "dshscp_hud" }),
259
+ h("div", { className: "dshscp_pill" },
260
+ h("button", {
261
+ onClick: () => setOn(!settings.on),
262
+ title: settings.on ? "关闭滚动钢琴" : "开启滚动钢琴"
263
+ },
264
+ h("span", { className: "dshscp_dot" + (settings.on ? " dshscp_dotOn" : "") }),
265
+ "🎵 滚动钢琴" + (settings.on ? " ON" : " OFF")
266
+ ),
267
+ h("input", {
268
+ className: "dshscp_vol",
269
+ type: "range",
270
+ min: 0,
271
+ max: 1,
272
+ step: 0.05,
273
+ value: settings.volume,
274
+ onChange: (e) => setVolume(parseFloat(e.target.value)),
275
+ title: "音量"
276
+ }),
277
+ label ? h("span", { style: { opacity: 0.75, minWidth: "26px" } }, label) : null
278
+ )
279
+ );
280
+ }
281
+
282
+ //#region apply
283
+ const inject = ["slots"];
284
+ function apply(ctx) {
285
+ ctx.slots.inject("shell.overlay", () => ctx.slots.register({
286
+ name: "shell.overlay",
287
+ id: "dsh-scroll-piano:overlay",
288
+ order: 110,
289
+ inject: () => ({})
290
+ }, ScrollPiano));
291
+ }
292
+ exports.apply = apply;
293
+ exports.inject = inject;
294
+
295
+ return module.exports;
296
+ }
297
+ });
package/lib/index.js ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * dsh-scroll-piano — host half (browser-only surface).
3
+ *
4
+ * 全部功能在浏览器半(`./client`,通过 package.json → `dsh.client` 声明)。
5
+ * 服务器半不需要任何服务、不新增宿主状态,只需一个空 `apply` 让 Loader 行能挂载。
6
+ */
7
+ function apply() {}
8
+
9
+ export { apply };
package/package.json CHANGED
@@ -1,6 +1,59 @@
1
1
  {
2
2
  "name": "dsh-scroll-piano",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "滚动钢琴:鼠标滑过 DSH 右侧边缘的滚动条竖条时,按纵向位置发出钢琴音(越高音越高,特雷门琴式)。",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "dsh",
9
+ "dsh-plugin",
10
+ "deepseek-harness",
11
+ "piano",
12
+ "scrollbar",
13
+ "theremin",
14
+ "钢琴",
15
+ "滚动钢琴"
16
+ ],
17
+ "main": "lib/index.js",
18
+ "exports": {
19
+ ".": {
20
+ "default": "./lib/index.js"
21
+ },
22
+ "./client": {
23
+ "default": "./lib/client.js"
24
+ },
25
+ "./package.json": "./package.json"
26
+ },
27
+ "files": [
28
+ "lib/**/*.js",
29
+ "cordis.patch.yml",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "engines": {
34
+ "node": ">=22",
35
+ "dsh": ">=0.2.0-rc.1"
36
+ },
37
+ "peerDependencies": {
38
+ "@deepseek-ai/cordis": "^4.0.1"
39
+ },
40
+ "dsh": {
41
+ "bundle": {
42
+ "patch": "./cordis.patch.yml"
43
+ },
44
+ "client": {
45
+ "inject": [
46
+ "@deepseek-ai/dsh-client-ui-slots"
47
+ ],
48
+ "platform": "web"
49
+ },
50
+ "compatibility": {
51
+ "dshReleases": {
52
+ "0.2.0-rc.2": "compatible"
53
+ }
54
+ }
55
+ },
56
+ "scripts": {
57
+ "test": "node test/bundle-check.mjs"
58
+ }
6
59
  }