dsh-live2dwaifu 0.2.0 → 0.3.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/README.md +4 -0
- package/assets/tone.html +256 -0
- package/assets/viewer.js +48 -5
- package/docs/blip-sample.html +259 -0
- package/lib/client.js +50 -0
- package/lib/index.js +153 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,8 +21,12 @@
|
|
|
21
21
|
|
|
22
22
|
**会自己动**:模型里带 `.motion3.json` 的,隔十几秒自己播一个动作。气泡能拖着挪地方,说完过一阵自己淡下去。
|
|
23
23
|
|
|
24
|
+
**会出声**:一个字一声很短的电音,标点停一拍,句尾沉一下——不是朗读,是“哔哔”那种。标题栏的 **♪** 打开调音页,放个真模型在旁边,边调边听;每只模型各存一份音色,换人换声。音量拉到 0 就安静,只剩气泡和口型。
|
|
25
|
+
|
|
24
26
|
**跟着聊**:她挂在聊天流上(`conversation.chat.node` 插槽),助手每说一句她就念一句,情绪按话里的词换。
|
|
25
27
|
|
|
28
|
+
**也能直接跟她说**:卡片底下有个小框,打一句回车,她回你。这条线**不进你的聊天记录**——后台直接问模型,往来只存在 `models/<id>/chat.json` 里。她说的话按人设卡现场写,不是复读;人设卡就是 `models/<id>/meta.json` 里的 `persona` 字段,没写就用默认那份(若妍)。
|
|
29
|
+
|
|
26
30
|
## 三十秒上手
|
|
27
31
|
|
|
28
32
|
```bash
|
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
|
@@ -24,6 +24,13 @@
|
|
|
24
24
|
.dsh-l2d-btns button{background:none;border:0;color:#7d858f;font:inherit;cursor:pointer;padding:0 2px;line-height:1}
|
|
25
25
|
.dsh-l2d-btns button:hover{color:#e2e6ee}
|
|
26
26
|
.dsh-l2d-body{display:block;width:100%;flex:1;border:0;background:transparent}
|
|
27
|
+
.dsh-l2d-say{display:flex;gap:6px;padding:6px 8px;border-top:1px solid #2c313a;flex:none}
|
|
28
|
+
.dsh-l2d-say input{flex:1;min-width:0;background:#1e222a;border:1px solid #333842;border-radius:7px;
|
|
29
|
+
color:#dfe4ec;font:inherit;font-size:11px;padding:5px 8px;outline:none}
|
|
30
|
+
.dsh-l2d-say input:focus{border-color:#3f4a5c}
|
|
31
|
+
.dsh-l2d-send{background:#2f3540;border:1px solid #3c424c;border-radius:7px;color:#cfd4dd;
|
|
32
|
+
font:inherit;cursor:pointer;padding:0 10px;font-size:11px}
|
|
33
|
+
.dsh-l2d-send:hover{background:#3a4049}
|
|
27
34
|
.dsh-l2d-grip{position:absolute;right:0;bottom:0;width:16px;height:16px;cursor:nwse-resize;
|
|
28
35
|
background:linear-gradient(135deg,transparent 50%,#3c424c 50%,#3c424c 62%,transparent 62%);
|
|
29
36
|
opacity:.9}
|
|
@@ -61,6 +68,8 @@
|
|
|
61
68
|
const [bg, setBg] = react.useState(() => localStorage.getItem('dsh-l2d-bg') || 'none');
|
|
62
69
|
const [size, setSize] = react.useState(readSize);
|
|
63
70
|
const [pos, setPos] = react.useState(readPos);
|
|
71
|
+
const [draft, setDraft] = react.useState('');
|
|
72
|
+
const [busy, setBusy] = react.useState(false);
|
|
64
73
|
const [nonce, setNonce] = react.useState(0);
|
|
65
74
|
const ref = react.useRef(null);
|
|
66
75
|
|
|
@@ -127,6 +136,29 @@
|
|
|
127
136
|
const noDrag = (e) => e.stopPropagation();
|
|
128
137
|
|
|
129
138
|
const manage = () => ref.current && ref.current.contentWindow.postMessage({ type: 'dsh-live2d/manage' }, '*');
|
|
139
|
+
const openTone = () => window.open(BASE + '/tone', '_blank', 'width=1140,height=780');
|
|
140
|
+
|
|
141
|
+
const send = () => {
|
|
142
|
+
const t = draft.trim();
|
|
143
|
+
if (!t || busy) return;
|
|
144
|
+
setBusy(true);
|
|
145
|
+
setDraft('');
|
|
146
|
+
fetch(BASE + '/api/chat', {
|
|
147
|
+
method: 'POST',
|
|
148
|
+
headers: { 'content-type': 'application/json' },
|
|
149
|
+
body: JSON.stringify({ text: t }),
|
|
150
|
+
})
|
|
151
|
+
.then((r) => r.json())
|
|
152
|
+
.then((d) => {
|
|
153
|
+
if (d.reply && frameWin) {
|
|
154
|
+
try {
|
|
155
|
+
frameWin.postMessage({ type: 'dsh-live2d/say', text: d.reply }, '*');
|
|
156
|
+
} catch {}
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
.catch(() => {})
|
|
160
|
+
.finally(() => setBusy(false));
|
|
161
|
+
};
|
|
130
162
|
const cycleBg = () => setBg(BGS[(BGS.indexOf(bg) + 1) % BGS.length]);
|
|
131
163
|
|
|
132
164
|
const cardStyle = open
|
|
@@ -146,6 +178,7 @@
|
|
|
146
178
|
{ className: 'dsh-l2d-btns' },
|
|
147
179
|
open && h('button', { title: '模型管理', onPointerDown: noDrag, onClick: manage }, '⚙'),
|
|
148
180
|
open && h('button', { title: '背景:' + BG_LABEL[bg], onPointerDown: noDrag, onClick: cycleBg }, '◐'),
|
|
181
|
+
open && h('button', { title: '音色', onPointerDown: noDrag, onClick: openTone }, '♪'),
|
|
149
182
|
open && h('button', { title: '刷新', onPointerDown: noDrag, onClick: () => setNonce((n) => n + 1) }, '↻'),
|
|
150
183
|
h('button', { title: open ? '收起' : '展开', onPointerDown: noDrag, onClick: () => setOpen(!open) }, open ? '⌄' : '⌃'),
|
|
151
184
|
),
|
|
@@ -162,6 +195,23 @@
|
|
|
162
195
|
},
|
|
163
196
|
})
|
|
164
197
|
: null,
|
|
198
|
+
open
|
|
199
|
+
? h(
|
|
200
|
+
'div',
|
|
201
|
+
{ className: 'dsh-l2d-say' },
|
|
202
|
+
h('input', {
|
|
203
|
+
className: 'dsh-l2d-input',
|
|
204
|
+
placeholder: busy ? '……' : '跟她说一句',
|
|
205
|
+
value: draft,
|
|
206
|
+
onPointerDown: noDrag,
|
|
207
|
+
onChange: (e) => setDraft(e.target.value),
|
|
208
|
+
onKeyDown: (e) => {
|
|
209
|
+
if (e.key === 'Enter') send();
|
|
210
|
+
},
|
|
211
|
+
}),
|
|
212
|
+
h('button', { className: 'dsh-l2d-send', onPointerDown: noDrag, onClick: send }, '说'),
|
|
213
|
+
)
|
|
214
|
+
: null,
|
|
165
215
|
open ? h('div', { className: 'dsh-l2d-grip', onPointerDown: startDrag, title: '拖动改大小' }) : null,
|
|
166
216
|
);
|
|
167
217
|
}
|
package/lib/index.js
CHANGED
|
@@ -10,6 +10,36 @@ 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
|
+
|
|
16
|
+
/** 没写人设卡时,就用这一句兜底 */
|
|
17
|
+
const DEFAULT_PERSONA = [
|
|
18
|
+
'你是若妍,二十六岁,跟老弟过日子。',
|
|
19
|
+
'说话短,一句常常不到十五个字;不用感叹号,不写书面语;情绪落在具体的事上——盐、番茄、袖口、手凉不凉。',
|
|
20
|
+
'不解释心情,不总结,不升华,不写括号里的动作。',
|
|
21
|
+
].join('\n');
|
|
22
|
+
|
|
23
|
+
function personaOf(meta) {
|
|
24
|
+
const p = typeof meta?.persona === 'string' ? meta.persona.trim() : '';
|
|
25
|
+
return p || DEFAULT_PERSONA;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readChat(id) {
|
|
29
|
+
try {
|
|
30
|
+
const d = JSON.parse(readFileSync(join(MODELS, id, 'chat.json'), 'utf8'));
|
|
31
|
+
return Array.isArray(d.messages) ? d.messages : [];
|
|
32
|
+
} catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function writeChat(id, messages) {
|
|
38
|
+
try {
|
|
39
|
+
writeFileSync(join(MODELS, id, 'chat.json'), JSON.stringify({ messages: messages.slice(-24) }, null, 2), 'utf8');
|
|
40
|
+
} catch {}
|
|
41
|
+
}
|
|
42
|
+
|
|
13
43
|
const MIME = {
|
|
14
44
|
'.html': 'text/html; charset=utf-8',
|
|
15
45
|
'.js': 'text/javascript; charset=utf-8',
|
|
@@ -101,7 +131,13 @@ function listModels() {
|
|
|
101
131
|
const meta = readMeta(e.name);
|
|
102
132
|
const model3 = meta?.model3 ?? findModel3(dir);
|
|
103
133
|
if (!model3) continue;
|
|
104
|
-
list.push({
|
|
134
|
+
list.push({
|
|
135
|
+
id: e.name,
|
|
136
|
+
name: meta?.name ?? e.name,
|
|
137
|
+
model3,
|
|
138
|
+
expressions: meta?.expressions ?? [],
|
|
139
|
+
voice: { ...DEFAULT_VOICE, ...(meta?.voice || {}) },
|
|
140
|
+
});
|
|
105
141
|
}
|
|
106
142
|
return list;
|
|
107
143
|
}
|
|
@@ -142,7 +178,7 @@ async function readBody(req, limit = 512 * 1024 * 1024) {
|
|
|
142
178
|
return Buffer.concat(chunks);
|
|
143
179
|
}
|
|
144
180
|
|
|
145
|
-
async function handle(req, res) {
|
|
181
|
+
async function handle(req, res, ctx) {
|
|
146
182
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
147
183
|
const path = url.pathname.replace(PREFIX, '') || '/';
|
|
148
184
|
const method = (req.method ?? 'GET').toUpperCase();
|
|
@@ -168,7 +204,121 @@ async function handle(req, res) {
|
|
|
168
204
|
return file ? sendFile(res, file) : send(res, 403, 'forbidden');
|
|
169
205
|
}
|
|
170
206
|
|
|
207
|
+
// 跟她说话:不进聊天记录,直接问模型
|
|
208
|
+
if (path === '/api/chat' && method === 'POST') {
|
|
209
|
+
const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
|
|
210
|
+
const text = String(body.text ?? '').trim().slice(0, 1000);
|
|
211
|
+
if (!text) return sendJson(res, 400, { error: 'empty' });
|
|
212
|
+
const cfg = readConfig();
|
|
213
|
+
const id = String(body.id || cfg.selected || '');
|
|
214
|
+
const meta = id ? readMeta(id) : null;
|
|
215
|
+
if (!meta) return sendJson(res, 404, { error: 'model not found' });
|
|
216
|
+
|
|
217
|
+
let sel;
|
|
218
|
+
let llm;
|
|
219
|
+
try {
|
|
220
|
+
sel = ctx?.get?.('agentDefaultModel')?.currentSelection?.();
|
|
221
|
+
llm = ctx?.get?.('llm');
|
|
222
|
+
} catch {
|
|
223
|
+
sel = undefined;
|
|
224
|
+
llm = undefined;
|
|
225
|
+
}
|
|
226
|
+
if (!llm || typeof llm.stream !== 'function' || !sel?.provider || !sel?.model) {
|
|
227
|
+
return sendJson(res, 503, { error: '模型接口不可用' });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const hist = readChat(id);
|
|
231
|
+
const lines = hist
|
|
232
|
+
.slice(-8)
|
|
233
|
+
.map((m) => (m.role === 'user' ? '他:' : '你:') + String(m.text ?? ''))
|
|
234
|
+
.join('\n');
|
|
235
|
+
const prompt = [
|
|
236
|
+
personaOf(meta),
|
|
237
|
+
lines ? '你们之前说的:\n' + lines : '',
|
|
238
|
+
'他说:' + text,
|
|
239
|
+
'回他一句。只输出这句话本身:一到三句,短,口语,像发消息。不要解释,不要加引号,不要旁白,不要括号里的动作。',
|
|
240
|
+
]
|
|
241
|
+
.filter(Boolean)
|
|
242
|
+
.join('\n\n');
|
|
243
|
+
|
|
244
|
+
let reply = '';
|
|
245
|
+
try {
|
|
246
|
+
for await (const chunk of llm.stream({
|
|
247
|
+
provider: sel.provider,
|
|
248
|
+
model: sel.model,
|
|
249
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
|
|
250
|
+
})) {
|
|
251
|
+
if (chunk?.type === 'text-delta') reply += chunk.text ?? '';
|
|
252
|
+
else if (chunk?.type === 'finish') break;
|
|
253
|
+
if (reply.length > 900) break;
|
|
254
|
+
}
|
|
255
|
+
} catch (e) {
|
|
256
|
+
return sendJson(res, 502, { error: '生成失败:' + (e instanceof Error ? e.message : String(e)) });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
reply = reply
|
|
260
|
+
.replace(/\r?\n+/g, ' ')
|
|
261
|
+
.trim()
|
|
262
|
+
.replace(/^["“「『']+|["”」』']+$/g, '')
|
|
263
|
+
.trim();
|
|
264
|
+
if (!reply) return sendJson(res, 200, { ok: true, reply: '', id });
|
|
265
|
+
if (reply.length > 240) reply = reply.slice(0, 240);
|
|
266
|
+
|
|
267
|
+
writeChat(id, hist.concat([
|
|
268
|
+
{ role: 'user', text, at: Date.now() },
|
|
269
|
+
{ role: 'assistant', text: reply, at: Date.now() },
|
|
270
|
+
]));
|
|
271
|
+
return sendJson(res, 200, { ok: true, reply, id });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// 人设卡
|
|
275
|
+
if (path === '/api/persona' && method === 'GET') {
|
|
276
|
+
const id = String(url.searchParams.get('id') ?? '');
|
|
277
|
+
const meta = id ? readMeta(id) : null;
|
|
278
|
+
return sendJson(res, 200, { id, persona: personaOf(meta), custom: typeof meta?.persona === 'string' && !!meta.persona.trim() });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (path === '/api/persona' && method === 'POST') {
|
|
282
|
+
const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
|
|
283
|
+
const id = String(body.id ?? '');
|
|
284
|
+
const meta = id ? readMeta(id) : null;
|
|
285
|
+
if (!meta) return sendJson(res, 404, { error: 'model not found' });
|
|
286
|
+
const persona = typeof body.persona === 'string' ? body.persona : '';
|
|
287
|
+
writeFileSync(join(MODELS, id, 'meta.json'), JSON.stringify({ ...meta, persona }, null, 2), 'utf8');
|
|
288
|
+
return sendJson(res, 200, { ok: true, persona: personaOf({ persona }) });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// 音色调音页
|
|
292
|
+
if (path === '/tone' || path === '/tone.html') {
|
|
293
|
+
return sendFile(res, join(ASSETS, 'tone.html'), MIME['.html']);
|
|
294
|
+
}
|
|
295
|
+
|
|
171
296
|
// API
|
|
297
|
+
if (path === '/api/voice' && method === 'GET') {
|
|
298
|
+
const id = String(url.searchParams.get('id') ?? '');
|
|
299
|
+
const meta = id ? readMeta(id) : null;
|
|
300
|
+
return sendJson(res, 200, { voice: { ...DEFAULT_VOICE, ...(meta?.voice || {}) }, id });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (path === '/api/voice' && method === 'POST') {
|
|
304
|
+
const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
|
|
305
|
+
const id = String(body.id ?? '');
|
|
306
|
+
const meta = id ? readMeta(id) : null;
|
|
307
|
+
if (!meta) return sendJson(res, 404, { error: 'model not found' });
|
|
308
|
+
const v = body.voice && typeof body.voice === 'object' ? body.voice : {};
|
|
309
|
+
const voice = {
|
|
310
|
+
preset: String(v.preset || 'soft'),
|
|
311
|
+
type: ['triangle', 'square', 'sawtooth', 'sine'].includes(v.type) ? v.type : 'triangle',
|
|
312
|
+
base: Math.max(80, Math.min(1200, Number(v.base) || DEFAULT_VOICE.base)),
|
|
313
|
+
jit: Math.max(0, Math.min(50, Number(v.jit) || 0)),
|
|
314
|
+
dur: Math.max(10, Math.min(200, Number(v.dur) || DEFAULT_VOICE.dur)),
|
|
315
|
+
vol: Math.max(0, Math.min(100, Number(v.vol) || 0)),
|
|
316
|
+
spd: Math.max(20, Math.min(200, Number(v.spd) || DEFAULT_VOICE.spd)),
|
|
317
|
+
};
|
|
318
|
+
writeFileSync(join(MODELS, id, 'meta.json'), JSON.stringify({ ...meta, voice }, null, 2), 'utf8');
|
|
319
|
+
return sendJson(res, 200, { ok: true, voice });
|
|
320
|
+
}
|
|
321
|
+
|
|
172
322
|
if (path === '/api/models' && method === 'GET') {
|
|
173
323
|
const cfg = readConfig();
|
|
174
324
|
return sendJson(res, 200, { models: listModels(), selected: cfg.selected ?? null });
|
|
@@ -228,7 +378,7 @@ export function apply(ctx) {
|
|
|
228
378
|
path: PREFIX,
|
|
229
379
|
handler: async (req, res) => {
|
|
230
380
|
try {
|
|
231
|
-
await handle(req, res);
|
|
381
|
+
await handle(req, res, ctx);
|
|
232
382
|
} catch (e) {
|
|
233
383
|
if (!res.headersSent) sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) });
|
|
234
384
|
else res.end();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-live2dwaifu",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "给 DSH 网页右下角请一个 Live2D 角色:会眨眼、会呼吸、视线跟着鼠标、点她有反应、说话时嘴动、心情变了眉毛跟着动。模型不用内置——谁装谁放自己的文件夹;支持自定义 .exp3.json 表情和 .motion3.json 动作,卡片能拖能缩,背景可换。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|