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 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
@@ -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
- bubble.textContent = s2.slice(0, ++i2);
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 + s2.length * 110));
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
- }, 55);
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({ id: e.name, name: meta?.name ?? e.name, model3, expressions: meta?.expressions ?? [] });
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.2.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",