dsh-live2dwaifu 0.2.0 → 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 +2 -0
- package/assets/tone.html +256 -0
- package/assets/viewer.js +48 -5
- package/docs/blip-sample.html +259 -0
- package/lib/client.js +2 -0
- package/lib/index.js +40 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
|
|
22
22
|
**会自己动**:模型里带 `.motion3.json` 的,隔十几秒自己播一个动作。气泡能拖着挪地方,说完过一阵自己淡下去。
|
|
23
23
|
|
|
24
|
+
**会出声**:一个字一声很短的电音,标点停一拍,句尾沉一下——不是朗读,是“哔哔”那种。标题栏的 **♪** 打开调音页,放个真模型在旁边,边调边听;每只模型各存一份音色,换人换声。音量拉到 0 就安静,只剩气泡和口型。
|
|
25
|
+
|
|
24
26
|
**跟着聊**:她挂在聊天流上(`conversation.chat.node` 插槽),助手每说一句她就念一句,情绪按话里的词换。
|
|
25
27
|
|
|
26
28
|
## 三十秒上手
|
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.js
CHANGED
|
@@ -44321,6 +44321,37 @@ void main(void)\r
|
|
|
44321
44321
|
var POKE_HEAD = ["\u5514\u3002", "\u522B\u6478\u3002", "\u5934\u53D1\u4E71\u4E86\u3002", "\u55EF\u2026\u2026", "\u4F60\u624B\u51C9\u3002"];
|
|
44322
44322
|
var POKE_BODY = ["\u770B\u4EC0\u4E48\u770B\u3002", "\u6233\u4EC0\u4E48\u3002", "\u54FC\u3002", "\u522B\u95F9\u3002"];
|
|
44323
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,.!?;:";
|
|
44324
44355
|
var base = {};
|
|
44325
44356
|
var BASE_IDS = ["ParamMouthForm", "ParamMouthOpenY", "ParamBrowLY", "ParamBrowRY", "ParamBrowLAngle", "ParamBrowRAngle", "ParamEyeLSmile", "ParamEyeRSmile", "ParamEyeLOpen", "ParamEyeROpen", "ParamEyeBallX", "ParamEyeBallY", "ParamAngleX", "ParamAngleY", "ParamAngleZ"];
|
|
44326
44357
|
for (const id of BASE_IDS) base[id] = 0;
|
|
@@ -44367,6 +44398,7 @@ void main(void)\r
|
|
|
44367
44398
|
app.stage.addChild(model);
|
|
44368
44399
|
core = model.internalModel.coreModel;
|
|
44369
44400
|
entryNow = entry;
|
|
44401
|
+
if (entry.voice) Object.assign(tone, entry.voice);
|
|
44370
44402
|
exps = entry.expressions || [];
|
|
44371
44403
|
expParams = /* @__PURE__ */ new Map();
|
|
44372
44404
|
expName = null;
|
|
@@ -44461,17 +44493,26 @@ void main(void)\r
|
|
|
44461
44493
|
bubble.textContent = "";
|
|
44462
44494
|
bubble.style.opacity = "1";
|
|
44463
44495
|
clearTimeout(sayFade);
|
|
44496
|
+
const chars = Array.from(s2);
|
|
44464
44497
|
let i2 = 0;
|
|
44498
|
+
const step = Math.max(20, Number(tone.spd) || 62);
|
|
44465
44499
|
const t2 = setInterval(() => {
|
|
44466
|
-
|
|
44467
|
-
mouth = 0.14;
|
|
44468
|
-
if (i2 >= s2.length) {
|
|
44500
|
+
if (i2 >= chars.length) {
|
|
44469
44501
|
clearInterval(t2);
|
|
44502
|
+
if (chars.length) blip(tone.base * 0.82, tone.dur * 1.6, tone.type);
|
|
44470
44503
|
sayFade = setTimeout(() => {
|
|
44471
44504
|
bubble.style.opacity = "0";
|
|
44472
|
-
}, Math.min(14e3, 4e3 +
|
|
44505
|
+
}, Math.min(14e3, 4e3 + chars.length * 110));
|
|
44506
|
+
return;
|
|
44507
|
+
}
|
|
44508
|
+
const ch = chars[i2++];
|
|
44509
|
+
bubble.textContent += ch;
|
|
44510
|
+
mouth = 0.14;
|
|
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);
|
|
44473
44514
|
}
|
|
44474
|
-
},
|
|
44515
|
+
}, step);
|
|
44475
44516
|
}
|
|
44476
44517
|
var idleIdx = 0;
|
|
44477
44518
|
function idleTick() {
|
|
@@ -44483,6 +44524,7 @@ void main(void)\r
|
|
|
44483
44524
|
if (d2.type === "dsh-live2d/manage") $2("drawer").classList.toggle("show");
|
|
44484
44525
|
if (d2.type === "dsh-live2d/say") say(String(d2.text || ""), d2.mood);
|
|
44485
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);
|
|
44486
44528
|
});
|
|
44487
44529
|
window.__live2d = { say, mood: applyMood, refresh };
|
|
44488
44530
|
$2("import").onclick = () => $2("files").click();
|
|
@@ -44615,6 +44657,7 @@ void main(void)\r
|
|
|
44615
44657
|
var pokeIdx = 0;
|
|
44616
44658
|
$2("stage").addEventListener("pointerdown", (e2) => {
|
|
44617
44659
|
if (!core) return;
|
|
44660
|
+
audioCtx();
|
|
44618
44661
|
const now = Date.now();
|
|
44619
44662
|
if (now - pokeAt < 800) return;
|
|
44620
44663
|
pokeAt = now;
|
|
@@ -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
|
@@ -127,6 +127,7 @@
|
|
|
127
127
|
const noDrag = (e) => e.stopPropagation();
|
|
128
128
|
|
|
129
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');
|
|
130
131
|
const cycleBg = () => setBg(BGS[(BGS.indexOf(bg) + 1) % BGS.length]);
|
|
131
132
|
|
|
132
133
|
const cardStyle = open
|
|
@@ -146,6 +147,7 @@
|
|
|
146
147
|
{ className: 'dsh-l2d-btns' },
|
|
147
148
|
open && h('button', { title: '模型管理', onPointerDown: noDrag, onClick: manage }, '⚙'),
|
|
148
149
|
open && h('button', { title: '背景:' + BG_LABEL[bg], onPointerDown: noDrag, onClick: cycleBg }, '◐'),
|
|
150
|
+
open && h('button', { title: '音色', onPointerDown: noDrag, onClick: openTone }, '♪'),
|
|
149
151
|
open && h('button', { title: '刷新', onPointerDown: noDrag, onClick: () => setNonce((n) => n + 1) }, '↻'),
|
|
150
152
|
h('button', { title: open ? '收起' : '展开', onPointerDown: noDrag, onClick: () => setOpen(!open) }, open ? '⌄' : '⌃'),
|
|
151
153
|
),
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-live2dwaifu",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "给 DSH 网页右下角请一个 Live2D 角色:会眨眼、会呼吸、视线跟着鼠标、点她有反应、说话时嘴动、心情变了眉毛跟着动。模型不用内置——谁装谁放自己的文件夹;支持自定义 .exp3.json 表情和 .motion3.json 动作,卡片能拖能缩,背景可换。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|