dsh-live2dwaifu 0.1.5 → 0.2.1
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/README.md +11 -1
- package/assets/tone.html +256 -0
- package/assets/viewer.html +4 -1
- package/assets/viewer.js +146 -6
- package/docs/blip-sample.html +259 -0
- package/lib/client.js +113 -7
- package/lib/index.js +40 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -13,7 +13,17 @@
|
|
|
13
13
|
| 展开 | 一张小卡片,上半身,底下一条气泡装台词 |
|
|
14
14
|
| 收起 | 缩成一条,只剩名字和一个 ⌃ |
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
按住标题栏能把她拖到屏幕任何地方,右下角那个小三角拖的是大小;标题栏的 **◐** 一键换背景(透明 / 深色 / 浅色 / 纸质)。位置、大小、背景、开合状态都记在本机,下次还是这样。
|
|
17
|
+
|
|
18
|
+
**她会看你**:鼠标在窗口里走,眼珠跟着转,脑袋也偏一点。
|
|
19
|
+
|
|
20
|
+
**能戳**:点她的头会害羞,戳身上会瞪你,点底下就应你一声。
|
|
21
|
+
|
|
22
|
+
**会自己动**:模型里带 `.motion3.json` 的,隔十几秒自己播一个动作。气泡能拖着挪地方,说完过一阵自己淡下去。
|
|
23
|
+
|
|
24
|
+
**会出声**:一个字一声很短的电音,标点停一拍,句尾沉一下——不是朗读,是“哔哔”那种。标题栏的 **♪** 打开调音页,放个真模型在旁边,边调边听;每只模型各存一份音色,换人换声。音量拉到 0 就安静,只剩气泡和口型。
|
|
25
|
+
|
|
26
|
+
**跟着聊**:她挂在聊天流上(`conversation.chat.node` 插槽),助手每说一句她就念一句,情绪按话里的词换。
|
|
17
27
|
|
|
18
28
|
## 三十秒上手
|
|
19
29
|
|
package/assets/tone.html
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>音色 — dsh-live2dwaifu</title>
|
|
6
|
+
<style>
|
|
7
|
+
:root { --bg:#12141a; --card:#171a20; --line:#2c313a; --txt:#dfe4ec; --dim:#7d858f; --acc:#6aa9ff; }
|
|
8
|
+
* { box-sizing: border-box; }
|
|
9
|
+
body { margin:0; min-height:100vh; background:radial-gradient(1100px 560px at 50% -10%, #1b1f27, var(--bg));
|
|
10
|
+
color:var(--txt); font:13px/1.6 "Microsoft YaHei", system-ui, sans-serif;
|
|
11
|
+
display:flex; align-items:flex-start; justify-content:center; gap:26px; padding:26px; flex-wrap:wrap; }
|
|
12
|
+
.left { display:flex; flex-direction:column; gap:10px; width:340px; }
|
|
13
|
+
.card { width:340px; height:460px; background:var(--card); border:1px solid var(--line); border-radius:14px;
|
|
14
|
+
overflow:hidden; box-shadow:0 18px 50px rgba(0,0,0,.45); }
|
|
15
|
+
.card iframe { display:block; width:100%; height:100%; border:0; background:transparent; }
|
|
16
|
+
.panel { width:400px; }
|
|
17
|
+
.panel h1 { font-size:15px; margin:0 0 3px; font-weight:600; }
|
|
18
|
+
.panel p.sub { margin:0 0 14px; color:var(--dim); font-size:12px; }
|
|
19
|
+
.group { border:1px solid var(--line); border-radius:12px; padding:12px; margin-bottom:12px; background:#15181e; }
|
|
20
|
+
.group h2 { font-size:11px; letter-spacing:.6px; color:var(--dim); margin:0 0 10px; font-weight:600; }
|
|
21
|
+
select { width:100%; padding:8px 10px; border-radius:9px; border:1px solid #3c424c; background:#181c22;
|
|
22
|
+
color:var(--txt); font:inherit; font-size:13px; }
|
|
23
|
+
.presets { display:flex; gap:8px; }
|
|
24
|
+
.presets button { flex:1; padding:9px 0; border-radius:9px; border:1px solid #3c424c; background:#232830;
|
|
25
|
+
color:#cfd4dd; cursor:pointer; font:inherit; font-size:12px; }
|
|
26
|
+
.presets button.on { border-color:var(--acc); background:#1e2c40; color:#eaf2ff; }
|
|
27
|
+
.row { display:flex; align-items:center; gap:10px; margin:9px 0; font-size:12px; }
|
|
28
|
+
.row span.lab { width:56px; flex:none; color:var(--dim); }
|
|
29
|
+
.row input[type=range] { flex:1; accent-color:var(--acc); }
|
|
30
|
+
.row b { width:46px; text-align:right; font-weight:500; color:#dfe4ec; font-variant-numeric:tabular-nums; }
|
|
31
|
+
.typebox { display:flex; gap:8px; }
|
|
32
|
+
.typebox input { flex:1; padding:9px 11px; border-radius:9px; border:1px solid #3c424c; background:#181c22;
|
|
33
|
+
color:var(--txt); font:inherit; font-size:12px; }
|
|
34
|
+
button.act { padding:9px 14px; border-radius:9px; border:1px solid #3c424c; background:#232830;
|
|
35
|
+
color:#dfe4ec; cursor:pointer; font:inherit; font-size:12px; }
|
|
36
|
+
button.act.primary { background:#27405f; border-color:#3e6b9e; color:#eaf2ff; }
|
|
37
|
+
button.act:hover { filter:brightness(1.12); }
|
|
38
|
+
.foot { display:flex; align-items:center; gap:10px; }
|
|
39
|
+
.state { color:var(--dim); font-size:11px; margin-left:auto; }
|
|
40
|
+
.tip { color:#6f7783; font-size:11px; margin-top:10px; line-height:1.7; }
|
|
41
|
+
</style>
|
|
42
|
+
</head>
|
|
43
|
+
<body>
|
|
44
|
+
|
|
45
|
+
<div class="left">
|
|
46
|
+
<div class="card"><iframe id="live" src="/dsh-live2d/viewer?embed=1"></iframe></div>
|
|
47
|
+
<div class="tip">没声音就先点她一下——浏览器要一次点击才允许出声。</div>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div class="panel">
|
|
51
|
+
<h1>音色</h1>
|
|
52
|
+
<p class="sub">一个字一声短音。音量 0 就是安静,只留气泡和口型。</p>
|
|
53
|
+
|
|
54
|
+
<div class="group">
|
|
55
|
+
<h2>配给哪个模型</h2>
|
|
56
|
+
<select id="models"></select>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<div class="group">
|
|
60
|
+
<h2>音色</h2>
|
|
61
|
+
<div class="presets" id="presets">
|
|
62
|
+
<button data-p="soft" class="on">柔和</button>
|
|
63
|
+
<button data-p="crisp">清脆</button>
|
|
64
|
+
<button data-p="hard">机械</button>
|
|
65
|
+
<button data-p="game">游戏</button>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<div class="group">
|
|
70
|
+
<h2>参数</h2>
|
|
71
|
+
<div class="row"><span class="lab">基频</span><input id="base" type="range" min="80" max="1200" value="400" /><b id="baseV">400</b></div>
|
|
72
|
+
<div class="row"><span class="lab">抖动</span><input id="jit" type="range" min="0" max="50" value="10" /><b id="jitV">10%</b></div>
|
|
73
|
+
<div class="row"><span class="lab">长度</span><input id="dur" type="range" min="10" max="200" value="60" /><b id="durV">60</b></div>
|
|
74
|
+
<div class="row"><span class="lab">音量</span><input id="vol" type="range" min="0" max="100" value="32" /><b id="volV">32</b></div>
|
|
75
|
+
<div class="row"><span class="lab">语速</span><input id="spd" type="range" min="20" max="200" value="62" /><b id="spdV">62</b></div>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<div class="group">
|
|
79
|
+
<h2>试一句</h2>
|
|
80
|
+
<div class="typebox">
|
|
81
|
+
<input id="text" value="窗台那盆,红了。" />
|
|
82
|
+
<button class="act primary" id="say">说</button>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<div class="foot">
|
|
87
|
+
<button class="act primary" id="save">存到这只模型</button>
|
|
88
|
+
<button class="act" id="reset">恢复默认</button>
|
|
89
|
+
<span class="state" id="state"></span>
|
|
90
|
+
</div>
|
|
91
|
+
<div class="tip">每只模型各存一份。换模型、换人,声音跟着换。</div>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<script>
|
|
95
|
+
(function () {
|
|
96
|
+
var DEFAULTS = { preset: 'soft', type: 'triangle', base: 400, jit: 10, dur: 60, vol: 32, spd: 62 };
|
|
97
|
+
var PRESETS = {
|
|
98
|
+
soft: { type: 'triangle', base: 400, jit: 10, dur: 60, vol: 32, spd: 62 },
|
|
99
|
+
crisp: { type: 'square', base: 620, jit: 8, dur: 34, vol: 24, spd: 48 },
|
|
100
|
+
hard: { type: 'sawtooth', base: 300, jit: 24, dur: 44, vol: 22, spd: 56 },
|
|
101
|
+
game: { type: 'square', base: 520, jit: 18, dur: 28, vol: 26, spd: 44 }
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
var voice = Object.assign({}, DEFAULTS);
|
|
105
|
+
var modelId = '';
|
|
106
|
+
var frame = document.getElementById('live');
|
|
107
|
+
var state = document.getElementById('state');
|
|
108
|
+
var sel = document.getElementById('models');
|
|
109
|
+
var presetBtns = [].slice.call(document.querySelectorAll('#presets button'));
|
|
110
|
+
|
|
111
|
+
function $(id) { return document.getElementById(id); }
|
|
112
|
+
|
|
113
|
+
function pushTone() {
|
|
114
|
+
try {
|
|
115
|
+
frame.contentWindow.postMessage({ type: 'dsh-live2d/tone', voice: voice }, '*');
|
|
116
|
+
} catch (e) {}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function sayOnce(text) {
|
|
120
|
+
pushTone();
|
|
121
|
+
try {
|
|
122
|
+
frame.contentWindow.postMessage({ type: 'dsh-live2d/say', text: text }, '*');
|
|
123
|
+
} catch (e) {}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function show() {
|
|
127
|
+
$('base').value = voice.base; $('baseV').textContent = voice.base;
|
|
128
|
+
$('jit').value = voice.jit; $('jitV').textContent = voice.jit + '%';
|
|
129
|
+
$('dur').value = voice.dur; $('durV').textContent = voice.dur;
|
|
130
|
+
$('vol').value = voice.vol; $('volV').textContent = voice.vol;
|
|
131
|
+
$('spd').value = voice.spd; $('spdV').textContent = voice.spd;
|
|
132
|
+
presetBtns.forEach(function (b) {
|
|
133
|
+
b.classList.toggle('on', b.dataset.p === voice.preset);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function bind(id, key, fmt) {
|
|
138
|
+
var el = $(id);
|
|
139
|
+
var out = $(id + 'V');
|
|
140
|
+
el.addEventListener('input', function () {
|
|
141
|
+
voice[key] = Number(el.value);
|
|
142
|
+
voice.preset = 'custom';
|
|
143
|
+
out.textContent = fmt ? fmt(voice[key]) : voice[key];
|
|
144
|
+
presetBtns.forEach(function (b) { b.classList.remove('on'); });
|
|
145
|
+
pushTone();
|
|
146
|
+
});
|
|
147
|
+
el.addEventListener('change', function () {
|
|
148
|
+
sayOnce('嗯。');
|
|
149
|
+
});
|
|
150
|
+
out.textContent = fmt ? fmt(voice[key]) : voice[key];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
bind('base', 'base');
|
|
154
|
+
bind('jit', 'jit', function (v) { return v + '%'; });
|
|
155
|
+
bind('dur', 'dur');
|
|
156
|
+
bind('vol', 'vol');
|
|
157
|
+
bind('spd', 'spd');
|
|
158
|
+
|
|
159
|
+
presetBtns.forEach(function (b) {
|
|
160
|
+
b.addEventListener('click', function () {
|
|
161
|
+
var p = PRESETS[b.dataset.p] || DEFAULTS;
|
|
162
|
+
Object.keys(DEFAULTS).forEach(function (k) {
|
|
163
|
+
if (k in p) voice[k] = p[k];
|
|
164
|
+
});
|
|
165
|
+
voice.preset = b.dataset.p;
|
|
166
|
+
show();
|
|
167
|
+
sayOnce('你回来了。锅里还有。');
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
$('say').addEventListener('click', function () {
|
|
172
|
+
sayOnce($('text').value || '嗯。');
|
|
173
|
+
});
|
|
174
|
+
$('text').addEventListener('keydown', function (e) {
|
|
175
|
+
if (e.key === 'Enter') sayOnce($('text').value || '嗯。');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
$('reset').addEventListener('click', function () {
|
|
179
|
+
voice = Object.assign({}, DEFAULTS);
|
|
180
|
+
show();
|
|
181
|
+
pushTone();
|
|
182
|
+
state.textContent = '已恢复默认,还没存';
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
$('save').addEventListener('click', function () {
|
|
186
|
+
if (!modelId) return;
|
|
187
|
+
state.textContent = '存…';
|
|
188
|
+
fetch('/dsh-live2d/api/voice', {
|
|
189
|
+
method: 'POST',
|
|
190
|
+
headers: { 'content-type': 'application/json' },
|
|
191
|
+
body: JSON.stringify({ id: modelId, voice: voice })
|
|
192
|
+
})
|
|
193
|
+
.then(function (r) { return r.json(); })
|
|
194
|
+
.then(function (d) {
|
|
195
|
+
state.textContent = d.ok ? '存好了' : '没存上:' + (d.error || '?');
|
|
196
|
+
})
|
|
197
|
+
.catch(function (e) { state.textContent = '没存上:' + e.message; });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
sel.addEventListener('change', function () {
|
|
201
|
+
modelId = sel.value;
|
|
202
|
+
loadVoice(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
function loadVoice(alsoSwitch) {
|
|
206
|
+
if (!modelId) return;
|
|
207
|
+
state.textContent = '读…';
|
|
208
|
+
if (alsoSwitch) {
|
|
209
|
+
fetch('/dsh-live2d/api/select', {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
headers: { 'content-type': 'application/json' },
|
|
212
|
+
body: JSON.stringify({ id: modelId })
|
|
213
|
+
}).then(function () {
|
|
214
|
+
frame.src = '/dsh-live2d/viewer?embed=1&t=' + Date.now();
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
fetch('/dsh-live2d/api/voice?id=' + encodeURIComponent(modelId))
|
|
218
|
+
.then(function (r) { return r.json(); })
|
|
219
|
+
.then(function (d) {
|
|
220
|
+
voice = Object.assign({}, DEFAULTS, d.voice || {});
|
|
221
|
+
show();
|
|
222
|
+
pushTone();
|
|
223
|
+
setTimeout(pushTone, 900);
|
|
224
|
+
state.textContent = '读好了';
|
|
225
|
+
})
|
|
226
|
+
.catch(function (e) { state.textContent = '读不了:' + e.message; });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
fetch('/dsh-live2d/api/models')
|
|
230
|
+
.then(function (r) { return r.json(); })
|
|
231
|
+
.then(function (d) {
|
|
232
|
+
var list = d.models || [];
|
|
233
|
+
sel.innerHTML = '';
|
|
234
|
+
list.forEach(function (m) {
|
|
235
|
+
var o = document.createElement('option');
|
|
236
|
+
o.value = m.id;
|
|
237
|
+
o.textContent = (m.name || m.id) + (m.id === d.selected ? '(正在显示)' : '');
|
|
238
|
+
sel.appendChild(o);
|
|
239
|
+
});
|
|
240
|
+
modelId = d.selected || (list[0] && list[0].id) || '';
|
|
241
|
+
if (modelId) sel.value = modelId;
|
|
242
|
+
if (!list.length) {
|
|
243
|
+
state.textContent = '还没有模型';
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
loadVoice(false);
|
|
247
|
+
})
|
|
248
|
+
.catch(function (e) { state.textContent = '读不了模型表:' + e.message; });
|
|
249
|
+
|
|
250
|
+
frame.addEventListener('load', function () {
|
|
251
|
+
pushTone();
|
|
252
|
+
});
|
|
253
|
+
})();
|
|
254
|
+
</script>
|
|
255
|
+
</body>
|
|
256
|
+
</html>
|
package/assets/viewer.html
CHANGED
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
#stage { display: block; width: 100%; height: 100%; }
|
|
11
11
|
#bubble { position: absolute; left: 12px; right: 12px; bottom: 10px; min-height: 30px; box-sizing: border-box;
|
|
12
12
|
background: rgba(24,27,33,.94); border: 1px solid #3a4049; border-radius: 10px; padding: 7px 10px;
|
|
13
|
-
color: #dfe4ec; font-size: 13px; line-height: 1.45;
|
|
13
|
+
color: #dfe4ec; font-size: 13px; line-height: 1.45;
|
|
14
|
+
opacity: 0; transition: opacity .35s ease; cursor: grab; user-select: none;
|
|
15
|
+
max-height: 40%; overflow: hidden; touch-action: none; }
|
|
16
|
+
#bubble:active { cursor: grabbing; }
|
|
14
17
|
#hint { position: absolute; inset: 0; display: none; align-items: center; justify-content: center;
|
|
15
18
|
text-align: center; padding: 20px; color: #98a0ad; }
|
|
16
19
|
#hint.show { display: flex; }
|
package/assets/viewer.js
CHANGED
|
@@ -44312,6 +44312,46 @@ void main(void)\r
|
|
|
44312
44312
|
var exps = [];
|
|
44313
44313
|
var expParams = /* @__PURE__ */ new Map();
|
|
44314
44314
|
var expName = null;
|
|
44315
|
+
var gazeX = 0;
|
|
44316
|
+
var gazeY = 0;
|
|
44317
|
+
var pokeAt = 0;
|
|
44318
|
+
var sayFade = 0;
|
|
44319
|
+
var motionGroups = [];
|
|
44320
|
+
var nextMotionAt = 0;
|
|
44321
|
+
var POKE_HEAD = ["\u5514\u3002", "\u522B\u6478\u3002", "\u5934\u53D1\u4E71\u4E86\u3002", "\u55EF\u2026\u2026", "\u4F60\u624B\u51C9\u3002"];
|
|
44322
|
+
var POKE_BODY = ["\u770B\u4EC0\u4E48\u770B\u3002", "\u6233\u4EC0\u4E48\u3002", "\u54FC\u3002", "\u522B\u95F9\u3002"];
|
|
44323
|
+
var POKE_LOW = ["\u5728\u5462\u3002", "\u6709\u4E8B\uFF1F", "\u600E\u4E48\u4E86\u3002"];
|
|
44324
|
+
var tone = { type: "triangle", base: 400, jit: 10, dur: 60, vol: 32, spd: 62 };
|
|
44325
|
+
var ac = null;
|
|
44326
|
+
function audioCtx() {
|
|
44327
|
+
if (!ac) {
|
|
44328
|
+
try {
|
|
44329
|
+
ac = new (window.AudioContext || window.webkitAudioContext)();
|
|
44330
|
+
} catch {
|
|
44331
|
+
ac = null;
|
|
44332
|
+
}
|
|
44333
|
+
}
|
|
44334
|
+
if (ac && ac.state === "suspended") ac.resume().catch(() => {
|
|
44335
|
+
});
|
|
44336
|
+
return ac;
|
|
44337
|
+
}
|
|
44338
|
+
function blip(freq, durMs, type) {
|
|
44339
|
+
const a2 = audioCtx();
|
|
44340
|
+
if (!a2 || !(tone.vol > 0)) return;
|
|
44341
|
+
const t0 = a2.currentTime;
|
|
44342
|
+
const osc = a2.createOscillator();
|
|
44343
|
+
const g2 = a2.createGain();
|
|
44344
|
+
osc.type = type || tone.type;
|
|
44345
|
+
osc.frequency.setValueAtTime(Math.max(40, freq), t0);
|
|
44346
|
+
const peak = Math.max(1e-4, tone.vol / 100 * 0.32);
|
|
44347
|
+
g2.gain.setValueAtTime(1e-4, t0);
|
|
44348
|
+
g2.gain.exponentialRampToValueAtTime(peak, t0 + 4e-3);
|
|
44349
|
+
g2.gain.exponentialRampToValueAtTime(1e-4, t0 + Math.max(0.02, durMs / 1e3));
|
|
44350
|
+
osc.connect(g2).connect(a2.destination);
|
|
44351
|
+
osc.start(t0);
|
|
44352
|
+
osc.stop(t0 + Math.max(0.02, durMs / 1e3) + 0.03);
|
|
44353
|
+
}
|
|
44354
|
+
var PAUSE_CH = "\uFF0C\u3002\u3001\uFF01\uFF1F\uFF1B\uFF1A\u2026\u2014,.!?;:";
|
|
44315
44355
|
var base = {};
|
|
44316
44356
|
var BASE_IDS = ["ParamMouthForm", "ParamMouthOpenY", "ParamBrowLY", "ParamBrowRY", "ParamBrowLAngle", "ParamBrowRAngle", "ParamEyeLSmile", "ParamEyeRSmile", "ParamEyeLOpen", "ParamEyeROpen", "ParamEyeBallX", "ParamEyeBallY", "ParamAngleX", "ParamAngleY", "ParamAngleZ"];
|
|
44317
44357
|
for (const id of BASE_IDS) base[id] = 0;
|
|
@@ -44358,9 +44398,17 @@ void main(void)\r
|
|
|
44358
44398
|
app.stage.addChild(model);
|
|
44359
44399
|
core = model.internalModel.coreModel;
|
|
44360
44400
|
entryNow = entry;
|
|
44401
|
+
if (entry.voice) Object.assign(tone, entry.voice);
|
|
44361
44402
|
exps = entry.expressions || [];
|
|
44362
44403
|
expParams = /* @__PURE__ */ new Map();
|
|
44363
44404
|
expName = null;
|
|
44405
|
+
try {
|
|
44406
|
+
const defs = model.internalModel && model.internalModel.motionManager && model.internalModel.motionManager.definitions || {};
|
|
44407
|
+
motionGroups = Object.keys(defs).filter((k2) => Array.isArray(defs[k2]) && defs[k2].length);
|
|
44408
|
+
} catch {
|
|
44409
|
+
motionGroups = [];
|
|
44410
|
+
}
|
|
44411
|
+
nextMotionAt = performance.now() + 6e3;
|
|
44364
44412
|
renderExps();
|
|
44365
44413
|
fit();
|
|
44366
44414
|
applyMood("\u5E73\u9759");
|
|
@@ -44440,13 +44488,31 @@ void main(void)\r
|
|
|
44440
44488
|
function say(text, m2) {
|
|
44441
44489
|
if (m2) applyMood(m2);
|
|
44442
44490
|
const bubble = $2("bubble");
|
|
44491
|
+
let s2 = String(text == null ? "" : text).replace(/\s+/g, " ").trim();
|
|
44492
|
+
if (s2.length > 180) s2 = s2.slice(0, 178) + "\u2026";
|
|
44443
44493
|
bubble.textContent = "";
|
|
44494
|
+
bubble.style.opacity = "1";
|
|
44495
|
+
clearTimeout(sayFade);
|
|
44496
|
+
const chars = Array.from(s2);
|
|
44444
44497
|
let i2 = 0;
|
|
44498
|
+
const step = Math.max(20, Number(tone.spd) || 62);
|
|
44445
44499
|
const t2 = setInterval(() => {
|
|
44446
|
-
|
|
44500
|
+
if (i2 >= chars.length) {
|
|
44501
|
+
clearInterval(t2);
|
|
44502
|
+
if (chars.length) blip(tone.base * 0.82, tone.dur * 1.6, tone.type);
|
|
44503
|
+
sayFade = setTimeout(() => {
|
|
44504
|
+
bubble.style.opacity = "0";
|
|
44505
|
+
}, Math.min(14e3, 4e3 + chars.length * 110));
|
|
44506
|
+
return;
|
|
44507
|
+
}
|
|
44508
|
+
const ch = chars[i2++];
|
|
44509
|
+
bubble.textContent += ch;
|
|
44447
44510
|
mouth = 0.14;
|
|
44448
|
-
if (
|
|
44449
|
-
|
|
44511
|
+
if (ch.trim() && PAUSE_CH.indexOf(ch) < 0) {
|
|
44512
|
+
const f2 = tone.base * (1 + (Math.random() - 0.5) * 2 * (Number(tone.jit) / 100));
|
|
44513
|
+
blip(f2, tone.dur, tone.type);
|
|
44514
|
+
}
|
|
44515
|
+
}, step);
|
|
44450
44516
|
}
|
|
44451
44517
|
var idleIdx = 0;
|
|
44452
44518
|
function idleTick() {
|
|
@@ -44458,6 +44524,7 @@ void main(void)\r
|
|
|
44458
44524
|
if (d2.type === "dsh-live2d/manage") $2("drawer").classList.toggle("show");
|
|
44459
44525
|
if (d2.type === "dsh-live2d/say") say(String(d2.text || ""), d2.mood);
|
|
44460
44526
|
if (d2.type === "dsh-live2d/mood") applyMood(String(d2.mood || "\u5E73\u9759"));
|
|
44527
|
+
if (d2.type === "dsh-live2d/tone" && d2.voice && typeof d2.voice === "object") Object.assign(tone, d2.voice);
|
|
44461
44528
|
});
|
|
44462
44529
|
window.__live2d = { say, mood: applyMood, refresh };
|
|
44463
44530
|
$2("import").onclick = () => $2("files").click();
|
|
@@ -44483,17 +44550,33 @@ void main(void)\r
|
|
|
44483
44550
|
}
|
|
44484
44551
|
}
|
|
44485
44552
|
setP("ParamBreath", (Math.sin(t2 * 1.1) + 1) / 2);
|
|
44553
|
+
const gx = Math.max(-1, Math.min(1, gazeX));
|
|
44554
|
+
const gy = Math.max(-1, Math.min(1, gazeY));
|
|
44555
|
+
setP("ParamEyeBallX", gx * 0.9);
|
|
44556
|
+
setP("ParamEyeBallY", -gy * 0.65);
|
|
44486
44557
|
if (!window.__holdHead) {
|
|
44487
|
-
setP("ParamAngleX", Math.sin(t2 * 0.43) * 4);
|
|
44488
|
-
setP("ParamAngleY", Math.sin(t2 * 0.31 + 1) *
|
|
44489
|
-
setP("ParamBodyAngleX", Math.sin(t2 * 0.29) * 2);
|
|
44558
|
+
setP("ParamAngleX", Math.sin(t2 * 0.43) * 1.4 + gx * 7);
|
|
44559
|
+
setP("ParamAngleY", Math.sin(t2 * 0.31 + 1) * 1.2 - gy * 5);
|
|
44560
|
+
setP("ParamBodyAngleX", Math.sin(t2 * 0.29) * 2 + gx * 2);
|
|
44490
44561
|
}
|
|
44562
|
+
maybeMotion();
|
|
44491
44563
|
if (mouth > 0) {
|
|
44492
44564
|
mouth = Math.max(0, mouth - app.ticker.deltaMS / 1e3);
|
|
44493
44565
|
setP("ParamMouthOpenY", (Math.sin(t2 * 18) * 0.5 + 0.5) * 0.26 + 0.02);
|
|
44494
44566
|
}
|
|
44495
44567
|
core.update();
|
|
44496
44568
|
});
|
|
44569
|
+
function maybeMotion() {
|
|
44570
|
+
if (!model || !motionGroups.length) return;
|
|
44571
|
+
const now = performance.now();
|
|
44572
|
+
if (now < nextMotionAt) return;
|
|
44573
|
+
nextMotionAt = now + 12e3 + Math.random() * 16e3;
|
|
44574
|
+
const g2 = motionGroups.indexOf("Idle") >= 0 ? "Idle" : motionGroups[Math.floor(Math.random() * motionGroups.length)];
|
|
44575
|
+
try {
|
|
44576
|
+
model.motion(g2, void 0, 3);
|
|
44577
|
+
} catch {
|
|
44578
|
+
}
|
|
44579
|
+
}
|
|
44497
44580
|
window.addEventListener("resize", () => setTimeout(fit, 60));
|
|
44498
44581
|
refresh(true).then(() => {
|
|
44499
44582
|
if (state.models.length) setTimeout(idleTick, 2500);
|
|
@@ -44562,6 +44645,63 @@ void main(void)\r
|
|
|
44562
44645
|
const d2 = e2.data || {};
|
|
44563
44646
|
if (d2.type === "dsh-live2d/bg") applyBg(String(d2.bg || "none"));
|
|
44564
44647
|
});
|
|
44648
|
+
window.addEventListener("pointermove", (e2) => {
|
|
44649
|
+
if (e2.target && e2.target.id === "bubble") return;
|
|
44650
|
+
gazeX = e2.clientX / Math.max(1, window.innerWidth) * 2 - 1;
|
|
44651
|
+
gazeY = e2.clientY / Math.max(1, window.innerHeight) * 2 - 1;
|
|
44652
|
+
});
|
|
44653
|
+
window.addEventListener("pointerleave", () => {
|
|
44654
|
+
gazeX = 0;
|
|
44655
|
+
gazeY = 0;
|
|
44656
|
+
});
|
|
44657
|
+
var pokeIdx = 0;
|
|
44658
|
+
$2("stage").addEventListener("pointerdown", (e2) => {
|
|
44659
|
+
if (!core) return;
|
|
44660
|
+
audioCtx();
|
|
44661
|
+
const now = Date.now();
|
|
44662
|
+
if (now - pokeAt < 800) return;
|
|
44663
|
+
pokeAt = now;
|
|
44664
|
+
const h2 = Math.max(1, app.renderer.height);
|
|
44665
|
+
const rel = e2.clientY / h2;
|
|
44666
|
+
if (rel < 0.42) say(POKE_HEAD[pokeIdx++ % POKE_HEAD.length], "\u5BB3\u7F9E");
|
|
44667
|
+
else if (rel < 0.75) say(POKE_BODY[pokeIdx++ % POKE_BODY.length], "\u751F\u6C14");
|
|
44668
|
+
else say(POKE_LOW[pokeIdx++ % POKE_LOW.length], "\u5F00\u5FC3");
|
|
44669
|
+
});
|
|
44670
|
+
(function dragBubble() {
|
|
44671
|
+
const b2 = $2("bubble");
|
|
44672
|
+
if (!b2) return;
|
|
44673
|
+
let bx = 0;
|
|
44674
|
+
let by = 0;
|
|
44675
|
+
try {
|
|
44676
|
+
const s2 = JSON.parse(localStorage.getItem("dsh-l2d-bub") || "{}");
|
|
44677
|
+
bx = Number(s2.x) || 0;
|
|
44678
|
+
by = Number(s2.y) || 0;
|
|
44679
|
+
b2.style.transform = "translate(" + bx + "px," + by + "px)";
|
|
44680
|
+
} catch {
|
|
44681
|
+
}
|
|
44682
|
+
b2.addEventListener("pointerdown", (e2) => {
|
|
44683
|
+
e2.preventDefault();
|
|
44684
|
+
const sx = e2.clientX;
|
|
44685
|
+
const sy = e2.clientY;
|
|
44686
|
+
const x0 = bx;
|
|
44687
|
+
const y0 = by;
|
|
44688
|
+
const move = (ev) => {
|
|
44689
|
+
bx = x0 + ev.clientX - sx;
|
|
44690
|
+
by = y0 + ev.clientY - sy;
|
|
44691
|
+
b2.style.transform = "translate(" + bx + "px," + by + "px)";
|
|
44692
|
+
};
|
|
44693
|
+
const up = () => {
|
|
44694
|
+
window.removeEventListener("pointermove", move);
|
|
44695
|
+
window.removeEventListener("pointerup", up);
|
|
44696
|
+
try {
|
|
44697
|
+
localStorage.setItem("dsh-l2d-bub", JSON.stringify({ x: Math.round(bx), y: Math.round(by) }));
|
|
44698
|
+
} catch {
|
|
44699
|
+
}
|
|
44700
|
+
};
|
|
44701
|
+
window.addEventListener("pointermove", move);
|
|
44702
|
+
window.addEventListener("pointerup", up);
|
|
44703
|
+
});
|
|
44704
|
+
})();
|
|
44565
44705
|
})();
|
|
44566
44706
|
/*! Bundled license information:
|
|
44567
44707
|
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>Live2D 电子音样本</title>
|
|
6
|
+
<style>
|
|
7
|
+
:root { --bg:#12141a; --card:#171a20; --line:#2c313a; --txt:#dfe4ec; --dim:#7d858f; --acc:#6aa9ff; }
|
|
8
|
+
* { box-sizing: border-box; }
|
|
9
|
+
body { margin:0; min-height:100vh; background:radial-gradient(1200px 600px at 50% -10%, #1b1f27, var(--bg));
|
|
10
|
+
color:var(--txt); font:13px/1.6 "Microsoft YaHei", system-ui, sans-serif;
|
|
11
|
+
display:flex; align-items:center; justify-content:center; gap:28px; padding:28px; flex-wrap:wrap; }
|
|
12
|
+
.card { width:320px; height:470px; background:var(--card); border:1px solid var(--line); border-radius:14px;
|
|
13
|
+
display:flex; flex-direction:column; overflow:hidden; box-shadow:0 18px 50px rgba(0,0,0,.45); }
|
|
14
|
+
.head { display:flex; align-items:center; gap:6px; padding:7px 10px; font-size:11px; letter-spacing:.5px;
|
|
15
|
+
border-bottom:1px solid var(--line); color:#c6ccd6; }
|
|
16
|
+
.dot { width:6px; height:6px; border-radius:50%; background:var(--acc); }
|
|
17
|
+
.head .sp { margin-left:auto; color:var(--dim); }
|
|
18
|
+
.body { flex:1; position:relative; display:flex; align-items:flex-end; justify-content:center;
|
|
19
|
+
background:linear-gradient(180deg, #1b1f27 0%, #14171d 100%); overflow:hidden; }
|
|
20
|
+
.body svg.sil { width:100%; height:100%; display:block; }
|
|
21
|
+
.bubble { min-height:34px; margin:0 10px 10px; padding:8px 11px; border-radius:10px;
|
|
22
|
+
background:rgba(24,27,33,.96); border:1px solid #3a4049; font-size:13px; line-height:1.5;
|
|
23
|
+
color:var(--txt); }
|
|
24
|
+
.bubble .ch { display:inline-block; animation:pop .12s ease-out; }
|
|
25
|
+
@keyframes pop { from { transform:translateY(3px) scale(.86); opacity:.2 } to { transform:none; opacity:1 } }
|
|
26
|
+
.bubble.idle::after { content:"按住麦克风说话,或者直接打字。"; color:var(--dim); }
|
|
27
|
+
.panel { width:330px; }
|
|
28
|
+
.panel h1 { font-size:15px; margin:0 0 4px; font-weight:600; }
|
|
29
|
+
.panel p.sub { margin:0 0 16px; color:var(--dim); font-size:12px; }
|
|
30
|
+
.group { border:1px solid var(--line); border-radius:12px; padding:12px; margin-bottom:12px; background:#15181e; }
|
|
31
|
+
.group h2 { font-size:11px; letter-spacing:.6px; color:var(--dim); margin:0 0 10px; font-weight:600; }
|
|
32
|
+
.presets { display:flex; gap:8px; }
|
|
33
|
+
.presets button { flex:1; padding:8px 0; border-radius:9px; border:1px solid #3c424c; background:#232830;
|
|
34
|
+
color:#cfd4dd; cursor:pointer; font:inherit; font-size:12px; }
|
|
35
|
+
.presets button.on { border-color:var(--acc); background:#1e2c40; color:#eaf2ff; }
|
|
36
|
+
.row { display:flex; align-items:center; gap:10px; margin:9px 0; font-size:12px; color:#b9c0cb; }
|
|
37
|
+
.row span.lab { width:64px; flex:none; color:var(--dim); }
|
|
38
|
+
.row input[type=range] { flex:1; accent-color:var(--acc); }
|
|
39
|
+
.row b { width:44px; text-align:right; font-weight:500; color:#dfe4ec; font-variant-numeric:tabular-nums; }
|
|
40
|
+
.actions { display:flex; gap:8px; }
|
|
41
|
+
.actions button { flex:1; padding:10px 0; border-radius:10px; cursor:pointer; font:inherit; font-size:13px;
|
|
42
|
+
border:1px solid #3c424c; background:#232830; color:#dfe4ec; }
|
|
43
|
+
.actions button.primary { background:#27405f; border-color:#3e6b9e; color:#eaf2ff; }
|
|
44
|
+
.actions button:hover { filter:brightness(1.12); }
|
|
45
|
+
.typebox { display:flex; gap:8px; margin-top:10px; }
|
|
46
|
+
.typebox input { flex:1; padding:9px 11px; border-radius:9px; border:1px solid #3c424c; background:#181c22;
|
|
47
|
+
color:var(--txt); font:inherit; font-size:12px; }
|
|
48
|
+
.typebox button { padding:9px 14px; border-radius:9px; border:1px solid #3c424c; background:#232830;
|
|
49
|
+
color:#dfe4ec; cursor:pointer; font:inherit; font-size:12px; }
|
|
50
|
+
.tip { color:#6f7783; font-size:11px; margin-top:10px; line-height:1.6; }
|
|
51
|
+
</style>
|
|
52
|
+
</head>
|
|
53
|
+
<body>
|
|
54
|
+
|
|
55
|
+
<div class="card">
|
|
56
|
+
<div class="head"><span class="dot"></span><span>若妍</span><span class="sp">◐ ↻ ⌄</span></div>
|
|
57
|
+
<div class="body">
|
|
58
|
+
<svg class="sil" viewBox="0 0 200 300" preserveAspectRatio="xMidYMax meet" aria-hidden="true">
|
|
59
|
+
<defs>
|
|
60
|
+
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
|
|
61
|
+
<stop offset="0" stop-color="#3d4552" />
|
|
62
|
+
<stop offset="1" stop-color="#20242b" />
|
|
63
|
+
</linearGradient>
|
|
64
|
+
</defs>
|
|
65
|
+
<ellipse cx="100" cy="282" rx="84" ry="30" fill="#1b1f26" />
|
|
66
|
+
<path d="M100 48c25 0 41 18 41 43 0 16-6 28-13 35l6 33c21 8 39 24 45 47l6 44H15l6-44c6-23 24-39 45-47l6-33c-7-7-13-19-13-35 0-25 16-43 41-43z" fill="url(#g)" />
|
|
67
|
+
<path d="M59 91c0-25 17-43 41-43s41 18 41 43c0 6-1 11-3 15-6-15-21-25-38-25s-32 10-38 25c-2-4-3-9-3-15z" fill="#4b5462" />
|
|
68
|
+
</svg>
|
|
69
|
+
</div>
|
|
70
|
+
<div class="bubble idle" id="bubble"></div>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<div class="panel">
|
|
74
|
+
<h1>电子音样本</h1>
|
|
75
|
+
<p class="sub">不读字,只发短促的“哔”。每出一个字响一下,音高随机抖一点,标点停一拍。</p>
|
|
76
|
+
|
|
77
|
+
<div class="group">
|
|
78
|
+
<h2>音色</h2>
|
|
79
|
+
<div class="presets" id="presets">
|
|
80
|
+
<button data-p="soft" class="on">柔和</button>
|
|
81
|
+
<button data-p="crisp">清脆</button>
|
|
82
|
+
<button data-p="hard">机械</button>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<div class="group">
|
|
87
|
+
<h2>参数</h2>
|
|
88
|
+
<div class="row"><span class="lab">基频</span><input id="base" type="range" min="180" max="720" value="430" /><b id="baseV">430</b></div>
|
|
89
|
+
<div class="row"><span class="lab">抖动</span><input id="jit" type="range" min="0" max="40" value="12" /><b id="jitV">12%</b></div>
|
|
90
|
+
<div class="row"><span class="lab">长度</span><input id="dur" type="range" min="20" max="110" value="46" /><b id="durV">46</b></div>
|
|
91
|
+
<div class="row"><span class="lab">音量</span><input id="vol" type="range" min="0" max="100" value="35" /><b id="volV">35</b></div>
|
|
92
|
+
<div class="row"><span class="lab">语速</span><input id="spd" type="range" min="28" max="110" value="62" /><b id="spdV">62</b></div>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div class="actions">
|
|
96
|
+
<button class="primary" id="say">说一句</button>
|
|
97
|
+
<button id="mic">按住说话</button>
|
|
98
|
+
</div>
|
|
99
|
+
<div class="typebox">
|
|
100
|
+
<input id="typed" placeholder="手动打一句,回车" />
|
|
101
|
+
<button id="send">发</button>
|
|
102
|
+
</div>
|
|
103
|
+
<div class="tip">按住“按住说话”会假装在听,松手就把转写的话说出来——对应 dsh-ears 那条路。<br />声音由 Web Audio 现场合成,不加载任何音频文件。</div>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<script>
|
|
107
|
+
(function () {
|
|
108
|
+
var ac = null;
|
|
109
|
+
var bubble = document.getElementById('bubble');
|
|
110
|
+
var timer = null;
|
|
111
|
+
var line = 0;
|
|
112
|
+
|
|
113
|
+
var LINES = [
|
|
114
|
+
'你回来了。锅里还有。',
|
|
115
|
+
'猫又上桌了,我把它抱下来三次。',
|
|
116
|
+
'今天换到一小袋糖,给你留了半袋。',
|
|
117
|
+
'窗外没动静。风有点大。'
|
|
118
|
+
];
|
|
119
|
+
var MIC = [
|
|
120
|
+
'我说,你今天是不是又没吃饭。',
|
|
121
|
+
'听见了。别站窗边,风硬。'
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
var cfg = { type: 'triangle', base: 430, jit: 12, dur: 46, vol: 35, spd: 62, slant: 0 };
|
|
125
|
+
|
|
126
|
+
var PRESETS = {
|
|
127
|
+
soft: { type: 'triangle', base: 400, jit: 10, dur: 60, vol: 32 },
|
|
128
|
+
crisp: { type: 'square', base: 560, jit: 8, dur: 34, vol: 22 },
|
|
129
|
+
hard: { type: 'sawtooth', base: 300, jit: 22, dur: 44, vol: 20 }
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
function audio() {
|
|
133
|
+
if (!ac) ac = new (window.AudioContext || window.webkitAudioContext)();
|
|
134
|
+
if (ac.state === 'suspended') ac.resume();
|
|
135
|
+
return ac;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function blip(freq, durMs, type) {
|
|
139
|
+
var a = audio();
|
|
140
|
+
var t0 = a.currentTime;
|
|
141
|
+
var osc = a.createOscillator();
|
|
142
|
+
var gain = a.createGain();
|
|
143
|
+
osc.type = type;
|
|
144
|
+
osc.frequency.setValueAtTime(freq, t0);
|
|
145
|
+
var peak = Math.max(0.0001, cfg.vol / 100);
|
|
146
|
+
gain.gain.setValueAtTime(0.0001, t0);
|
|
147
|
+
gain.gain.exponentialRampToValueAtTime(peak, t0 + 0.004);
|
|
148
|
+
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + durMs / 1000);
|
|
149
|
+
osc.connect(gain).connect(a.destination);
|
|
150
|
+
osc.start(t0);
|
|
151
|
+
osc.stop(t0 + durMs / 1000 + 0.03);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isPause(ch) {
|
|
155
|
+
return ',。、!?;:…,.!?;:'.indexOf(ch) >= 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function speak(text) {
|
|
159
|
+
clearInterval(timer);
|
|
160
|
+
bubble.classList.remove('idle');
|
|
161
|
+
bubble.innerHTML = '';
|
|
162
|
+
var chars = Array.from(String(text));
|
|
163
|
+
var i = 0;
|
|
164
|
+
var jitter = cfg.jit / 100;
|
|
165
|
+
timer = setInterval(function () {
|
|
166
|
+
if (i >= chars.length) {
|
|
167
|
+
clearInterval(timer);
|
|
168
|
+
if (chars.length) {
|
|
169
|
+
blip(cfg.base * 0.82, cfg.dur * 1.6, cfg.type);
|
|
170
|
+
}
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
var ch = chars[i++];
|
|
174
|
+
var el = document.createElement('span');
|
|
175
|
+
el.className = 'ch';
|
|
176
|
+
el.textContent = ch;
|
|
177
|
+
bubble.appendChild(el);
|
|
178
|
+
if (ch.trim() && !isPause(ch)) {
|
|
179
|
+
var f = cfg.base * (1 + (Math.random() - 0.5) * 2 * jitter);
|
|
180
|
+
blip(f, cfg.dur, cfg.type);
|
|
181
|
+
}
|
|
182
|
+
}, cfg.spd);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function bind(id, key, fmt) {
|
|
186
|
+
var el = document.getElementById(id);
|
|
187
|
+
var out = document.getElementById(id + 'V');
|
|
188
|
+
el.addEventListener('input', function () {
|
|
189
|
+
cfg[key] = Number(el.value);
|
|
190
|
+
out.textContent = fmt ? fmt(cfg[key]) : cfg[key];
|
|
191
|
+
});
|
|
192
|
+
out.textContent = fmt ? fmt(cfg[key]) : cfg[key];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
bind('base', 'base');
|
|
196
|
+
bind('jit', 'jit', function (v) { return v + '%'; });
|
|
197
|
+
bind('dur', 'dur');
|
|
198
|
+
bind('vol', 'vol');
|
|
199
|
+
bind('spd', 'spd');
|
|
200
|
+
|
|
201
|
+
var presetBtns = [].slice.call(document.querySelectorAll('#presets button'));
|
|
202
|
+
presetBtns.forEach(function (b) {
|
|
203
|
+
b.addEventListener('click', function () {
|
|
204
|
+
presetBtns.forEach(function (x) { x.classList.remove('on'); });
|
|
205
|
+
b.classList.add('on');
|
|
206
|
+
var p = PRESETS[b.dataset.p];
|
|
207
|
+
Object.keys(p).forEach(function (k) { cfg[k] = p[k]; });
|
|
208
|
+
document.getElementById('base').value = cfg.base;
|
|
209
|
+
document.getElementById('baseV').textContent = cfg.base;
|
|
210
|
+
document.getElementById('jit').value = cfg.jit;
|
|
211
|
+
document.getElementById('jitV').textContent = cfg.jit + '%';
|
|
212
|
+
document.getElementById('dur').value = cfg.dur;
|
|
213
|
+
document.getElementById('durV').textContent = cfg.dur;
|
|
214
|
+
document.getElementById('vol').value = cfg.vol;
|
|
215
|
+
document.getElementById('volV').textContent = cfg.vol;
|
|
216
|
+
speak(LINES[line++ % LINES.length]);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
document.getElementById('say').addEventListener('click', function () {
|
|
221
|
+
speak(LINES[line++ % LINES.length]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
var micBtn = document.getElementById('mic');
|
|
225
|
+
var micAt = 0;
|
|
226
|
+
micBtn.addEventListener('pointerdown', function () {
|
|
227
|
+
micAt = Date.now();
|
|
228
|
+
micBtn.textContent = '在听…';
|
|
229
|
+
bubble.classList.remove('idle');
|
|
230
|
+
bubble.textContent = '……';
|
|
231
|
+
audio();
|
|
232
|
+
});
|
|
233
|
+
micBtn.addEventListener('pointerup', function () {
|
|
234
|
+
micBtn.textContent = '按住说话';
|
|
235
|
+
var held = Date.now() - micAt;
|
|
236
|
+
var text = held < 350 ? '没听清,再说一遍。' : MIC[(Math.random() * MIC.length) | 0];
|
|
237
|
+
speak(text);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
function sendTyped() {
|
|
241
|
+
var box = document.getElementById('typed');
|
|
242
|
+
var t = box.value.trim();
|
|
243
|
+
if (!t) return;
|
|
244
|
+
box.value = '';
|
|
245
|
+
speak(t);
|
|
246
|
+
}
|
|
247
|
+
document.getElementById('send').addEventListener('click', sendTyped);
|
|
248
|
+
document.getElementById('typed').addEventListener('keydown', function (e) {
|
|
249
|
+
if (e.key === 'Enter') sendTyped();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
setInterval(function () {
|
|
253
|
+
if (document.hidden) return;
|
|
254
|
+
if (cfg.vol > 0 && !timer) return;
|
|
255
|
+
}, 1000);
|
|
256
|
+
})();
|
|
257
|
+
</script>
|
|
258
|
+
</body>
|
|
259
|
+
</html>
|
package/lib/client.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
display:flex;flex-direction:column}
|
|
18
18
|
.dsh-l2d-card.collapsed{width:146px!important;height:36px!important}
|
|
19
19
|
.dsh-l2d-head{display:flex;align-items:center;gap:6px;padding:8px 10px;color:#9aa3b0;font-size:11px;
|
|
20
|
-
letter-spacing:.5px;border-bottom:1px solid #2c313a;user-select:none;flex:none}
|
|
20
|
+
letter-spacing:.5px;border-bottom:1px solid #2c313a;user-select:none;flex:none;cursor:move}
|
|
21
21
|
.dsh-l2d-dot{width:6px;height:6px;border-radius:50%;background:#6aa9ff;flex:none}
|
|
22
22
|
.dsh-l2d-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
23
23
|
.dsh-l2d-btns{margin-left:auto;display:flex;gap:6px;flex:none}
|
|
@@ -46,17 +46,31 @@
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
function readPos() {
|
|
50
|
+
try {
|
|
51
|
+
const p = JSON.parse(localStorage.getItem('dsh-l2d-pos') || '{}');
|
|
52
|
+
return { x: Number(p.x) || 0, y: Number(p.y) || 0 };
|
|
53
|
+
} catch {
|
|
54
|
+
return { x: 0, y: 0 };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
49
58
|
function Panel() {
|
|
50
59
|
const [open, setOpen] = react.useState(() => localStorage.getItem('dsh-l2d-open') !== '0');
|
|
51
60
|
const [title, setTitle] = react.useState('Live2D');
|
|
52
61
|
const [bg, setBg] = react.useState(() => localStorage.getItem('dsh-l2d-bg') || 'none');
|
|
53
62
|
const [size, setSize] = react.useState(readSize);
|
|
63
|
+
const [pos, setPos] = react.useState(readPos);
|
|
54
64
|
const [nonce, setNonce] = react.useState(0);
|
|
55
65
|
const ref = react.useRef(null);
|
|
56
66
|
|
|
57
67
|
react.useEffect(() => localStorage.setItem('dsh-l2d-open', open ? '1' : '0'), [open]);
|
|
58
68
|
react.useEffect(() => localStorage.setItem('dsh-l2d-bg', bg), [bg]);
|
|
59
69
|
react.useEffect(() => localStorage.setItem('dsh-l2d-size', JSON.stringify(size)), [size]);
|
|
70
|
+
react.useEffect(() => localStorage.setItem('dsh-l2d-pos', JSON.stringify(pos)), [pos]);
|
|
71
|
+
react.useEffect(() => {
|
|
72
|
+
frameWin = (ref.current && ref.current.contentWindow) || null;
|
|
73
|
+
}, [nonce, open]);
|
|
60
74
|
react.useEffect(() => {
|
|
61
75
|
if (ref.current && ref.current.contentWindow) {
|
|
62
76
|
ref.current.contentWindow.postMessage({ type: 'dsh-live2d/bg', bg }, '*');
|
|
@@ -92,26 +106,50 @@
|
|
|
92
106
|
window.addEventListener('pointerup', up);
|
|
93
107
|
};
|
|
94
108
|
|
|
109
|
+
const startMove = (e) => {
|
|
110
|
+
if (e.button !== undefined && e.button !== 0) return;
|
|
111
|
+
const sx = e.clientX;
|
|
112
|
+
const sy = e.clientY;
|
|
113
|
+
const x0 = pos.x;
|
|
114
|
+
const y0 = pos.y;
|
|
115
|
+
const move = (ev) => {
|
|
116
|
+
const nx = x0 + (ev.clientX - sx);
|
|
117
|
+
const ny = y0 + (ev.clientY - sy);
|
|
118
|
+
setPos({ x: Math.round(nx), y: Math.round(ny) });
|
|
119
|
+
};
|
|
120
|
+
const up = () => {
|
|
121
|
+
window.removeEventListener('pointermove', move);
|
|
122
|
+
window.removeEventListener('pointerup', up);
|
|
123
|
+
};
|
|
124
|
+
window.addEventListener('pointermove', move);
|
|
125
|
+
window.addEventListener('pointerup', up);
|
|
126
|
+
};
|
|
127
|
+
const noDrag = (e) => e.stopPropagation();
|
|
128
|
+
|
|
95
129
|
const manage = () => ref.current && ref.current.contentWindow.postMessage({ type: 'dsh-live2d/manage' }, '*');
|
|
130
|
+
const openTone = () => window.open(BASE + '/tone', '_blank', 'width=1140,height=780');
|
|
96
131
|
const cycleBg = () => setBg(BGS[(BGS.indexOf(bg) + 1) % BGS.length]);
|
|
97
132
|
|
|
98
|
-
const cardStyle = open
|
|
133
|
+
const cardStyle = open
|
|
134
|
+
? { width: size.w + 'px', height: size.h + 'px', transform: 'translate(' + pos.x + 'px,' + pos.y + 'px)' }
|
|
135
|
+
: { transform: 'translate(' + pos.x + 'px,' + pos.y + 'px)' };
|
|
99
136
|
|
|
100
137
|
return h(
|
|
101
138
|
'div',
|
|
102
139
|
{ className: 'dsh-l2d-card' + (open ? '' : ' collapsed'), style: cardStyle },
|
|
103
140
|
h(
|
|
104
141
|
'div',
|
|
105
|
-
{ className: 'dsh-l2d-head' },
|
|
142
|
+
{ className: 'dsh-l2d-head', onPointerDown: startMove },
|
|
106
143
|
h('span', { className: 'dsh-l2d-dot' }),
|
|
107
144
|
h('span', { className: 'dsh-l2d-name' }, title),
|
|
108
145
|
h(
|
|
109
146
|
'span',
|
|
110
147
|
{ className: 'dsh-l2d-btns' },
|
|
111
|
-
open && h('button', { title: '模型管理', onClick: manage }, '⚙'),
|
|
112
|
-
open && h('button', { title: '背景:' + BG_LABEL[bg], onClick: cycleBg }, '◐'),
|
|
113
|
-
open && h('button', { title: '
|
|
114
|
-
h('button', { title:
|
|
148
|
+
open && h('button', { title: '模型管理', onPointerDown: noDrag, onClick: manage }, '⚙'),
|
|
149
|
+
open && h('button', { title: '背景:' + BG_LABEL[bg], onPointerDown: noDrag, onClick: cycleBg }, '◐'),
|
|
150
|
+
open && h('button', { title: '音色', onPointerDown: noDrag, onClick: openTone }, '♪'),
|
|
151
|
+
open && h('button', { title: '刷新', onPointerDown: noDrag, onClick: () => setNonce((n) => n + 1) }, '↻'),
|
|
152
|
+
h('button', { title: open ? '收起' : '展开', onPointerDown: noDrag, onClick: () => setOpen(!open) }, open ? '⌄' : '⌃'),
|
|
115
153
|
),
|
|
116
154
|
),
|
|
117
155
|
open
|
|
@@ -121,17 +159,85 @@
|
|
|
121
159
|
className: 'dsh-l2d-body',
|
|
122
160
|
src: BASE + '/viewer?embed=1&bg=' + encodeURIComponent(bg),
|
|
123
161
|
allow: 'autoplay',
|
|
162
|
+
onLoad: () => {
|
|
163
|
+
frameWin = (ref.current && ref.current.contentWindow) || null;
|
|
164
|
+
},
|
|
124
165
|
})
|
|
125
166
|
: null,
|
|
126
167
|
open ? h('div', { className: 'dsh-l2d-grip', onPointerDown: startDrag, title: '拖动改大小' }) : null,
|
|
127
168
|
);
|
|
128
169
|
}
|
|
129
170
|
|
|
171
|
+
let frameWin = null;
|
|
172
|
+
let lastSaid = '';
|
|
173
|
+
let lastSaidAt = 0;
|
|
174
|
+
|
|
175
|
+
const MOOD_HINTS = [
|
|
176
|
+
['生气', ['气', '烦', '讨厌', '凭什么']],
|
|
177
|
+
['委屈', ['委屈', '难过', '对不起', '哭']],
|
|
178
|
+
['害羞', ['唔', '别看', '害羞', '脸红']],
|
|
179
|
+
['开心', ['谢谢', '喜欢', '开心', '好耶']],
|
|
180
|
+
['困', ['困', '睡', '熬夜']],
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
function guessMood(text) {
|
|
184
|
+
for (let i = 0; i < MOOD_HINTS.length; i++) {
|
|
185
|
+
const words = MOOD_HINTS[i][1];
|
|
186
|
+
for (let j = 0; j < words.length; j++) {
|
|
187
|
+
if (text.indexOf(words[j]) >= 0) return MOOD_HINTS[i][0];
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return '平静';
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function pushSay(text) {
|
|
194
|
+
const t = String(text || '').replace(/\s+/g, ' ').trim();
|
|
195
|
+
if (!t || t === lastSaid) return;
|
|
196
|
+
if (Date.now() - lastSaidAt < 700) return;
|
|
197
|
+
lastSaid = t;
|
|
198
|
+
lastSaidAt = Date.now();
|
|
199
|
+
if (!frameWin) return;
|
|
200
|
+
try {
|
|
201
|
+
frameWin.postMessage({ type: 'dsh-live2d/say', text: t, mood: guessMood(t) }, '*');
|
|
202
|
+
} catch {}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function ChatProbe(props) {
|
|
206
|
+
const anchor = react.useRef(null);
|
|
207
|
+
const role =
|
|
208
|
+
(props && (props.role || props.kind)) ||
|
|
209
|
+
(props && props.node && (props.node.role || props.node.kind)) ||
|
|
210
|
+
(props && props.message && props.message.role) ||
|
|
211
|
+
'';
|
|
212
|
+
const skip = /user|human|prompt/i.test(String(role));
|
|
213
|
+
react.useEffect(() => {
|
|
214
|
+
if (skip) return;
|
|
215
|
+
const el = anchor.current;
|
|
216
|
+
if (!el) return;
|
|
217
|
+
let node = el.parentElement;
|
|
218
|
+
let text = '';
|
|
219
|
+
for (let i = 0; i < 5 && node; i++) {
|
|
220
|
+
const t = (node.textContent || '').trim();
|
|
221
|
+
if (t.length > 3 && t.length < 900) {
|
|
222
|
+
text = t;
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
node = node.parentElement;
|
|
226
|
+
}
|
|
227
|
+
if (text) pushSay(text);
|
|
228
|
+
});
|
|
229
|
+
if (skip) return null;
|
|
230
|
+
return h('i', { ref: anchor, style: { display: 'none' } });
|
|
231
|
+
}
|
|
232
|
+
|
|
130
233
|
function apply(ctx) {
|
|
131
234
|
ensureCss();
|
|
132
235
|
ctx.slots.inject('shell.overlay', function* () {
|
|
133
236
|
yield ctx.slots.register({ name: 'shell.overlay', id: 'live2d', order: 900 }, Panel);
|
|
134
237
|
});
|
|
238
|
+
ctx.slots.inject('conversation.chat.node', function* () {
|
|
239
|
+
yield ctx.slots.register({ name: 'conversation.chat.node', id: 'live2d-say', order: 900 }, ChatProbe);
|
|
240
|
+
});
|
|
135
241
|
}
|
|
136
242
|
|
|
137
243
|
return { apply, inject, name };
|
package/lib/index.js
CHANGED
|
@@ -10,6 +10,9 @@ const MODELS = join(ROOT, 'models');
|
|
|
10
10
|
const CONFIG = join(ROOT, 'config.json');
|
|
11
11
|
const PREFIX = '/dsh-live2d';
|
|
12
12
|
|
|
13
|
+
/** 默认音色:一个字一声短音 */
|
|
14
|
+
const DEFAULT_VOICE = { preset: 'soft', type: 'triangle', base: 400, jit: 10, dur: 60, vol: 32, spd: 62 };
|
|
15
|
+
|
|
13
16
|
const MIME = {
|
|
14
17
|
'.html': 'text/html; charset=utf-8',
|
|
15
18
|
'.js': 'text/javascript; charset=utf-8',
|
|
@@ -101,7 +104,13 @@ function listModels() {
|
|
|
101
104
|
const meta = readMeta(e.name);
|
|
102
105
|
const model3 = meta?.model3 ?? findModel3(dir);
|
|
103
106
|
if (!model3) continue;
|
|
104
|
-
list.push({
|
|
107
|
+
list.push({
|
|
108
|
+
id: e.name,
|
|
109
|
+
name: meta?.name ?? e.name,
|
|
110
|
+
model3,
|
|
111
|
+
expressions: meta?.expressions ?? [],
|
|
112
|
+
voice: { ...DEFAULT_VOICE, ...(meta?.voice || {}) },
|
|
113
|
+
});
|
|
105
114
|
}
|
|
106
115
|
return list;
|
|
107
116
|
}
|
|
@@ -168,7 +177,37 @@ async function handle(req, res) {
|
|
|
168
177
|
return file ? sendFile(res, file) : send(res, 403, 'forbidden');
|
|
169
178
|
}
|
|
170
179
|
|
|
180
|
+
// 音色调音页
|
|
181
|
+
if (path === '/tone' || path === '/tone.html') {
|
|
182
|
+
return sendFile(res, join(ASSETS, 'tone.html'), MIME['.html']);
|
|
183
|
+
}
|
|
184
|
+
|
|
171
185
|
// API
|
|
186
|
+
if (path === '/api/voice' && method === 'GET') {
|
|
187
|
+
const id = String(url.searchParams.get('id') ?? '');
|
|
188
|
+
const meta = id ? readMeta(id) : null;
|
|
189
|
+
return sendJson(res, 200, { voice: { ...DEFAULT_VOICE, ...(meta?.voice || {}) }, id });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (path === '/api/voice' && method === 'POST') {
|
|
193
|
+
const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
|
|
194
|
+
const id = String(body.id ?? '');
|
|
195
|
+
const meta = id ? readMeta(id) : null;
|
|
196
|
+
if (!meta) return sendJson(res, 404, { error: 'model not found' });
|
|
197
|
+
const v = body.voice && typeof body.voice === 'object' ? body.voice : {};
|
|
198
|
+
const voice = {
|
|
199
|
+
preset: String(v.preset || 'soft'),
|
|
200
|
+
type: ['triangle', 'square', 'sawtooth', 'sine'].includes(v.type) ? v.type : 'triangle',
|
|
201
|
+
base: Math.max(80, Math.min(1200, Number(v.base) || DEFAULT_VOICE.base)),
|
|
202
|
+
jit: Math.max(0, Math.min(50, Number(v.jit) || 0)),
|
|
203
|
+
dur: Math.max(10, Math.min(200, Number(v.dur) || DEFAULT_VOICE.dur)),
|
|
204
|
+
vol: Math.max(0, Math.min(100, Number(v.vol) || 0)),
|
|
205
|
+
spd: Math.max(20, Math.min(200, Number(v.spd) || DEFAULT_VOICE.spd)),
|
|
206
|
+
};
|
|
207
|
+
writeFileSync(join(MODELS, id, 'meta.json'), JSON.stringify({ ...meta, voice }, null, 2), 'utf8');
|
|
208
|
+
return sendJson(res, 200, { ok: true, voice });
|
|
209
|
+
}
|
|
210
|
+
|
|
172
211
|
if (path === '/api/models' && method === 'GET') {
|
|
173
212
|
const cfg = readConfig();
|
|
174
213
|
return sendJson(res, 200, { models: listModels(), selected: cfg.selected ?? null });
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-live2dwaifu",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "给 DSH 网页右下角请一个 Live2D
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "给 DSH 网页右下角请一个 Live2D 角色:会眨眼、会呼吸、视线跟着鼠标、点她有反应、说话时嘴动、心情变了眉毛跟着动。模型不用内置——谁装谁放自己的文件夹;支持自定义 .exp3.json 表情和 .motion3.json 动作,卡片能拖能缩,背景可换。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|