dsh-piano 0.0.0-stage → 0.2.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 +21 -0
- package/README.md +39 -2
- package/cordis.patch.yml +5 -0
- package/lib/client.js +434 -0
- package/lib/index.js +11 -0
- package/package.json +56 -3
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,40 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-piano · 滑动钢琴
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
在 DSH 界面里弹一条钢琴键盘:**点单个键出声,按住鼠标 / 触控板横向滑动会像刮奏(glissando)一样连续发声**。声音用 Web Audio 实时合成,零采样、零网络、零第三方依赖。
|
|
4
|
+
|
|
5
|
+
## 效果
|
|
6
|
+
|
|
7
|
+
- 左下角一枚 🎹 按钮(默认收起,不打扰正常使用)。**按钮可拖动**:按住移动超过 6px 即判定为拖(不会误开面板),松手后位置存 `localStorage`,下次进来仍在原处;400ms 内的 `click` 才算"点开",拖动不会吞掉下一次真正点击。
|
|
8
|
+
- 点开是底部居中的浮动键盘面板:音量滑块、八度移调(− / +)、收起。
|
|
9
|
+
- 键盘含白键 + 黑键;按住后滑动,指到哪个键就响哪个音(按住拖动 = 刮奏)。
|
|
10
|
+
- 设置(音量、起始八度)持久化到 `localStorage`,刷新后保留。
|
|
11
|
+
|
|
12
|
+
## 技术要点
|
|
13
|
+
|
|
14
|
+
| 项 | 做法 |
|
|
15
|
+
|---|---|
|
|
16
|
+
| 挂载槽位 | `shell.overlay`(常驻应用面座位) |
|
|
17
|
+
| 音频 | Web Audio 合成:基音 + 泛音(指数衰减)+ 高通噪声锤击瞬态 + 低通 + 压缩器防削波 |
|
|
18
|
+
| 刮奏 | 指针按下 `setPointerCapture` → `pointermove` 用 `document.elementFromPoint` 命中 `data-midi` 键 → 键变化即触发新音 |
|
|
19
|
+
| 客户端形态 | 手写 `window.__ModuleLoader__.load` 零构建 bundle,`platform: web` |
|
|
20
|
+
|
|
21
|
+
## 安装(本地)
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
# 以 file:/link: 目录依赖装进 profile,并加入 bundles(或 cordis insert)
|
|
25
|
+
dsh plugin --profile desktop add /path/to/dsh-piano -w
|
|
26
|
+
# 或手写:profile/package.json 加 "dsh-piano": "link:...", 并在 dsh.profile.bundles 加 "dsh-piano"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## 开发
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
npm test # node test/bundle-check.mjs —— 断言 bundle 结构与关键 marker
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
客户端 bundle 用 `link:` 目录依赖时,改完浏览器刷新即生效;服务端半改动需重启 Host。
|
|
36
|
+
|
|
37
|
+
## 说明
|
|
38
|
+
|
|
39
|
+
- 首次出声需要一次用户手势(点开按钮或按下琴键即自动解锁 AudioContext)。
|
|
40
|
+
- 浏览器无 `AudioContext`(极老环境)时静默不发声。
|
package/cordis.patch.yml
ADDED
package/lib/client.js
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-piano — browser half.
|
|
3
|
+
*
|
|
4
|
+
* 一条浮动钢琴键盘:点单键出声,鼠标/触控板按住后横向滑动会像刮奏(glissando)
|
|
5
|
+
* 一样连续触发不同音高。声音用 Web Audio 实时合成(泛音 + 快速起音 + 指数衰减 +
|
|
6
|
+
* 噪声锤击瞬态 + 低通/压缩),零采样、零网络、零依赖。
|
|
7
|
+
*
|
|
8
|
+
* 挂载点:`shell.overlay`(常驻应用面座位),渲染一个固定定位的悬浮小组件——
|
|
9
|
+
* 默认收起成左下角一枚 🎹 按钮,点开是底部居中的键盘面板(音量 / 八度移调 / 收起)。
|
|
10
|
+
*/
|
|
11
|
+
window.__ModuleLoader__.load({
|
|
12
|
+
id: "dsh-piano",
|
|
13
|
+
factory: (require) => {
|
|
14
|
+
var module = { exports: {} };
|
|
15
|
+
var exports = module.exports;
|
|
16
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
17
|
+
const React = require("react");
|
|
18
|
+
const { useEffect, useRef, useState, useMemo, useCallback } = React;
|
|
19
|
+
const h = React.createElement;
|
|
20
|
+
|
|
21
|
+
//#region styles
|
|
22
|
+
const CSS = [
|
|
23
|
+
".dshp_btn{box-sizing:border-box;position:fixed;left:16px;bottom:16px;z-index:2147483000;height:44px;min-width:44px;padding:0 14px;display:inline-flex;align-items:center;justify-content:center;gap:8px;border:none;border-radius:22px;background:var(--dsw-alias-bg-elevated,#1f2430);color:var(--dsw-alias-label-primary,#e6e8ee);box-shadow:0 6px 24px rgba(0,0,0,.35);cursor:pointer;user-select:none;touch-action:none;will-change:transform;font-size:16px;line-height:20px}",
|
|
24
|
+
".dshp_btn:hover{background:var(--dsw-alias-interactive-bg-hover,#2a3140)}",
|
|
25
|
+
".dshp_panel{box-sizing:border-box;position:fixed;left:50%;bottom:16px;transform:translateX(-50%);z-index:2147483000;width:min(94vw,760px);display:flex;flex-direction:column;gap:8px;padding:12px;border-radius:14px;background:var(--dsw-alias-bg-elevated,#1f2430);color:var(--dsw-alias-label-primary,#e6e8ee);box-shadow:0 12px 40px rgba(0,0,0,.45);user-select:none}",
|
|
26
|
+
".dshp_head{display:flex;align-items:center;gap:12px}",
|
|
27
|
+
".dshp_title{flex:none;font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary,#9aa2b1)}",
|
|
28
|
+
".dshp_controls{flex:1;display:flex;align-items:center;gap:12px}",
|
|
29
|
+
".dshp_vol{display:flex;align-items:center;gap:6px;color:var(--dsw-alias-label-tertiary,#6b7280);font-size:11px;line-height:16px}",
|
|
30
|
+
".dshp_vol input[type=range]{width:96px;accent-color:var(--dsw-alias-state-business-primary,#5b8def)}",
|
|
31
|
+
".dshp_oct{display:flex;align-items:center;gap:6px;color:var(--dsw-alias-label-tertiary,#6b7280);font-size:11px;line-height:16px}",
|
|
32
|
+
".dshp_oct button{box-sizing:border-box;width:24px;height:24px;padding:0;border:1px solid var(--dsw-alias-border-l2,#3a4150);border-radius:6px;background:transparent;color:var(--dsw-alias-label-primary,#e6e8ee);cursor:pointer;font-size:14px;line-height:1;display:inline-flex;align-items:center;justify-content:center}",
|
|
33
|
+
".dshp_oct button:hover{background:var(--dsw-alias-interactive-bg-hover,#2a3140)}",
|
|
34
|
+
".dshp_close{margin-left:auto;box-sizing:border-box;height:24px;padding:0 10px;border:none;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary,#9aa2b1);cursor:pointer;font-size:12px;line-height:20px}",
|
|
35
|
+
".dshp_close:hover{background:var(--dsw-alias-interactive-bg-hover,#2a3140);color:var(--dsw-alias-label-primary,#e6e8ee)}",
|
|
36
|
+
".dshp_kb{position:relative;height:180px;touch-action:none;cursor:pointer;border-radius:10px;overflow:hidden}",
|
|
37
|
+
".dshp_kb::after{content:\"\";position:absolute;left:0;right:0;bottom:0;height:4px;background:linear-gradient(90deg,#d33,#e88,#d33);opacity:.55;pointer-events:none}",
|
|
38
|
+
".dshp_white{position:relative;z-index:1;flex:1 1 0;height:100%;border-right:1px solid var(--dsw-alias-border-l2,#3a4150);border-radius:0 0 4px 4px;background:linear-gradient(180deg,#f7f8fa 0%,#e8ebf0 96%);color:#3a4150;display:flex;align-items:flex-end;justify-content:center;padding-bottom:8px;font-size:11px;line-height:14px}",
|
|
39
|
+
".dshp_white:last-child{border-right:none}",
|
|
40
|
+
".dshp_white.isActive{background:linear-gradient(180deg,#bcd4ff 0%,#9fc0ff 96%)}",
|
|
41
|
+
".dshp_black{position:absolute;z-index:2;top:0;height:62%;border-radius:0 0 4px 4px;background:linear-gradient(180deg,#2b303b 0%,#11141a 100%);color:#9aa2b1;box-shadow:0 2px 6px rgba(0,0,0,.4);display:flex;align-items:flex-end;justify-content:center;padding-bottom:6px;font-size:9px;line-height:12px}",
|
|
42
|
+
".dshp_black.isActive{background:linear-gradient(180deg,#4a6fb0 0%,#2f4d8f 100%)}",
|
|
43
|
+
".dshp_hint{margin:0;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,#6b7280)}"
|
|
44
|
+
].join("");
|
|
45
|
+
const tagId = "dsh-piano/piano.css";
|
|
46
|
+
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
47
|
+
const style = document.createElement("style");
|
|
48
|
+
style.setAttribute("data-plugin-css", tagId);
|
|
49
|
+
style.textContent = CSS;
|
|
50
|
+
document.head.appendChild(style);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
//#region audio engine
|
|
54
|
+
let _ctx = null;
|
|
55
|
+
let _out = null;
|
|
56
|
+
let _noiseBuf = null;
|
|
57
|
+
function audioCtx() {
|
|
58
|
+
if (!_ctx) {
|
|
59
|
+
const AC = window.AudioContext || window.webkitAudioContext;
|
|
60
|
+
if (!AC) return null;
|
|
61
|
+
_ctx = new AC();
|
|
62
|
+
}
|
|
63
|
+
if (_ctx.state === "suspended") _ctx.resume().catch(() => {});
|
|
64
|
+
return _ctx;
|
|
65
|
+
}
|
|
66
|
+
function midiToFreq(m) {
|
|
67
|
+
return 440 * Math.pow(2, (m - 69) / 12);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* 共享输出链(只建一次):总线 → 低通 → 压缩器。
|
|
71
|
+
* 所有音共用一个压缩器,滑奏时多音叠加由它统一压住:既防削波,
|
|
72
|
+
* 也避免每个音都重建一条 master/lowpass/compressor 链。
|
|
73
|
+
*/
|
|
74
|
+
function outputChain(ctx) {
|
|
75
|
+
if (_out) return _out;
|
|
76
|
+
const master = ctx.createGain();
|
|
77
|
+
master.gain.value = 0.85;
|
|
78
|
+
const lp = ctx.createBiquadFilter();
|
|
79
|
+
lp.type = "lowpass";
|
|
80
|
+
lp.frequency.value = 6500;
|
|
81
|
+
lp.Q.value = 0.3;
|
|
82
|
+
const comp = ctx.createDynamicsCompressor();
|
|
83
|
+
comp.threshold.value = -22;
|
|
84
|
+
comp.knee.value = 24;
|
|
85
|
+
comp.ratio.value = 12;
|
|
86
|
+
comp.attack.value = 0.004;
|
|
87
|
+
comp.release.value = 0.18;
|
|
88
|
+
master.connect(lp);
|
|
89
|
+
lp.connect(comp);
|
|
90
|
+
comp.connect(ctx.destination);
|
|
91
|
+
_out = master;
|
|
92
|
+
return master;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* 缓存的锤击噪声缓冲(只建一次)。
|
|
96
|
+
* 上一版每个音都新建一个 AudioBuffer,快速滑动时分配抖动会引入杂音。
|
|
97
|
+
*/
|
|
98
|
+
function noiseBuffer(ctx) {
|
|
99
|
+
if (_noiseBuf) return _noiseBuf;
|
|
100
|
+
const len = Math.max(1, Math.floor(ctx.sampleRate * 0.02));
|
|
101
|
+
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
|
|
102
|
+
const data = buf.getChannelData(0);
|
|
103
|
+
for (let i = 0; i < len; i++) data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, 3);
|
|
104
|
+
_noiseBuf = buf;
|
|
105
|
+
return buf;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* 合成一枚「电钢」音:基音 + 3 枚泛音(各带指数衰减)+ 极轻的击弦瞬态。
|
|
109
|
+
* 泛音改成更少更柔的正弦、瞬态降到 0.05 倍,叠加时靠共享压缩器兜底。
|
|
110
|
+
*/
|
|
111
|
+
function playNote(midi, volume) {
|
|
112
|
+
if (volume <= 0.01) return;
|
|
113
|
+
const ctx = audioCtx();
|
|
114
|
+
if (!ctx) return;
|
|
115
|
+
const out = outputChain(ctx);
|
|
116
|
+
const freq = midiToFreq(midi);
|
|
117
|
+
const now = ctx.currentTime;
|
|
118
|
+
const v = Math.max(0.02, Math.min(1, volume));
|
|
119
|
+
|
|
120
|
+
const partials = [
|
|
121
|
+
{ r: 1, a: 0.85, d: 2.0, type: "sine" },
|
|
122
|
+
{ r: 2, a: 0.28, d: 1.1, type: "sine" },
|
|
123
|
+
{ r: 3, a: 0.11, d: 0.6, type: "sine" },
|
|
124
|
+
{ r: 4, a: 0.045, d: 0.4, type: "sine" }
|
|
125
|
+
];
|
|
126
|
+
for (const p of partials) {
|
|
127
|
+
const osc = ctx.createOscillator();
|
|
128
|
+
osc.type = p.type;
|
|
129
|
+
osc.frequency.value = freq * p.r;
|
|
130
|
+
osc.detune.value = (Math.random() - 0.5) * 3;
|
|
131
|
+
const g = ctx.createGain();
|
|
132
|
+
g.gain.setValueAtTime(0.0001, now);
|
|
133
|
+
g.gain.linearRampToValueAtTime(p.a * v, now + 0.006);
|
|
134
|
+
g.gain.exponentialRampToValueAtTime(0.0001, now + p.d);
|
|
135
|
+
osc.connect(g);
|
|
136
|
+
g.connect(out);
|
|
137
|
+
osc.start(now);
|
|
138
|
+
osc.stop(now + p.d + 0.08);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 极轻的击弦瞬态:只留一点触键质感,不再是明显嘶声
|
|
142
|
+
const noise = ctx.createBufferSource();
|
|
143
|
+
noise.buffer = noiseBuffer(ctx);
|
|
144
|
+
const hp = ctx.createBiquadFilter();
|
|
145
|
+
hp.type = "highpass";
|
|
146
|
+
hp.frequency.value = 2500;
|
|
147
|
+
const ng = ctx.createGain();
|
|
148
|
+
ng.gain.value = v * 0.05;
|
|
149
|
+
noise.connect(hp);
|
|
150
|
+
hp.connect(ng);
|
|
151
|
+
ng.connect(out);
|
|
152
|
+
noise.start(now);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
//#region note layout
|
|
156
|
+
const NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
|
|
157
|
+
const WHITE_SEMITONES = [0, 2, 4, 5, 7, 9, 11];
|
|
158
|
+
// 黑键:afterWhite = 它右侧紧邻白键在“每个八度内”的序号(用于定位到边界),offset = 半音偏移。
|
|
159
|
+
const BLACK_KEYS = [
|
|
160
|
+
{ name: "C#", afterWhite: 1, offset: 1 },
|
|
161
|
+
{ name: "D#", afterWhite: 2, offset: 3 },
|
|
162
|
+
{ name: "F#", afterWhite: 4, offset: 6 },
|
|
163
|
+
{ name: "G#", afterWhite: 5, offset: 8 },
|
|
164
|
+
{ name: "A#", afterWhite: 6, offset: 10 }
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
function buildKeys(startMidi, octaves) {
|
|
168
|
+
const white = [];
|
|
169
|
+
const black = [];
|
|
170
|
+
for (let o = 0; o < octaves; o++) {
|
|
171
|
+
for (let i = 0; i < 7; i++) {
|
|
172
|
+
const midi = startMidi + o * 12 + WHITE_SEMITONES[i];
|
|
173
|
+
const pc = midi % 12;
|
|
174
|
+
white.push({
|
|
175
|
+
midi,
|
|
176
|
+
label: NOTE_NAMES[pc] + (pc === 0 ? "" + (Math.floor(midi / 12) - 1) : ""),
|
|
177
|
+
idx: o * 7 + i
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
for (const b of BLACK_KEYS) {
|
|
181
|
+
black.push({
|
|
182
|
+
midi: startMidi + o * 12 + b.offset,
|
|
183
|
+
label: b.name,
|
|
184
|
+
boundary: o * 7 + b.afterWhite
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return { white, black, totalWhite: white.length };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
//#region keyboard component
|
|
192
|
+
function PianoKeyboard({ startMidi, octaves, volume, activeMidi, setActiveMidi }) {
|
|
193
|
+
const { white, black, totalWhite } = useMemo(
|
|
194
|
+
() => buildKeys(startMidi, octaves),
|
|
195
|
+
[startMidi, octaves]
|
|
196
|
+
);
|
|
197
|
+
const kbRef = useRef(null);
|
|
198
|
+
const draggingRef = useRef(false);
|
|
199
|
+
const lastMidiRef = useRef(null);
|
|
200
|
+
const lastPlayRef = useRef(0);
|
|
201
|
+
const volumeRef = useRef(volume);
|
|
202
|
+
volumeRef.current = volume;
|
|
203
|
+
|
|
204
|
+
const keyAt = useCallback((e) => {
|
|
205
|
+
const el = document.elementFromPoint(e.clientX, e.clientY);
|
|
206
|
+
if (!el || !el.closest) return null;
|
|
207
|
+
const keyEl = el.closest("[data-midi]");
|
|
208
|
+
if (!keyEl) return null;
|
|
209
|
+
return parseInt(keyEl.getAttribute("data-midi"), 10);
|
|
210
|
+
}, []);
|
|
211
|
+
|
|
212
|
+
const onPointerDown = useCallback((e) => {
|
|
213
|
+
if (e.button !== 0 && e.pointerType === "mouse") return;
|
|
214
|
+
audioCtx(); // 用户手势里解锁/恢复 AudioContext
|
|
215
|
+
draggingRef.current = true;
|
|
216
|
+
const midi = keyAt(e);
|
|
217
|
+
if (midi != null) {
|
|
218
|
+
lastMidiRef.current = midi;
|
|
219
|
+
setActiveMidi(midi);
|
|
220
|
+
playNote(midi, volumeRef.current);
|
|
221
|
+
}
|
|
222
|
+
try { e.currentTarget.setPointerCapture(e.pointerId); } catch {}
|
|
223
|
+
}, [keyAt, setActiveMidi]);
|
|
224
|
+
|
|
225
|
+
const onPointerMove = useCallback((e) => {
|
|
226
|
+
if (!draggingRef.current) return;
|
|
227
|
+
const midi = keyAt(e);
|
|
228
|
+
if (midi == null) {
|
|
229
|
+
setActiveMidi(null);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (midi !== lastMidiRef.current) {
|
|
233
|
+
lastMidiRef.current = midi;
|
|
234
|
+
setActiveMidi(midi);
|
|
235
|
+
// 限速:快速划过整排键时不要瞬间堆满音(会糊成一片)
|
|
236
|
+
const nowMs = performance.now();
|
|
237
|
+
if (nowMs - lastPlayRef.current >= 24) {
|
|
238
|
+
lastPlayRef.current = nowMs;
|
|
239
|
+
playNote(midi, volumeRef.current);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}, [keyAt, setActiveMidi]);
|
|
243
|
+
|
|
244
|
+
const onPointerEnd = useCallback((e) => {
|
|
245
|
+
draggingRef.current = false;
|
|
246
|
+
lastMidiRef.current = null;
|
|
247
|
+
setActiveMidi(null);
|
|
248
|
+
try { e.currentTarget.releasePointerCapture(e.pointerId); } catch {}
|
|
249
|
+
}, [setActiveMidi]);
|
|
250
|
+
|
|
251
|
+
const whiteWidthPct = 100 / totalWhite;
|
|
252
|
+
const blackWidthPct = whiteWidthPct * 0.62;
|
|
253
|
+
|
|
254
|
+
const whiteRow = white.map((k) =>
|
|
255
|
+
h("div", {
|
|
256
|
+
key: "w" + k.midi,
|
|
257
|
+
"data-midi": k.midi,
|
|
258
|
+
className: "dshp_white" + (k.midi === activeMidi ? " isActive" : ""),
|
|
259
|
+
style: { flex: "1 1 0" }
|
|
260
|
+
}, k.label)
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const blackRow = black.map((k) => {
|
|
264
|
+
const leftPct = k.boundary * whiteWidthPct - blackWidthPct / 2;
|
|
265
|
+
return h("div", {
|
|
266
|
+
key: "b" + k.midi,
|
|
267
|
+
"data-midi": k.midi,
|
|
268
|
+
className: "dshp_black" + (k.midi === activeMidi ? " isActive" : ""),
|
|
269
|
+
style: { left: leftPct + "%", width: blackWidthPct + "%" }
|
|
270
|
+
}, k.label);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
return h("div", {
|
|
274
|
+
ref: kbRef,
|
|
275
|
+
className: "dshp_kb",
|
|
276
|
+
style: { display: "flex" },
|
|
277
|
+
onPointerDown,
|
|
278
|
+
onPointerMove,
|
|
279
|
+
onPointerUp: onPointerEnd,
|
|
280
|
+
onPointerCancel: onPointerEnd,
|
|
281
|
+
onContextMenu: (e) => e.preventDefault()
|
|
282
|
+
}, whiteRow, blackRow);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
//#region widget
|
|
286
|
+
const LS_KEY = "dsh-piano:settings";
|
|
287
|
+
const DEFAULTS = { volume: 0.35, startMidi: 48, octaves: 3 };
|
|
288
|
+
function loadSettings() {
|
|
289
|
+
try {
|
|
290
|
+
return { ...DEFAULTS, ...(JSON.parse(localStorage.getItem(LS_KEY) || "{}")) };
|
|
291
|
+
} catch {
|
|
292
|
+
return { ...DEFAULTS };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function saveSettings(s) {
|
|
296
|
+
try { localStorage.setItem(LS_KEY, JSON.stringify(s)); } catch {}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function PianoWidget() {
|
|
300
|
+
const [open, setOpen] = useState(false);
|
|
301
|
+
const [settings, setSettings] = useState(loadSettings);
|
|
302
|
+
const [activeMidi, setActiveMidi] = useState(null);
|
|
303
|
+
|
|
304
|
+
const setVolume = useCallback((volume) => {
|
|
305
|
+
setSettings((s) => {
|
|
306
|
+
const next = { ...s, volume };
|
|
307
|
+
saveSettings(next);
|
|
308
|
+
return next;
|
|
309
|
+
});
|
|
310
|
+
}, []);
|
|
311
|
+
const shiftOctave = useCallback((delta) => {
|
|
312
|
+
setSettings((s) => {
|
|
313
|
+
const startMidi = Math.max(24, Math.min(84, s.startMidi + delta * 12));
|
|
314
|
+
const next = { ...s, startMidi };
|
|
315
|
+
saveSettings(next);
|
|
316
|
+
return next;
|
|
317
|
+
});
|
|
318
|
+
}, []);
|
|
319
|
+
const onOpen = useCallback(() => {
|
|
320
|
+
audioCtx(); // 点开时预解锁,避免第一次按键静音
|
|
321
|
+
setOpen(true);
|
|
322
|
+
}, []);
|
|
323
|
+
|
|
324
|
+
// —— 收起态按钮**可拖动**:移动超过 6px 判定为「拖」(不触发开面板),
|
|
325
|
+
// 松手后位置存 localStorage,下次进来仍在原处。 ——
|
|
326
|
+
const [btnPos, setBtnPos] = useState(() => {
|
|
327
|
+
try { return JSON.parse(localStorage.getItem("dsh-piano:btnPos") || "null"); } catch { return null; }
|
|
328
|
+
});
|
|
329
|
+
const btnPosRef = useRef(btnPos);
|
|
330
|
+
btnPosRef.current = btnPos;
|
|
331
|
+
const dragRef = useRef(null);
|
|
332
|
+
const draggedRef = useRef(false);
|
|
333
|
+
const dragClearRef = useRef(0);
|
|
334
|
+
|
|
335
|
+
const onBtnDown = (e) => {
|
|
336
|
+
if (e.pointerType === "mouse" && e.button !== 0) return;
|
|
337
|
+
draggedRef.current = false;
|
|
338
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
339
|
+
dragRef.current = { x: e.clientX, y: e.clientY, left: r.left, top: r.top, moved: false };
|
|
340
|
+
try { e.currentTarget.setPointerCapture(e.pointerId); } catch {}
|
|
341
|
+
};
|
|
342
|
+
const onBtnMove = (e) => {
|
|
343
|
+
const d = dragRef.current;
|
|
344
|
+
if (!d) return;
|
|
345
|
+
const dx = e.clientX - d.x;
|
|
346
|
+
const dy = e.clientY - d.y;
|
|
347
|
+
if (!d.moved && Math.abs(dx) + Math.abs(dy) > 6) d.moved = true;
|
|
348
|
+
if (!d.moved) return;
|
|
349
|
+
const left = Math.max(4, Math.min(window.innerWidth - 70, d.left + dx));
|
|
350
|
+
const top = Math.max(4, Math.min(window.innerHeight - 50, d.top + dy));
|
|
351
|
+
btnPosRef.current = { left, top };
|
|
352
|
+
setBtnPos({ left, top });
|
|
353
|
+
};
|
|
354
|
+
const onBtnUp = (e) => {
|
|
355
|
+
const d = dragRef.current;
|
|
356
|
+
dragRef.current = null;
|
|
357
|
+
try { e.currentTarget.releasePointerCapture(e.pointerId); } catch {}
|
|
358
|
+
if (!d || !d.moved) return;
|
|
359
|
+
draggedRef.current = true;
|
|
360
|
+
const pos = btnPosRef.current;
|
|
361
|
+
if (pos) { try { localStorage.setItem("dsh-piano:btnPos", JSON.stringify(pos)); } catch {} }
|
|
362
|
+
// 拖完不一定紧跟 click(拖到按钮外松手就点不出来)→ 400ms 后自动清标志,
|
|
363
|
+
// 免得把**下一次**真正的点击吞掉
|
|
364
|
+
if (dragClearRef.current) window.clearTimeout(dragClearRef.current);
|
|
365
|
+
dragClearRef.current = window.setTimeout(() => { draggedRef.current = false; }, 400);
|
|
366
|
+
};
|
|
367
|
+
const onBtnClick = () => {
|
|
368
|
+
if (draggedRef.current) { draggedRef.current = false; return; } // 刚才那一下是拖,不是点
|
|
369
|
+
onOpen();
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
if (!open) {
|
|
373
|
+
return h("button", {
|
|
374
|
+
className: "dshp_btn",
|
|
375
|
+
title: "滑动钢琴(可拖动,点开面板)",
|
|
376
|
+
onClick: onBtnClick,
|
|
377
|
+
onPointerDown: onBtnDown,
|
|
378
|
+
onPointerMove: onBtnMove,
|
|
379
|
+
onPointerUp: onBtnUp,
|
|
380
|
+
onPointerCancel: onBtnUp,
|
|
381
|
+
style: btnPos ? { left: btnPos.left + "px", top: btnPos.top + "px", bottom: "auto" } : undefined
|
|
382
|
+
}, "🎹 钢琴");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return h("div", { className: "dshp_panel" },
|
|
386
|
+
h("div", { className: "dshp_head" },
|
|
387
|
+
h("span", { className: "dshp_title" }, "🎹 滑动钢琴"),
|
|
388
|
+
h("div", { className: "dshp_controls" },
|
|
389
|
+
h("label", { className: "dshp_vol" },
|
|
390
|
+
"音量",
|
|
391
|
+
h("input", {
|
|
392
|
+
type: "range",
|
|
393
|
+
min: 0,
|
|
394
|
+
max: 1,
|
|
395
|
+
step: 0.05,
|
|
396
|
+
value: settings.volume,
|
|
397
|
+
onChange: (e) => setVolume(parseFloat(e.target.value))
|
|
398
|
+
})
|
|
399
|
+
),
|
|
400
|
+
h("div", { className: "dshp_oct" },
|
|
401
|
+
h("button", { onClick: () => shiftOctave(-1), title: "低一个八度" }, "−"),
|
|
402
|
+
"八度",
|
|
403
|
+
h("button", { onClick: () => shiftOctave(1), title: "高一个八度" }, "+")
|
|
404
|
+
)
|
|
405
|
+
),
|
|
406
|
+
h("button", { className: "dshp_close", onClick: () => setOpen(false) }, "收起")
|
|
407
|
+
),
|
|
408
|
+
h(PianoKeyboard, {
|
|
409
|
+
startMidi: settings.startMidi,
|
|
410
|
+
octaves: settings.octaves,
|
|
411
|
+
volume: settings.volume,
|
|
412
|
+
activeMidi,
|
|
413
|
+
setActiveMidi
|
|
414
|
+
}),
|
|
415
|
+
h("p", { className: "dshp_hint" }, "按住鼠标 / 触控板横向滑动 = 刮奏;点单个键 = 单音")
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
//#region apply
|
|
420
|
+
const inject = ["slots"];
|
|
421
|
+
function apply(ctx) {
|
|
422
|
+
ctx.slots.inject("shell.overlay", () => ctx.slots.register({
|
|
423
|
+
name: "shell.overlay",
|
|
424
|
+
id: "dsh-piano:overlay",
|
|
425
|
+
order: 100,
|
|
426
|
+
inject: () => ({})
|
|
427
|
+
}, PianoWidget));
|
|
428
|
+
}
|
|
429
|
+
exports.apply = apply;
|
|
430
|
+
exports.inject = inject;
|
|
431
|
+
|
|
432
|
+
return module.exports;
|
|
433
|
+
}
|
|
434
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-piano — host half (browser-only surface).
|
|
3
|
+
*
|
|
4
|
+
* 整个功能都在浏览器半(`./client`,通过 package.json → `dsh.client` 声明,
|
|
5
|
+
* `platform: "web"`)里:一条浮动钢琴键盘 + Web Audio 合成,鼠标/触控板
|
|
6
|
+
* 滑过去触发刮奏。服务器半不需要任何服务、不新增任何宿主状态,因此这里
|
|
7
|
+
* 只需导出一个空 `apply`,让 Loader 行能干净挂载即可。
|
|
8
|
+
*/
|
|
9
|
+
function apply() {}
|
|
10
|
+
|
|
11
|
+
export { apply };
|
package/package.json
CHANGED
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-piano",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "滑动钢琴:在 DSH 界面里弹一条钢琴键盘,鼠标/触控板滑过去像刮奏(glissando)一样出声。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"dsh",
|
|
9
|
+
"dsh-plugin",
|
|
10
|
+
"deepseek-harness",
|
|
11
|
+
"piano",
|
|
12
|
+
"glissando",
|
|
13
|
+
"music",
|
|
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
|
}
|