dsh-creation-mode 3.0.0 → 3.0.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/client.js CHANGED
@@ -107,6 +107,37 @@ window.__ModuleLoader__.load({
107
107
  .cas-lightbox{position:fixed;inset:0;z-index:9100;background:rgba(4,5,8,.88);display:flex;align-items:center;
108
108
  justify-content:center;padding:32px;cursor:zoom-out}
109
109
  .cas-lightbox img{max-width:100%;max-height:100%;border-radius:10px;box-shadow:0 20px 60px rgba(0,0,0,.6)}
110
+
111
+ /* 样式库:会话里的第三个页签(对话 / 轨迹 / 样式库) */
112
+ .cas-lib-view{display:flex;flex-direction:column;gap:10px;padding:16px;min-height:0}
113
+ .cas-lib-head{display:flex;align-items:center;gap:10px}
114
+ .cas-lib-title{font-weight:600;font-size:14px}
115
+ .cas-lib-count{font-size:11px;color:var(--dsw-alias-label-secondary);white-space:nowrap}
116
+ .cas-lib-search{flex:1;height:30px;padding:0 10px;border-radius:8px;outline:none;min-width:0;
117
+ border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);
118
+ color:var(--dsw-alias-label-primary);font:13px/1 inherit}
119
+ .cas-lib-search:focus{border-color:var(--dsw-alias-border-l2)}
120
+ .cas-lib-chips{display:flex;flex-wrap:wrap;gap:6px}
121
+ .cas-chip{height:24px;padding:0 9px;border-radius:999px;cursor:pointer;
122
+ border:1px solid var(--dsw-alias-border-l1);background:transparent;
123
+ color:var(--dsw-alias-label-secondary);font:11.5px/1 inherit}
124
+ .cas-chip:hover{border-color:var(--dsw-alias-border-l2)}
125
+ .cas-chip[data-on="1"]{background:var(--dsw-alias-label-primary);border-color:transparent;
126
+ color:var(--dsw-alias-bg-base)}
127
+ .cas-lib-body{overflow:auto;min-height:240px;max-height:calc(100vh - 260px)}
128
+ .cas-lib-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px}
129
+ .cas-lib-card{border:1px solid var(--dsw-alias-border-l1);border-radius:9px;overflow:hidden;
130
+ background:var(--dsw-alias-bg-layer-1);padding:0;text-align:left;cursor:zoom-in;
131
+ color:inherit;font:inherit}
132
+ .cas-lib-card:hover{border-color:var(--dsw-alias-border-l2)}
133
+ .cas-lib-card img{width:100%;display:block;aspect-ratio:16/9;object-fit:cover;
134
+ background:var(--dsw-alias-bg-layer-2)}
135
+ .cas-lib-cap{padding:7px 9px}
136
+ .cas-lib-id{font-size:10px;font-weight:600;letter-spacing:.5px;color:var(--dsw-alias-label-secondary)}
137
+ .cas-lib-name{margin-left:6px;font-size:12.5px}
138
+ .cas-lib-desc{display:block;margin-top:3px;font-size:11px;color:var(--dsw-alias-label-secondary);
139
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
140
+ .cas-lib-empty{padding:48px 0;text-align:center;font-size:13px;color:var(--dsw-alias-label-secondary)}
110
141
  `;
111
142
 
112
143
  const PALETTE = ['#ff8a3d', '#e0457b', '#7b5bff', '#3fd7c0', '#4d6bfe', '#ffc04d'];
@@ -123,1244 +154,42 @@ window.__ModuleLoader__.load({
123
154
  }
124
155
 
125
156
  /**
126
- * Real-time, cue-accurate score synthesis.
127
- *
128
- * Two problems have to be solved together for a cut to feel like a 卡点:
129
- *
130
- * 1. The cut must sit on a strong musical position. That is a tempo-and-phase
131
- * problem: pick the beat period that puts every cut on a bar line, not merely
132
- * "near a beat". (This is the same thing `web-audio-beat-detector` solves when it
133
- * jointly estimates tempo and first-beat offset — here it is inverted, because the
134
- * cut times are known up front and the music is composed to them.)
135
- *
136
- * 2. The bar has to be an actual groove. A drone with occasional booms does not read
137
- * as an accent, because there is no pulse for the accent to break. So every bar
138
- * carries kick, snare, hats and bass, and the cut lands where the ear expects the
139
- * downbeat.
140
- *
141
- * On top of that, the arrangement sidechains under each cue so the impact lands in a
142
- * gap rather than on top of an equally loud bed.
143
- *
144
- * Everything is synthesised with Web Audio through `OfflineAudioContext`, so there are
145
- * no bundled assets and no network.
146
- */
147
-
148
- const MUSIC_PRESETS = [
149
- { id: 'auto', label: '智能匹配分镜卡点' },
150
- { id: 'guofeng', label: '国风水墨韵律' },
151
- { id: 'epic', label: '史诗宏大震撼' },
152
- { id: 'synth', label: '赛博未来科技' },
153
- { id: 'pulse', label: '快节奏动感脉冲' },
154
- { id: 'bright', label: '活泼轻快灵动' },
155
- { id: 'chiptune', label: '8-bit 芯片音乐' },
156
- { id: 'ambient', label: '沉浸深邃氛围' },
157
- { id: 'none', label: '无配乐' },
158
- ];
159
-
160
- const SCALES = {
161
- pentatonic: [0, 2, 4, 7, 9],
162
- minorPent: [0, 3, 5, 7, 10],
163
- minor: [0, 2, 3, 5, 7, 8, 10],
164
- major: [0, 2, 4, 5, 7, 9, 11],
165
- dorian: [0, 2, 3, 5, 7, 9, 10],
166
- phrygian: [0, 1, 3, 5, 7, 8, 10],
167
- };
168
-
169
- /**
170
- * Bar patterns, sixteen steps each: one character per sixteenth note.
171
- *
172
- * `K`/`k` kick (accent/soft), `X` kick and snare together, `S` snare,
173
- * `H`/`h` hat (accent/soft), `o` open hat, `.` silence.
174
- *
175
- * `four` is four-on-the-floor (a kick on every beat), `backbeat` is kick on 1 and 3
176
- * with the snare on 2 and 4, `sparse` leaves air for an arrival. Each family holds
177
- * several variants because repeating one bar verbatim is what made every score read
178
- * as the same loop; consecutive bars rotate through them.
179
- */
180
- const GROOVE_PATTERNS = {
181
- four: [
182
- 'K.h.X.h.K.h.X.h.',
183
- 'K.hhX.hhK.hhX.hh',
184
- 'K.h.X.h.K.h.X.oH',
185
- 'K.h.X.hhK.h.X.hh',
186
- ],
187
- backbeat: [
188
- 'K.h.S.h.K.h.S.h.',
189
- 'K..hS..hK..hS..h',
190
- 'K.hhS.hhK.hhS.hh',
191
- 'K.h.S.h.K.h.S.oH',
192
- ],
193
- sparse: [
194
- 'K.......o.......',
195
- 'K.....k.o.......',
196
- 'K.......h...S...',
197
- 'K...k...o...S...',
198
- ],
199
- };
200
-
201
- /**
202
- * One instrument set per mood.
203
- *
204
- * Every preset used to share a single electronic kit — the same sine kick, the same
205
- * noise snare, the same saw pluck — so changing mood moved only the scale, tempo and
206
- * reverb and every score still sounded like the same track. These sets change the
207
- * instrument families themselves: a taiko and a plucked zither for 国风, an orchestra
208
- * for 史诗, an 808/909 machine for 赛博, a marimba for 轻快, an FM bell for 氛围.
209
- *
210
- * Voice parameters are read by the primitives below; `null` drops the voice entirely.
211
- */
212
- /**
213
- * Instruments, each built by a different synthesis method.
214
- *
215
- * A parameter sweep over one oscillator cannot make a zither, a flute and a piano —
216
- * they differ in *how* they make sound, not in cutoff and decay. Each entry here names
217
- * the method its voice uses:
218
- *
219
- * pluck filtered oscillator stack, exponential decay, optional pitch bend
220
- * struck additive partials with hammer noise; higher partials decay faster (piano)
221
- * fm carrier ring-modulated by a ratio (marimba, bell, e-piano)
222
- * bowed detuned saw with bow noise, vibrato and a formant peak (violin, cello, brass)
223
- * wind sine/triangle with breath noise and vibrato (笛子)
224
- * chip unfiltered pulse wave by duty cycle (NES/8-bit)
225
- */
226
- const INSTRUMENTS = {
227
- // ---- 中国乐器 ----
228
- zheng: {
229
- synth: 'pluck', wave: 'triangle', attack: 0.0015, c0: 3600, c1: 520, bend: -0.5, decayScale: 0.9,
230
- // 古筝的泛音列比钢琴更亮、更不谐和
231
- partials: [{ wave: 'sine', octave: 12, gain: 0.40 }, { wave: 'sine', octave: 19, gain: 0.16 }, { wave: 'triangle', octave: 24, gain: 0.08 }],
232
- },
233
- dizi: {
234
- synth: 'wind', wave: 'sine', attack: 0.05, decayScale: 1.7, sustain: true,
235
- breath: 0.20, breathF: 2600, air: 0.05, vibrato: 5.6, vibratoDepth: 0.013, c0: 2600, c1: 2000, level: 0.85,
236
- },
237
- // ---- 西洋乐器 ----
238
- piano: {
239
- synth: 'struck', attack: 0.003, decayScale: 1.5, hammer: 0.09, hammerF: 3200,
240
- // 非谐泛音 + 高次泛音衰减更快:这是钢琴与拨弦最大的区别
241
- partials: [
242
- { octave: 0, gain: 1 }, { octave: 12, gain: 0.34 }, { octave: 19, gain: 0.20 },
243
- { octave: 24, gain: 0.10 }, { octave: 28, gain: 0.05 },
244
- ],
245
- c0: 2800, c1: 700,
246
- },
247
- violin: {
248
- synth: 'bowed', wave: 'sawtooth', attack: 0.075, decayScale: 1.8, sustain: true,
249
- bow: 0.09, bowF: 3000, vibrato: 5.8, vibratoDepth: 0.010, formant: 900, c0: 3200, c1: 2400, level: 0.9,
250
- },
251
- cello: {
252
- synth: 'bowed', wave: 'sawtooth', attack: 0.11, decayScale: 2.0, sustain: true,
253
- bow: 0.07, bowF: 1800, vibrato: 4.6, vibratoDepth: 0.008, formant: 520, c0: 1700, c1: 1300, level: 1.0,
254
- },
255
- brass: {
256
- synth: 'bowed', wave: 'sawtooth', attack: 0.055, decayScale: 1.9, sustain: false,
257
- bow: 0.02, bowF: 1500, vibrato: 5.2, vibratoDepth: 0.005, formant: 1200, c0: 2200, c1: 900, level: 1.4,
258
- },
259
- guitar: {
260
- synth: 'pluck', wave: 'triangle', attack: 0.004, c0: 2600, c1: 700, decayScale: 1.0,
261
- partials: [{ wave: 'sine', octave: 12, gain: 0.30 }, { wave: 'sine', octave: 19, gain: 0.12 }],
262
- },
263
- // ---- 打击与键盘 ----
264
- marimba: { synth: 'fm', ratio: 4, index: 1.6, attack: 0.003, decayScale: 0.8, level: 1.25 },
265
- bell: { synth: 'fm', ratio: 3.5, index: 1.4, attack: 0.006, decayScale: 2.6, level: 1.25 },
266
- ePiano: { synth: 'fm', ratio: 2, index: 1.1, attack: 0.004, decayScale: 1.6, level: 1.2 },
267
- // ---- 电子与 8-bit ----
268
- acid: { synth: 'pluck', wave: 'square', attack: 0.002, c0: 4200, c1: 900, decayScale: 0.6, q: 7 },
269
- stab: { synth: 'pluck', wave: 'sawtooth', attack: 0.001, c0: 5200, c1: 1400, decayScale: 0.35 },
270
- padSynth: { synth: 'pluck', wave: 'sawtooth', attack: 0.01, c0: 2000, c1: 800, decayScale: 1.2 },
271
- chipPulse: { synth: 'chip', duty: 0.25, attack: 0.001, decayScale: 0.55 },
272
- chipSquare: { synth: 'chip', duty: 0.5, attack: 0.001, decayScale: 0.65 },
273
- chipTri: { synth: 'chipTri', attack: 0.002, decayScale: 0.8 },
274
- };
275
-
276
- /**
277
- * One kit per mood: which instruments play, plus the drum design.
278
- *
279
- * `lead` is the melody line; `answer` is an optional second instrument that takes
280
- * every other melodic step, which is what turns a single line into an exchange
281
- * (古筝 问答 笛子) instead of the same timbre repeating.
282
- */
283
- const KITS = {
284
- guofeng: {
285
- trim: 1.997, // 响度校准
286
- // 太鼓:鼓皮噪声为主
287
- kick: { f0: 96, f1: 52, sweep: 0.12, dur: 0.55, peak: 0.34, tone: 0.55, thump: 0.36, thumpDur: 0.10, thumpF0: 1400, thumpF1: 160, thumpType: 'bandpass', thumpQ: 0.8 },
288
- // 梆子:极短高 Q 木质
289
- snare: { kind: 'wood', type: 'bandpass', f0: 1700, q: 4.5, dur: 0.075, peak: 0.20, attack: 0.0008, body: 430, bodyLevel: 0.10, bodyDur: 0.07 },
290
- hat: { type: 'highpass', f0: 4600, dur: 0.05, peak: 0.07, attack: 0.004 },
291
- lead: 'zheng',
292
- answer: 'dizi',
293
- bass: { wave: 'sine', attack: 0.03, sustain: true, release: 0.16, level: 0.30, cutoff: 320 },
294
- pad: { voices: [{ wave: 'sine', detune: -5 }, { wave: 'triangle', detune: 5 }], attack: 0.65, sustain: true, release: 0.3, cutoff: 700, level: 0.18 },
295
- riser: { type: 'bandpass', f0: 500, f1: 5200, q: 2, peak: 0.24 },
296
- impact: { sub0: 92, sub1: 34, subDur: 0.9, crash: 2600, crashQ: 0.6, stab: 'triangle', stabPeak: 0.13 },
297
- },
298
- epic: {
299
- trim: 0.611, // 响度校准
300
- kick: { f0: 88, f1: 40, sweep: 0.20, dur: 0.80, peak: 0.44, tone: 0.85, thump: 0.26, thumpDur: 0.12, thumpF0: 1100, thumpF1: 120 },
301
- snare: { kind: 'noise', type: 'bandpass', f0: 2100, q: 1.1, dur: 0.19, peak: 0.26, attack: 0.001, body: 185, bodyWave: 'sine', bodyLevel: 0.20, bodyDur: 0.16 },
302
- hat: { type: 'highpass', f0: 6600, dur: 0.045, peak: 0.055, attack: 0.004 },
303
- // 弦乐群:小提琴 + 大提琴分解,而不是铜管主奏
304
- lead: 'violin',
305
- answer: 'cello',
306
- bass: { voices: [{ wave: 'sawtooth', detune: -7 }, { wave: 'sawtooth', detune: 0 }, { wave: 'sawtooth', detune: 7 }], attack: 0.09, sustain: true, release: 0.2, level: 0.26, cutoff: 900 },
307
- pad: { voices: [{ wave: 'sawtooth', detune: -10 }, { wave: 'sawtooth', detune: -3 }, { wave: 'sawtooth', detune: 3 }, { wave: 'sawtooth', detune: 10 }], attack: 0.4, sustain: true, release: 0.3, cutoff: 2100, level: 0.14 },
308
- riser: { type: 'bandpass', f0: 300, f1: 4500, q: 1.4, peak: 0.34 },
309
- impact: { sub0: 76, sub1: 30, subDur: 1.1, crash: 3000, crashQ: 0.5, stab: 'sawtooth', stabPeak: 0.17 },
310
- },
311
- synth: {
312
- trim: 4.377, // 响度校准
313
- kick: { f0: 182, f1: 42, sweep: 0.055, dur: 0.55, peak: 0.60, tone: 1, thump: 0.05, thumpDur: 0.02, thumpF0: 800, thumpF1: 90 },
314
- snare: { kind: 'noise', type: 'highpass', f0: 1500, q: 0.8, dur: 0.12, peak: 0.26, attack: 0.0008, body: 225, bodyLevel: 0.14, bodyDur: 0.08 },
315
- hat: { type: 'highpass', f0: 9200, dur: 0.03, peak: 0.10, attack: 0.0006 },
316
- lead: 'acid',
317
- bass: { wave: 'sawtooth', attack: 0.004, sustain: true, release: 0.08, level: 0.30, c0: 260, c1: 420, q: 9, resonant: true },
318
- pad: { voices: [{ wave: 'sawtooth', detune: -14 }, { wave: 'sawtooth', detune: 14 }], attack: 0.22, sustain: true, release: 0.25, cutoff: 1150, level: 0.13 },
319
- riser: { type: 'bandpass', f0: 600, f1: 7000, q: 4, peak: 0.30 },
320
- impact: { sub0: 84, sub1: 26, subDur: 0.95, crash: 4200, crashQ: 0.9, stab: 'square', stabPeak: 0.14 },
321
- },
322
- chiptune: {
323
- trim: 14.917, // 响度校准
324
- // NES 没有真正的底鼓:三角波快速下扫当鼓,是芯片音乐的标志手法
325
- kick: { f0: 220, f1: 48, sweep: 0.04, dur: 0.16, peak: 0.55, tone: 1, thump: 0.12, thumpDur: 0.015, thumpF0: 1200, thumpF1: 200, thumpType: 'highpass', wave: 'triangle' },
326
- // 噪声通道:短促、不做滤波塑形
327
- snare: { kind: 'noise', type: 'highpass', f0: 2400, q: 0.6, dur: 0.10, peak: 0.24, attack: 0.0004 },
328
- hat: { type: 'highpass', f0: 7000, dur: 0.025, peak: 0.11, attack: 0.0004 },
329
- lead: 'chipPulse',
330
- answer: 'chipSquare',
331
- arpeggio: true,
332
- // 三角波贝斯:NES 三个音源之一
333
- bass: { wave: 'triangle', attack: 0.002, sustain: true, release: 0.04, level: 0.34, cutoff: 2400 },
334
- pad: null,
335
- riser: { type: 'highpass', f0: 2000, f1: 8000, q: 1, peak: 0.22 },
336
- impact: { sub0: 120, sub1: 40, subDur: 0.4, crash: 5200, crashQ: 1.1, stab: 'square', stabPeak: 0.16 },
337
- },
338
- pulse: {
339
- trim: 6.709, // 响度校准
340
- kick: { f0: 205, f1: 52, sweep: 0.05, dur: 0.26, peak: 0.62, tone: 1, thump: 0.09, thumpDur: 0.02, thumpF0: 900, thumpF1: 100 },
341
- snare: { kind: 'clap', type: 'bandpass', f0: 1800, q: 1.5, peak: 0.24 },
342
- hat: { type: 'highpass', f0: 8600, dur: 0.028, peak: 0.10, attack: 0.0006 },
343
- lead: 'stab',
344
- answer: 'padSynth',
345
- bass: { wave: 'square', attack: 0.004, sustain: true, release: 0.06, level: 0.30, cutoff: 760 },
346
- pad: { voices: [{ wave: 'square', detune: -8 }, { wave: 'sawtooth', detune: 8 }], attack: 0.18, sustain: true, release: 0.2, cutoff: 2600, level: 0.09 },
347
- riser: { type: 'bandpass', f0: 700, f1: 6500, q: 3, peak: 0.30 },
348
- impact: { sub0: 88, sub1: 28, subDur: 0.8, crash: 3800, crashQ: 0.8, stab: 'sawtooth', stabPeak: 0.15 },
349
- },
350
- bright: {
351
- trim: 8.116, // 响度校准
352
- kick: { f0: 150, f1: 72, sweep: 0.04, dur: 0.17, peak: 0.26, tone: 0.5, thump: 0.30, thumpDur: 0.04, thumpF0: 1800, thumpF1: 300, thumpType: 'bandpass', thumpQ: 0.9 },
353
- snare: { kind: 'wood', type: 'bandpass', f0: 2500, q: 4, dur: 0.055, peak: 0.20, attack: 0.0006, body: 620, bodyLevel: 0.09, bodyDur: 0.05 },
354
- hat: { type: 'highpass', f0: 6200, dur: 0.06, peak: 0.085, attack: 0.006 },
355
- // 马林巴主奏,钢琴应答
356
- lead: 'marimba',
357
- answer: 'piano',
358
- bass: { wave: 'sine', attack: 0.008, sustain: true, release: 0.08, level: 0.28, cutoff: 500 },
359
- pad: { voices: [{ wave: 'triangle', detune: -7 }, { wave: 'sine', detune: 7 }], attack: 0.28, sustain: true, release: 0.2, cutoff: 3200, level: 0.12 },
360
- riser: { type: 'bandpass', f0: 900, f1: 6000, q: 2.5, peak: 0.22 },
361
- impact: { sub0: 96, sub1: 40, subDur: 0.6, crash: 4600, crashQ: 0.9, stab: 'sine', stabPeak: 0.12 },
362
- },
363
- ambient: {
364
- trim: 1.742, // 响度校准
365
- // 完全不敲鼓
366
- kick: null,
367
- snare: null,
368
- hat: null,
369
- lead: 'bell',
370
- answer: 'ePiano',
371
- bass: { wave: 'sine', attack: 0.5, sustain: true, release: 0.5, level: 0.26, cutoff: 240 },
372
- pad: { voices: [{ wave: 'sine', detune: -12 }, { wave: 'triangle', detune: -2 }, { wave: 'triangle', detune: 2 }, { wave: 'sine', detune: 12 }], attack: 0.9, sustain: true, release: 0.5, cutoff: 560, level: 0.18 },
373
- riser: { type: 'bandpass', f0: 200, f1: 3000, q: 1.2, peak: 0.18 },
374
- impact: { sub0: 64, sub1: 26, subDur: 1.6, crash: 1800, crashQ: 0.4, stab: 'sine', stabPeak: 0.10 },
375
- },
376
- cinematic: {
377
- trim: 1.994, // 响度校准
378
- kick: { f0: 122, f1: 45, sweep: 0.14, dur: 0.55, peak: 0.42, tone: 0.9, thump: 0.18, thumpDur: 0.07, thumpF0: 1000, thumpF1: 120 },
379
- snare: { kind: 'noise', type: 'bandpass', f0: 2000, q: 1.0, dur: 0.15, peak: 0.24, attack: 0.001, body: 200, bodyWave: 'triangle', bodyLevel: 0.15, bodyDur: 0.1 },
380
- hat: { type: 'highpass', f0: 6800, dur: 0.04, peak: 0.07, attack: 0.003 },
381
- // 钢琴主奏,小提琴应答
382
- lead: 'piano',
383
- answer: 'violin',
384
- bass: { voices: [{ wave: 'triangle', detune: -4 }, { wave: 'sine', detune: 4 }], attack: 0.02, sustain: true, release: 0.12, level: 0.30, cutoff: 600 },
385
- pad: { voices: [{ wave: 'sawtooth', detune: -8 }, { wave: 'sawtooth', detune: 0 }, { wave: 'sawtooth', detune: 8 }], attack: 0.5, sustain: true, release: 0.3, cutoff: 1350, level: 0.14 },
386
- riser: { type: 'bandpass', f0: 400, f1: 5000, q: 2, peak: 0.28 },
387
- impact: { sub0: 80, sub1: 28, subDur: 0.95, crash: 3400, crashQ: 0.7, stab: 'triangle', stabPeak: 0.14 },
388
- },
389
- };
390
-
391
- /** Unknown ids (including `auto` and the retired `lofi`) keep a distinct default. */
392
- function kitFor(mood) {
393
- return KITS[mood] || KITS.cinematic;
394
- }
395
-
396
- function mulberry32(seed) {
397
- let a = seed >>> 0;
398
- return () => {
399
- a = (a + 0x6D2B79F5) | 0;
400
- let t = Math.imul(a ^ (a >>> 15), 1 | a);
401
- t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
402
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
403
- };
404
- }
405
-
406
- function midiToHz(midi) {
407
- return 440 * 2 ** ((midi - 69) / 12);
408
- }
409
-
410
- function createNoiseBuffer(ctx, seconds, rng) {
411
- const len = Math.max(1, Math.floor(ctx.sampleRate * seconds));
412
- const buf = ctx.createBuffer(1, len, ctx.sampleRate);
413
- const data = buf.getChannelData(0);
414
- for (let i = 0; i < len; i++) data[i] = rng() * 2 - 1;
415
- return buf;
416
- }
417
-
418
- function createReverbBuffer(ctx, seconds, decay, rng) {
419
- const len = Math.max(1, Math.floor(ctx.sampleRate * seconds));
420
- const buf = ctx.createBuffer(2, len, ctx.sampleRate);
421
- for (let ch = 0; ch < 2; ch++) {
422
- const data = buf.getChannelData(ch);
423
- for (let i = 0; i < len; i++) data[i] = (rng() * 2 - 1) * (1 - i / len) ** decay;
424
- }
425
- return buf;
426
- }
427
-
428
- /**
429
- * Turn the free-form music description into synthesis parameters.
430
- *
431
- * Both Chinese and English words are matched, because the description is whatever the
432
- * Agent wrote for this clip rather than a fixed preset id.
433
- */
434
- function analyzePrompt(promptOrId) {
435
- const text = String(promptOrId || '').toLowerCase();
436
- const has = (...words) => words.some((w) => text.includes(w));
437
-
438
- if (has('国风', '水墨', '中国风', '古', '山水', '禅', 'guofeng', 'chinese', 'ink')) {
439
- return {
440
- mood: 'guofeng', scale: SCALES.pentatonic, root: 52, bpm: 88,
441
- chords: [0, 3, 4, 1, 0, 4], wave: 'triangle', pluckDecay: 0.9,
442
- groove: 'sparse', reverbWet: 0.42, cutoff: 2400, impact: 0.85,
443
- };
444
- }
445
- if (has('史诗', '宏大', '震撼', '大片', '战争', '英雄', 'epic', 'cinematic', 'trailer')) {
446
- return {
447
- mood: 'epic', scale: SCALES.minor, root: 41, bpm: 80,
448
- chords: [0, 5, 3, 4], wave: 'sawtooth', pluckDecay: 0.45,
449
- groove: 'backbeat', reverbWet: 0.38, cutoff: 1600, impact: 1.0,
450
- };
451
- }
452
- if (has('赛博', '科技', '未来', 'crt', '故障', 'synth', 'cyber', 'electronic', 'techno')) {
453
- return {
454
- mood: 'synth', scale: SCALES.minor, root: 43, bpm: 124,
455
- chords: [0, 3, 5, 2], wave: 'sawtooth', pluckDecay: 0.3,
456
- groove: 'four', reverbWet: 0.24, cutoff: 2800, impact: 0.92,
457
- };
458
- }
459
- if (has('轻快', '活泼', '卡通', '趣味', '明快', 'bright', 'playful', 'happy')) {
460
- return {
461
- mood: 'bright', scale: SCALES.major, root: 48, bpm: 116,
462
- chords: [0, 4, 5, 3], wave: 'sine', pluckDecay: 0.35,
463
- groove: 'backbeat', reverbWet: 0.2, cutoff: 3200, impact: 0.7,
464
- };
465
- }
466
- if (has('快', '脉冲', '燃', '节奏', '紧张', 'pulse', 'drive', 'energy', 'fast')) {
467
- return {
468
- mood: 'pulse', scale: SCALES.dorian, root: 45, bpm: 128,
469
- chords: [0, 2, 3, 0], wave: 'sawtooth', pluckDecay: 0.25,
470
- groove: 'four', reverbWet: 0.18, cutoff: 2200, impact: 0.9,
471
- };
472
- }
473
- if (has('8bit', '8-bit', '芯片', '红白机', 'fc', 'nes', 'chiptune', '像素游戏', 'gameboy', 'gb')) {
474
- return {
475
- mood: 'chiptune', scale: SCALES.major, root: 60, bpm: 150,
476
- chords: [0, 4, 5, 3], wave: 'square', pluckDecay: 0.16,
477
- groove: 'backbeat', reverbWet: 0.12, cutoff: 6000, impact: 0.7,
478
- };
479
- }
480
- if (has('氛围', '静谧', '空灵', '孤独', 'ambient', 'calm', 'drone', 'space')) {
481
- return {
482
- mood: 'ambient', scale: SCALES.minorPent, root: 45, bpm: 72,
483
- chords: [0, 5, 3, 4], wave: 'sine', pluckDecay: 0.8,
484
- groove: 'sparse', reverbWet: 0.5, cutoff: 1200, impact: 0.6,
485
- };
486
- }
487
- return {
488
- mood: 'cinematic', scale: SCALES.minorPent, root: 45, bpm: 96,
489
- chords: [0, 3, 4, 2], wave: 'triangle', pluckDecay: 0.45,
490
- groove: 'backbeat', reverbWet: 0.34, cutoff: 2000, impact: 0.88,
491
- };
492
- }
493
-
494
- /**
495
- * Choose the beat period whose bar lines land on the clip's cuts.
496
- *
497
- * Every cut is a musical arrival, so it should fall on a downbeat. Candidate tempos are
498
- * scored by the mean distance between each cut and the nearest bar line, with a small
499
- * preference for the mood's intended tempo when two candidates fit equally well.
500
- *
501
- * @param cuts - cut times in seconds.
502
- * @param duration - clip length.
503
- * @param hintedBpm - the mood's tempo, used to break ties and when there are no cuts.
504
- * @returns `{ beat, bar, bpm, fit }` where `fit` is 1 for a perfect alignment.
505
- */
506
- function fitGroove(cuts, duration, hintedBpm) {
507
- const target = Number(hintedBpm) || 96;
508
- const usable = (Array.isArray(cuts) ? cuts : [])
509
- .map(Number)
510
- .filter((t) => Number.isFinite(t) && t > 0.15 && t < duration - 0.1);
511
-
512
- if (!usable.length) {
513
- const beat = 60 / target;
514
- return { beat, bar: beat * 4, bpm: target, fit: 0 };
515
- }
516
-
517
- let best = null;
518
- for (let bpm = 60; bpm <= 190; bpm += 1) {
519
- const beat = 60 / bpm;
520
- const bar = beat * 4;
521
- // A bar shorter than ~0.35s has no room for a groove; longer than 3s drags.
522
- if (bar < 0.35 || bar > 3) continue;
523
-
524
- let err = 0;
525
- for (const t of usable) {
526
- const k = t / bar;
527
- err += Math.abs(k - Math.round(k));
528
- }
529
- err /= usable.length;
530
-
531
- // Ties go to the tempo the mood asked for, so a well-fitting clip keeps its feel.
532
- const comfort = Math.abs(bpm - target) / (target * 8);
533
- const score = err * 6 + comfort;
534
- if (!best || score < best.score) best = { beat, bar, bpm, err, score };
535
- }
536
-
537
- if (!best) {
538
- const beat = 60 / target;
539
- return { beat, bar: beat * 4, bpm: target, fit: 0 };
540
- }
541
- return { beat: best.beat, bar: best.bar, bpm: best.bpm, fit: Math.max(0, 1 - best.err * 4) };
542
- }
543
-
544
- /**
545
- * Synthesise a score tailored to one clip.
546
- *
547
- * @param musicPromptOrPreset - free-form description (or a legacy preset id).
548
- * @param options - `{ duration, cuts, cues, seed, sampleRate }`. `cuts` are storyboard
549
- * edits; `cues` are in-shot visual peaks. Both get a hit, sample-accurately.
550
- */
551
- async function renderMusic(musicPromptOrPreset, options = {}) {
552
- const duration = Math.max(0.5, Number(options.duration) || 8);
553
- const sampleRate = Number(options.sampleRate) || 44100;
554
- const seed = Number(options.seed) || 20261103;
555
- const rng = mulberry32(seed);
556
- const spec = analyzePrompt(musicPromptOrPreset);
557
- // The instrument set for this mood; every voice below reads from it.
558
- const K = kitFor(spec.mood);
559
-
560
- // Collect every moment the picture needs an accent on.
561
- const rawCuts = Array.isArray(options.cuts) ? options.cuts : [];
562
- const rawCues = Array.isArray(options.cues) ? options.cues : [];
563
- const allCues = [];
564
-
565
- for (const c of rawCuts) {
566
- const t = Number(c);
567
- if (Number.isFinite(t) && t > 0.05 && t < duration - 0.05) {
568
- allCues.push({ time: t, type: 'cut', power: 1 });
569
- }
570
- }
571
- for (const cue of rawCues) {
572
- const t = typeof cue === 'number' ? cue : Number(cue.time ?? cue.t);
573
- if (Number.isFinite(t) && t > 0.05 && t < duration - 0.05) {
574
- allCues.push({
575
- time: t,
576
- type: (cue && cue.type) || 'hit',
577
- power: (cue && Number(cue.power)) || 0.75,
578
- });
579
- }
580
- }
581
-
582
- allCues.sort((a, b) => a.time - b.time);
583
- const cues = [];
584
- for (const c of allCues) {
585
- if (!cues.some((prev) => Math.abs(prev.time - c.time) < 0.07)) cues.push(c);
586
- }
587
-
588
- const totalFrames = Math.ceil(duration * sampleRate);
589
- const ctx = new OfflineAudioContext(2, totalFrames, sampleRate);
590
-
591
- // ---- master chain: headroom, glue, then a hard ceiling so impacts never clip ----
592
- const master = ctx.createGain();
593
- master.gain.value = 0.6;
594
-
595
- const glue = ctx.createDynamicsCompressor();
596
- glue.threshold.value = -16;
597
- glue.knee.value = 8;
598
- glue.ratio.value = 4;
599
- glue.attack.value = 0.004;
600
- glue.release.value = 0.18;
601
-
602
- const ceiling = ctx.createDynamicsCompressor();
603
- ceiling.threshold.value = -3;
604
- ceiling.knee.value = 0;
605
- ceiling.ratio.value = 20;
606
- ceiling.attack.value = 0.001;
607
- ceiling.release.value = 0.08;
608
-
609
- // A soft ceiling of last resort. The two compressors above shape the mix, but they
610
- // are dynamics processors, not limiters: a bright 8-bit arpeggio at full level can
611
- // still overshoot. tanh keeps that from clipping on the way out.
612
- const softClip = ctx.createWaveShaper();
613
- const curve = new Float32Array(1024);
614
- for (let i = 0; i < curve.length; i += 1) {
615
- const x = (i / (curve.length - 1)) * 2 - 1;
616
- curve[i] = Math.tanh(x * 1.35);
617
- }
618
- softClip.curve = curve;
619
-
620
- master.connect(glue);
621
- glue.connect(ceiling);
622
- ceiling.connect(softClip);
623
- softClip.connect(ctx.destination);
624
-
625
- const verb = ctx.createConvolver();
626
- verb.buffer = createReverbBuffer(ctx, Math.min(2.4, duration * 0.8), 2.2, rng);
627
- const wet = ctx.createGain();
628
- wet.gain.value = spec.reverbWet;
629
- verb.connect(wet);
630
- // `wet` joins the kit bus below, not `master`: routing the reverb past the trim let
631
- // a wet preset (氛围 uses 0.5) ignore its own loudness calibration.
632
-
633
- const fade = ctx.createGain();
634
- fade.gain.setValueAtTime(0.0001, 0);
635
- fade.gain.linearRampToValueAtTime(1, Math.min(0.15, duration * 0.04));
636
- // This only removes the tail click. The ending itself is a cadence written by the
637
- // arrangement below, so the last chord rings instead of being faded out mid-loop.
638
- fade.gain.setValueAtTime(1, Math.max(0.15, duration - 0.06));
639
- fade.gain.linearRampToValueAtTime(0.0001, duration);
640
- fade.connect(master);
641
-
642
- const { beat, bar, bpm, fit } = fitGroove(rawCuts, duration, spec.bpm);
643
-
644
- /**
645
- * The bed bus carries the groove. Cue impacts connect past it to `kitBus`, so when
646
- * the bed ducks the hit is exposed rather than masked.
647
- *
648
- * `kitBus` is the per-kit trim: instruments differ in loudness by more than 20 dB
649
- * (a bowed string ensemble against a bare pulse wave), so each kit carries one
650
- * calibration gain instead of every voice guessing its own level.
651
- */
652
- const kitBus = ctx.createGain();
653
- kitBus.gain.value = K.trim ?? 1;
654
- kitBus.connect(fade);
655
- wet.connect(kitBus);
656
-
657
- const bed = ctx.createGain();
658
- bed.gain.value = 1;
659
- bed.connect(kitBus);
660
-
661
- const scale = SCALES[spec.scale] || SCALES.minorPent;
662
-
663
- const noiseBuf = createNoiseBuffer(ctx, 1.5, rng);
664
- const hatsBuf = createNoiseBuffer(ctx, 0.2, rng);
665
-
666
- // ---- voices ----------------------------------------------------------------
667
- //
668
- // Each voice is a thin wrapper over a primitive, and the primitive reads its
669
- // parameters from the mood's kit. The call signatures stay as they were, so the
670
- // arrangement and ending code above is unchanged.
671
-
672
- /** Filtered noise burst: snare, clap, hat, shaker, woodblock, cymbal. */
673
- const noiseBurst = (t, power, dest, k, overrides) => {
674
- const cfg = overrides ? { ...k, ...overrides } : k;
675
- const src = ctx.createBufferSource();
676
- src.buffer = hatsBuf;
677
- const f = ctx.createBiquadFilter();
678
- f.type = cfg.type || 'bandpass';
679
- f.frequency.setValueAtTime(cfg.f0, t);
680
- if (cfg.f1) f.frequency.exponentialRampToValueAtTime(cfg.f1, t + cfg.dur);
681
- f.Q.value = cfg.q ?? 1;
682
- const g = ctx.createGain();
683
- const attack = cfg.attack ?? 0.001;
684
- g.gain.setValueAtTime(0.0001, t);
685
- g.gain.linearRampToValueAtTime(cfg.peak * power, t + attack);
686
- g.gain.exponentialRampToValueAtTime(0.0001, t + cfg.dur);
687
- src.connect(f);
688
- f.connect(g);
689
- g.connect(dest);
690
- if (cfg.verb) g.connect(verb);
691
- src.start(t);
692
- src.stop(t + cfg.dur + 0.03);
693
- };
694
-
695
- /**
696
- * Drum: 808/909 kick, taiko, timpani, tom, or a soft brush thump.
697
- *
698
- * `tone` and `thump` set the balance between the pitched body and the skin noise.
699
- * Every kit used to be a bare sine sweep, which is the single most "electronic"
700
- * thing in the score; a frame drum is mostly noise over a short low tone, while an
701
- * 808 is almost pure tone.
702
- */
703
- const playKick = (t, power, dest) => {
704
- const k = K.kick;
705
- if (!k) return;
706
- if (k.tone !== 0) {
707
- const osc = ctx.createOscillator();
708
- osc.type = 'sine';
709
- osc.frequency.setValueAtTime(k.f0, t);
710
- osc.frequency.exponentialRampToValueAtTime(k.f1, t + k.sweep);
711
- const g = ctx.createGain();
712
- g.gain.setValueAtTime(k.peak * (k.tone ?? 1) * power, t);
713
- g.gain.exponentialRampToValueAtTime(0.0001, t + k.dur);
714
- osc.connect(g);
715
- g.connect(dest);
716
- osc.start(t);
717
- osc.stop(t + k.dur + 0.02);
718
- }
719
- // The skin: noise the pitched body alone cannot give.
720
- if (k.thump > 0) {
721
- noiseBurst(t, k.thump * power, dest, {
722
- type: k.thumpType || 'lowpass',
723
- f0: k.thumpF0 ?? 900, f1: k.thumpF1 ?? 110,
724
- dur: k.thumpDur || 0.05, q: k.thumpQ ?? 0.7, peak: 1, attack: 0.0005,
725
- });
726
- }
727
- };
728
-
729
- const playSnare = (t, power, dest) => {
730
- const k = K.snare;
731
- if (!k) return;
732
- if (k.kind === 'clap') {
733
- // A clap is three very short bursts, not one long noise hit.
734
- for (let i = 0; i < 3; i++) {
735
- noiseBurst(t + i * 0.011, power * (1 - i * 0.16), dest, k, { dur: 0.05 + i * 0.02, attack: 0.0005 });
736
- }
737
- return;
738
- }
739
- noiseBurst(t, power, dest, k);
740
- if (k.body) {
741
- // A body tone under the noise so the hit reads on small speakers too.
742
- const body = ctx.createOscillator();
743
- body.type = k.bodyWave || 'triangle';
744
- body.frequency.setValueAtTime(k.body, t);
745
- body.frequency.exponentialRampToValueAtTime(k.body * 0.72, t + 0.07);
746
- const bg = ctx.createGain();
747
- bg.gain.setValueAtTime(k.bodyLevel * power, t);
748
- bg.gain.exponentialRampToValueAtTime(0.0001, t + (k.bodyDur ?? 0.1));
749
- body.connect(bg);
750
- bg.connect(dest);
751
- body.start(t);
752
- body.stop(t + (k.bodyDur ?? 0.1) + 0.03);
753
- }
754
- };
755
-
756
- const playHat = (t, power, dest, open = false) => {
757
- const k = K.hat;
758
- if (!k) return;
759
- noiseBurst(t, power, dest, k, open ? { dur: Math.max(k.dur, 0.16) } : undefined);
760
- };
761
-
762
- /**
763
- * General tonal voice: bass, pad, strings, brass.
764
- *
765
- * One oscillator per entry in `voices`, so a preset can stack detuned layers for a
766
- * string ensemble instead of the single sine every preset used to get.
767
- */
768
- const tonal = (midi, t, length, level, dest, k) => {
769
- const lp = ctx.createBiquadFilter();
770
- lp.type = 'lowpass';
771
- if (k.c1) {
772
- lp.frequency.setValueAtTime(k.c0 ?? k.cutoff ?? 2000, t);
773
- lp.frequency.exponentialRampToValueAtTime(k.c1, t + Math.min(length, 0.5));
774
- } else {
775
- lp.frequency.value = k.cutoff ?? 2000;
776
- }
777
- if (k.q) lp.Q.value = k.q;
778
-
779
- const g = ctx.createGain();
780
- const attack = k.attack ?? 0.01;
781
- g.gain.setValueAtTime(0.0001, t);
782
- g.gain.linearRampToValueAtTime(level, t + attack);
783
- if (k.sustain) {
784
- const release = k.release ?? 0.12;
785
- g.gain.setValueAtTime(level, Math.max(t + attack, t + length - release));
786
- g.gain.linearRampToValueAtTime(0.0001, t + length);
787
- } else {
788
- g.gain.exponentialRampToValueAtTime(0.0001, t + length);
789
- }
790
-
791
- for (const v of (k.voices || [{ wave: k.wave || 'sine', detune: 0 }])) {
792
- const osc = ctx.createOscillator();
793
- osc.type = v.wave;
794
- osc.detune.value = v.detune || 0;
795
- osc.frequency.setValueAtTime(midiToHz(midi), t);
796
- if (k.vibrato) {
797
- const lfo = ctx.createOscillator();
798
- lfo.frequency.value = k.vibrato;
799
- const lg = ctx.createGain();
800
- lg.gain.value = midiToHz(midi) * 0.008;
801
- lfo.connect(lg);
802
- lg.connect(osc.frequency);
803
- lfo.start(t);
804
- lfo.stop(t + length + 0.05);
805
- }
806
- osc.connect(lp);
807
- osc.start(t);
808
- osc.stop(t + length + 0.04);
809
- }
810
- lp.connect(g);
811
- g.connect(dest);
812
- if (k.verb !== false) g.connect(verb);
813
- };
814
-
815
- /** FM voice: marimba, bell. Two oscillators give a struck, inharmonic attack. */
816
- const fmVoice = (midi, t, dur, level, dest, k) => {
817
- const hz = midiToHz(midi);
818
- const car = ctx.createOscillator();
819
- car.type = 'sine';
820
- car.frequency.value = hz;
821
- const mod = ctx.createOscillator();
822
- mod.type = 'sine';
823
- mod.frequency.value = hz * (k.ratio ?? 2);
824
- const mg = ctx.createGain();
825
- mg.gain.setValueAtTime(hz * (k.index ?? 1.5), t);
826
- mg.gain.exponentialRampToValueAtTime(hz * 0.02, t + dur * 0.6);
827
- mod.connect(mg);
828
- mg.connect(car.frequency);
829
-
830
- const g = ctx.createGain();
831
- g.gain.setValueAtTime(0.0001, t);
832
- g.gain.linearRampToValueAtTime(level, t + (k.attack ?? 0.004));
833
- g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
834
- car.connect(g);
835
- g.connect(dest);
836
- if (k.verb !== false) g.connect(verb);
837
- car.start(t);
838
- car.stop(t + dur + 0.03);
839
- mod.start(t);
840
- mod.stop(t + dur + 0.03);
841
- };
842
-
843
- const playBass = (midi, t, length, power) => {
844
- tonal(midi, t, length, (K.bass.level ?? 0.32) * power, bed, K.bass);
845
- };
846
-
847
- /** NES-style pulse wave; `duty` sets the harmonic content (0.125/0.25/0.5). */
848
- const pulseWave = (duty) => {
849
- const size = 32;
850
- const real = new Float32Array(size);
851
- const imag = new Float32Array(size);
852
- for (let k = 1; k < size; k += 1) imag[k] = (2 / (k * Math.PI)) * Math.sin(Math.PI * k * duty);
853
- return ctx.createPeriodicWave(real, imag);
854
- };
855
- const pulseCache = new Map();
856
-
857
- /** Filtered noise used as a breath, bow or hammer transient. */
858
- const transient = (t, dur, level, kind, freq, q = 1) => {
859
- const src = ctx.createBufferSource();
860
- src.buffer = hatsBuf;
861
- const f = ctx.createBiquadFilter();
862
- f.type = kind;
863
- f.frequency.value = freq;
864
- f.Q.value = q;
865
- const g = ctx.createGain();
866
- g.gain.setValueAtTime(0.0001, t);
867
- g.gain.linearRampToValueAtTime(level, t + Math.min(0.006, dur * 0.25));
868
- g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
869
- src.connect(f);
870
- f.connect(g);
871
- g.connect(bed);
872
- src.start(t);
873
- src.stop(t + dur + 0.02);
874
- };
875
-
876
- /** Vibrato on one oscillator, in cents relative to its own pitch. */
877
- const applyVibrato = (osc, t, dur, rate, depth) => {
878
- if (!rate) return;
879
- const lfo = ctx.createOscillator();
880
- lfo.frequency.value = rate;
881
- const g = ctx.createGain();
882
- g.gain.value = midiToHz(60) * depth * 12;
883
- lfo.connect(g);
884
- g.connect(osc.frequency);
885
- lfo.start(t);
886
- lfo.stop(t + dur + 0.05);
887
- };
888
-
889
- /** Additive stack with faster decay on higher partials — a struck string. */
890
- const struckVoice = (midi, t, dur, level, k) => {
891
- const lp = ctx.createBiquadFilter();
892
- lp.type = 'lowpass';
893
- lp.frequency.setValueAtTime(k.c0 ?? 2800, t);
894
- lp.frequency.exponentialRampToValueAtTime(k.c1 ?? 700, t + dur);
895
- const out = ctx.createGain();
896
- out.gain.setValueAtTime(0.0001, t);
897
- out.gain.linearRampToValueAtTime(level, t + (k.attack ?? 0.003));
898
- out.gain.exponentialRampToValueAtTime(0.0001, t + dur);
899
- for (const part of (k.partials || [{ octave: 0, gain: 1 }])) {
900
- const osc = ctx.createOscillator();
901
- osc.type = 'sine';
902
- osc.frequency.setValueAtTime(midiToHz(midi + part.octave), t);
903
- // Upper partials die first; that two-stage decay is what reads as "piano".
904
- const pg = ctx.createGain();
905
- const partialDur = part.octave === 0 ? dur : dur * Math.max(0.25, 1 - part.octave / 40);
906
- pg.gain.setValueAtTime(0.0001, t);
907
- pg.gain.linearRampToValueAtTime(part.gain, t + (k.attack ?? 0.003));
908
- pg.gain.exponentialRampToValueAtTime(0.0001, t + partialDur);
909
- osc.connect(pg);
910
- pg.connect(lp);
911
- osc.start(t);
912
- osc.stop(t + partialDur + 0.03);
913
- }
914
- if (k.hammer) transient(t, 0.03, k.hammer * level, 'highpass', k.hammerF ?? 3200);
915
- lp.connect(out);
916
- out.connect(bed);
917
- out.connect(verb);
918
- };
919
-
920
- /** Bowed string: saw with bow noise, vibrato and a formant peak. */
921
- const bowedVoice = (midi, t, dur, level, k) => {
922
- const lp = ctx.createBiquadFilter();
923
- lp.type = 'lowpass';
924
- lp.frequency.setValueAtTime(k.c0 ?? 3000, t);
925
- if (k.sustain) lp.frequency.linearRampToValueAtTime(k.c1 ?? 2000, t + Math.min(0.3, dur));
926
- else lp.frequency.exponentialRampToValueAtTime(k.c1 ?? 900, t + dur);
927
- const peak = ctx.createBiquadFilter();
928
- peak.type = 'peaking';
929
- peak.frequency.value = k.formant ?? 900;
930
- peak.Q.value = 1.1;
931
- peak.gain.value = 7;
932
- const g = ctx.createGain();
933
- const attack = k.attack ?? 0.07;
934
- g.gain.setValueAtTime(0.0001, t);
935
- g.gain.linearRampToValueAtTime(level, t + attack);
936
- if (k.sustain) {
937
- g.gain.setValueAtTime(level, Math.max(t + attack, t + dur - 0.12));
938
- g.gain.linearRampToValueAtTime(0.0001, t + dur);
939
- } else {
940
- g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
941
- }
942
- for (const detune of [-7, 7]) {
943
- const osc = ctx.createOscillator();
944
- osc.type = k.wave || 'sawtooth';
945
- osc.detune.value = detune;
946
- osc.frequency.setValueAtTime(midiToHz(midi), t);
947
- applyVibrato(osc, t, dur, k.vibrato, k.vibratoDepth ?? 0.008);
948
- osc.connect(lp);
949
- osc.start(t);
950
- osc.stop(t + dur + 0.04);
951
- }
952
- if (k.bow) transient(t, 0.05, k.bow * level, 'bandpass', k.bowF ?? 2600, 0.8);
953
- lp.connect(peak);
954
- peak.connect(g);
955
- g.connect(bed);
956
- g.connect(verb);
957
- };
958
-
959
- /** 笛子: a hollow tone plus breath noise, with vibrato on the held part. */
960
- const windVoice = (midi, t, dur, level, k) => {
961
- const lp = ctx.createBiquadFilter();
962
- lp.type = 'lowpass';
963
- lp.frequency.setValueAtTime(k.c0 ?? 2400, t);
964
- if (k.sustain) lp.frequency.linearRampToValueAtTime(k.c1 ?? 1800, t + Math.min(0.4, dur));
965
- const g = ctx.createGain();
966
- const attack = k.attack ?? 0.05;
967
- g.gain.setValueAtTime(0.0001, t);
968
- g.gain.linearRampToValueAtTime(level, t + attack);
969
- g.gain.setValueAtTime(level, Math.max(t + attack, t + dur - 0.1));
970
- g.gain.linearRampToValueAtTime(0.0001, t + dur);
971
- // A flute is nearly a sine; its character lives in the breath and the vibrato.
972
- const osc = ctx.createOscillator();
973
- osc.type = k.wave || 'sine';
974
- osc.frequency.setValueAtTime(midiToHz(midi), t);
975
- applyVibrato(osc, t, dur, k.vibrato, k.vibratoDepth ?? 0.012);
976
- const wobble = ctx.createOscillator();
977
- wobble.type = 'triangle';
978
- wobble.frequency.setValueAtTime(midiToHz(midi + 12), t);
979
- const wg = ctx.createGain();
980
- wg.gain.value = 0.18;
981
- wobble.connect(wg);
982
- wg.connect(lp);
983
- osc.connect(lp);
984
- osc.start(t);
985
- osc.stop(t + dur + 0.04);
986
- wobble.start(t);
987
- wobble.stop(t + dur + 0.04);
988
- if (k.breath) transient(t, 0.09, k.breath * level, 'bandpass', k.breathF ?? 2600, 0.7);
989
- if (k.air) transient(t + 0.06, Math.max(0.1, dur - 0.06), k.air * level, 'highpass', 3600);
990
- lp.connect(g);
991
- g.connect(bed);
992
- g.connect(verb);
993
- };
994
-
995
- /** Unfiltered pulse/triangle: the 8-bit voice. Nothing shapes it but its envelope. */
996
- const chipVoice = (midi, t, dur, level, k) => {
997
- const osc = ctx.createOscillator();
998
- if (k.synth === 'chipTri') {
999
- osc.type = 'triangle';
1000
- } else {
1001
- const duty = k.duty ?? 0.5;
1002
- if (!pulseCache.has(duty)) pulseCache.set(duty, pulseWave(duty));
1003
- osc.setPeriodicWave(pulseCache.get(duty));
1004
- }
1005
- osc.frequency.setValueAtTime(midiToHz(midi), t);
1006
- const g = ctx.createGain();
1007
- const attack = k.attack ?? 0.001;
1008
- g.gain.setValueAtTime(0.0001, t);
1009
- g.gain.linearRampToValueAtTime(level, t + attack);
1010
- g.gain.setValueAtTime(level, Math.max(t + attack, t + dur - 0.02));
1011
- g.gain.linearRampToValueAtTime(0.0001, t + dur);
1012
- osc.connect(g);
1013
- // A touch of the room keeps it from sounding like a bare test tone.
1014
- g.connect(bed);
1015
- g.connect(verb);
1016
- osc.start(t);
1017
- osc.stop(t + dur + 0.02);
1018
- };
1019
-
1020
- /** Dispatch one melodic note to whichever instrument the kit names. */
1021
- const playInstrument = (name, midi, t, vol, decay) => {
1022
- const k = INSTRUMENTS[name] || INSTRUMENTS.piano;
1023
- const dur = Math.max(0.08, decay * (k.decayScale ?? 1));
1024
- const level = vol * (k.level ?? 1);
1025
- switch (k.synth) {
1026
- case 'fm': fmVoice(midi, t, dur, level, bed, k); break;
1027
- case 'struck': struckVoice(midi, t, dur, level, k); break;
1028
- case 'bowed': bowedVoice(midi, t, dur, level, k); break;
1029
- case 'wind': windVoice(midi, t, dur, level, k); break;
1030
- case 'chip':
1031
- case 'chipTri': chipVoice(midi, t, dur, level, k); break;
1032
- default: {
1033
- // Struck/plucked string: fast attack, exponential decay, filter sweep, and a
1034
- // stack of partials; `q` gives a synth stab its resonant onset.
1035
- const lp = ctx.createBiquadFilter();
1036
- lp.type = 'lowpass';
1037
- lp.frequency.setValueAtTime(k.c0 ?? spec.cutoff, t);
1038
- lp.frequency.exponentialRampToValueAtTime(k.c1 ?? 420, t + dur);
1039
- if (k.q) lp.Q.value = k.q;
1040
- const g = ctx.createGain();
1041
- g.gain.setValueAtTime(0.0001, t);
1042
- g.gain.linearRampToValueAtTime(level, t + (k.attack ?? 0.005));
1043
- g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
1044
- const layers = [{ wave: k.wave || spec.wave, gain: 1 }];
1045
- for (const part of (k.partials || [])) layers.push(part);
1046
- for (const layer of layers) {
1047
- const osc = ctx.createOscillator();
1048
- osc.type = layer.wave;
1049
- osc.frequency.setValueAtTime(midiToHz(midi + (layer.octave || 0)), t);
1050
- if (k.bend) osc.frequency.exponentialRampToValueAtTime(midiToHz(midi + (layer.octave || 0) + k.bend), t + Math.min(0.25, dur));
1051
- const lg = ctx.createGain();
1052
- lg.gain.value = layer.gain;
1053
- osc.connect(lg);
1054
- lg.connect(lp);
1055
- osc.start(t);
1056
- osc.stop(t + dur + 0.04);
157
+ * 把用户自己选的音频文件解码成 AudioBuffer,并铺满整段片长。
158
+ *
159
+ * 这是唯一一处「不是模型写的配乐」还能进片子的入口,而且必须由用户主动选文件;
160
+ * 插件自己不会生成任何兜底配乐。
161
+ */
162
+ async function audioFileToBuffer(file, duration, sampleRate = 44100) {
163
+ const bytes = await file.arrayBuffer();
164
+ const ctx = new OfflineAudioContext(2, Math.ceil(duration * sampleRate), sampleRate);
165
+ const decoded = await ctx.decodeAudioData(bytes);
166
+ const targetLength = Math.ceil(duration * decoded.sampleRate);
167
+ const out = ctx.createBuffer(2, targetLength, decoded.sampleRate);
168
+
169
+ for (let ch = 0; ch < 2; ch += 1) {
170
+ const from = decoded.getChannelData(Math.min(ch, decoded.numberOfChannels - 1));
171
+ const to = out.getChannelData(ch);
172
+ const take = Math.min(targetLength, from.length);
173
+ to.set(from.subarray(0, take));
174
+ // 素材比片子短就首尾相接地铺满,不做拉伸,免得变速变调。
175
+ if (take < targetLength && from.length > 0) {
176
+ let offset = take;
177
+ while (offset < targetLength) {
178
+ const chunk = Math.min(from.length, targetLength - offset);
179
+ to.set(from.subarray(0, chunk), offset);
180
+ offset += chunk;
181
+ }
1057
182
  }
1058
- lp.connect(g);
1059
- g.connect(bed);
1060
- g.connect(verb);
1061
183
  }
1062
- }
1063
- };
1064
-
1065
- /** The kit's melody line; `answer` alternates with it for a call-and-response. */
1066
- const playPluck = (midi, t, vol, decay, useAnswer) => {
1067
- const name = useAnswer && K.answer ? K.answer : K.lead;
1068
- playInstrument(name, midi, t, vol, decay);
1069
- };
1070
-
1071
- const playPad = (midi, t, length, vol) => {
1072
- const k = K.pad;
1073
- if (!k) return;
1074
- const attack = Math.min(k.attack ?? 0.35, length * 0.4);
1075
- tonal(midi, t, length, vol * (k.level ?? 1), bed, { ...k, attack });
1076
- };
1077
-
1078
- const playRiser = (targetT, power) => {
1079
- const k = K.riser || { type: 'bandpass', f0: 400, f1: 6000, q: 3, peak: 0.3 };
1080
- const lead = Math.min(0.4, targetT);
1081
- if (lead < 0.12) return;
1082
- const startT = targetT - lead;
1083
- const src = ctx.createBufferSource();
1084
- src.buffer = noiseBuf;
1085
- src.loop = true;
1086
- const bp = ctx.createBiquadFilter();
1087
- bp.type = k.type;
1088
- bp.Q.value = k.q;
1089
- bp.frequency.setValueAtTime(k.f0, startT);
1090
- bp.frequency.exponentialRampToValueAtTime(k.f1, targetT);
1091
- const g = ctx.createGain();
1092
- g.gain.setValueAtTime(0.0001, startT);
1093
- g.gain.linearRampToValueAtTime(k.peak * power, targetT - 0.02);
1094
- g.gain.linearRampToValueAtTime(0.0001, targetT);
1095
- src.connect(bp);
1096
- bp.connect(g);
1097
- g.connect(bed);
1098
- src.start(startT);
1099
- src.stop(targetT + 0.03);
1100
- };
1101
-
1102
- /** The cut accent: sub boom + noise crash + an in-key chord stab. */
1103
- const playImpact = (t, power, rootMidi) => {
1104
- const sub = ctx.createOscillator();
1105
- sub.type = 'sine';
1106
- sub.frequency.setValueAtTime(72, t);
1107
- sub.frequency.exponentialRampToValueAtTime(28, t + 0.6);
1108
- const sg = ctx.createGain();
1109
- sg.gain.setValueAtTime(0.42 * power, t);
1110
- sg.gain.exponentialRampToValueAtTime(0.0001, t + 0.75);
1111
- sub.connect(sg);
1112
- sg.connect(kitBus);
1113
- sub.start(t);
1114
- sub.stop(t + 0.8);
1115
-
1116
- playKick(t, 1.1 * power, kitBus);
1117
-
1118
- // The crash bright and length come from the kit too: a gong for 国风, a bright
1119
- // metal crash for 赛博, a soft wash for 氛围.
1120
- const crash = ctx.createBufferSource();
1121
- crash.buffer = noiseBuf;
1122
- const bp = ctx.createBiquadFilter();
1123
- bp.type = 'bandpass';
1124
- bp.frequency.value = K.impact.crash;
1125
- bp.Q.value = K.impact.crashQ;
1126
- const cg = ctx.createGain();
1127
- cg.gain.setValueAtTime(0.3 * power, t);
1128
- cg.gain.exponentialRampToValueAtTime(0.0001, t + 0.9);
1129
- crash.connect(bp);
1130
- bp.connect(cg);
1131
- cg.connect(kitBus);
1132
- cg.connect(verb);
1133
- crash.start(t);
1134
- crash.stop(t + 1);
1135
-
1136
- // A stab makes the hit harmonic instead of a bare boom.
1137
- const stabNotes = [0, 7, 12];
1138
- for (const off of stabNotes) {
1139
- const osc = ctx.createOscillator();
1140
- osc.type = K.impact.stab;
1141
- osc.frequency.value = midiToHz(rootMidi + off);
1142
- const g = ctx.createGain();
1143
- g.gain.setValueAtTime(0.0001, t);
1144
- g.gain.linearRampToValueAtTime(K.impact.stabPeak * power, t + 0.008);
1145
- g.gain.exponentialRampToValueAtTime(0.0001, t + 0.5);
1146
- osc.connect(g);
1147
- g.connect(kitBus);
1148
- g.connect(verb);
1149
- osc.start(t);
1150
- osc.stop(t + 0.55);
1151
- }
1152
- };
1153
-
1154
- // ---- sidechain: the bed ducks under every cue ----
1155
- bed.gain.setValueAtTime(1, 0);
1156
- for (const cue of cues) {
1157
- const from = Math.max(0, cue.time - 0.1);
1158
- const depth = 0.2 + (1 - Math.min(1, cue.power)) * 0.28;
1159
- bed.gain.setValueAtTime(1, from);
1160
- bed.gain.linearRampToValueAtTime(depth, cue.time);
1161
- bed.gain.setValueAtTime(depth, cue.time + 0.045);
1162
- bed.gain.linearRampToValueAtTime(1, Math.min(duration, cue.time + 0.2));
1163
- }
1164
-
1165
- // ---- arrangement: bars of real groove, with a lead-in and a written ending ----
1166
- //
1167
- // The clip is not "N copies of one bar". A three-part shape is imposed instead:
1168
- // the first bar thins out as a lead-in, the middle bars rotate through the pattern
1169
- // variants so no two consecutive bars are identical, and the last bar stops the
1170
- // groove and resolves on the tonic. Previously every bar repeated one hardcoded
1171
- // pattern and the piece simply faded, which read as the same loop cut off.
1172
- const totalBars = Math.max(1, Math.ceil(duration / bar));
1173
- const cueTimes = new Set(cues.map((c) => c.time.toFixed(2)));
1174
- const isCueAt = (t) => cueTimes.has(t.toFixed(2));
1175
-
1176
- const variants = GROOVE_PATTERNS[spec.groove] || GROOVE_PATTERNS.backbeat;
1177
- const endingBar = totalBars - 1;
1178
- const endingT = endingBar * bar;
1179
- // Only spend a bar on a lead-in when the clip is long enough to afford one.
1180
- const hasIntro = totalBars >= 3;
1181
- const tonic = spec.root + (scale[spec.chords[0] % scale.length] || 0);
1182
-
1183
- for (let b = 0; b < totalBars; b++) {
1184
- const barT = b * bar;
1185
- if (barT >= duration) break;
1186
- const isEnding = b === endingBar;
1187
- const isIntro = hasIntro && b === 0;
1188
- const barLen = Math.min(bar, duration - barT);
1189
- const stepDur = bar / 16;
1190
- const chordIdx = spec.chords[b % spec.chords.length];
1191
- const rootNote = spec.root + (scale[chordIdx % scale.length] || 0);
1192
-
1193
- if (isEnding) {
1194
- // Hold the tonic under the cadence for as long as the clip still has room.
1195
- const ring = Math.max(0.4, duration - barT);
1196
- playPad(tonic, barT, ring, 0.2);
1197
- playBass(tonic - 12, barT, ring, 0.95);
1198
- } else {
1199
- // A final bar usually gets cut short; only start a pad the bar can hold.
1200
- if (barLen > beat * 1.2) playPad(rootNote, barT, barLen, isIntro ? 0.12 : 0.16);
1201
- playBass(rootNote - 12, barT, Math.min(beat * 1.8, barLen), isIntro ? 0.7 : 0.9);
1202
- }
1203
-
1204
- const pattern = variants[b % variants.length];
1205
- for (let s = 0; s < 16; s++) {
1206
- const t = barT + s * stepDur;
1207
- if (t >= duration - 0.02) break;
1208
- if (isCueAt(t)) continue; // the impact covers this step
1209
184
 
1210
- if (isEnding) {
1211
- // One downbeat, then silence so the resolution is heard rather than filled.
1212
- if (s === 0) playKick(t, 1, bed);
1213
- continue;
185
+ const fadeLen = Math.min(Math.floor(out.sampleRate * 0.4), targetLength);
186
+ for (let ch = 0; ch < 2; ch += 1) {
187
+ const data = out.getChannelData(ch);
188
+ for (let i = 0; i < fadeLen; i += 1) data[targetLength - 1 - i] *= i / fadeLen;
1214
189
  }
1215
- if (isIntro) {
1216
- // Thin lead-in: a soft pulse and one open hat, no full groove yet.
1217
- if (s === 0) playKick(t, 0.55, bed);
1218
- if (s === 8) playHat(t, 0.3, bed, true);
1219
- continue;
1220
- }
1221
-
1222
- const mark = pattern[s] || '.';
1223
- if (mark === 'K') playKick(t, 1, bed);
1224
- else if (mark === 'k') playKick(t, 0.7, bed);
1225
- else if (mark === 'X') { playKick(t, 1, bed); playSnare(t, 0.8, bed); }
1226
- else if (mark === 'S') playSnare(t, 0.88, bed);
1227
- else if (mark === 'H') playHat(t, 0.7, bed, false);
1228
- else if (mark === 'h') playHat(t, 0.45, bed, false);
1229
- else if (mark === 'o') playHat(t, 0.6, bed, true);
1230
- }
1231
-
1232
- // Melodic movement, rotated per bar so the phrase does not repeat verbatim.
1233
- if (!isEnding) {
1234
- const steps = isIntro ? [0] : [0, 6 + (b % 2) * 2, 10];
1235
- steps.forEach((s, stepIndex) => {
1236
- const t = barT + s * stepDur;
1237
- if (t >= duration - 0.05) return;
1238
- if (isCueAt(t)) return;
1239
- const degree = scale[Math.floor(rng() * scale.length)];
1240
- const octave = rng() > 0.45 ? 12 : 0;
1241
- const midi = rootNote + degree + octave;
1242
- const vol = 0.13 + rng() * 0.05;
1243
- // The second instrument answers on alternating steps, so a phrase is a交换
1244
- // between two timbres rather than one colour repeating.
1245
- const answer = stepIndex % 2 === 1 && K.answer;
1246
- if (K.arpeggio) {
1247
- // 8-bit lead writing: cycle the chord quickly instead of holding one note.
1248
- const arp = [midi, midi + 4, midi + 7, midi + 12];
1249
- const gap = Math.min(0.055, stepDur / 4);
1250
- arp.forEach((note, i) => playPluck(note, t + i * gap, vol * 0.85, gap * 3.2, i % 2 === 1));
1251
- } else {
1252
- playPluck(midi, t, vol, spec.pluckDecay, answer);
1253
- }
1254
- });
190
+ return out;
1255
191
  }
1256
192
 
1257
- // Fill into the next cut when one lands on this bar's end.
1258
- const nextCut = isEnding ? undefined
1259
- : cues.find((c) => c.time > barT + bar * 0.6 && c.time < barT + bar + 0.02);
1260
- if (nextCut && beat > 0.12) {
1261
- const fillStart = nextCut.time - beat * 0.5;
1262
- for (let k = 0; k < 3; k++) {
1263
- const t = fillStart + k * (beat * 0.5) / 3;
1264
- if (t > barT && t < duration - 0.02 && !isCueAt(t)) playSnare(t, 0.35 + k * 0.18, bed);
1265
- }
1266
- }
1267
- }
1268
-
1269
- // ---- the cuts themselves: riser then impact, sample-accurate ----
1270
- for (const cue of cues) {
1271
- playRiser(cue.time, cue.power);
1272
- const chordIdx = Math.max(0, Math.floor(cue.time / bar)) % spec.chords.length;
1273
- const rootNote = spec.root + (scale[spec.chords[chordIdx] % scale.length] || 0);
1274
- playImpact(cue.time, cue.power * spec.impact, rootNote);
1275
- }
1276
-
1277
- // ---- the ending: a real cadence on the tonic, not a cut ----
1278
- //
1279
- // A tonic triad that rings to the last sample, plus one impact as the final button.
1280
- // This is what makes a five-second clip feel finished instead of looped-and-stopped.
1281
- if (endingT < duration - 0.05) {
1282
- const ring = duration - endingT;
1283
- // Keep the cadence out of any sidechain dip that lands on the same downbeat.
1284
- bed.gain.setValueAtTime(1, endingT);
1285
- for (const degree of [0, 2, 4]) {
1286
- const tone = tonic + (scale[degree % scale.length] || 0);
1287
- playPluck(tone + 12, endingT, 0.1, Math.min(ring, 1.5));
1288
- playPluck(tone, endingT + 0.012, 0.08, Math.min(ring, 1.5));
1289
- }
1290
- playPluck(tonic + 24, endingT, 0.07, Math.min(ring, 1.2));
1291
- if (!isCueAt(endingT)) playImpact(endingT, 0.7 * spec.impact, tonic);
1292
- }
1293
-
1294
- const buffer = await ctx.startRendering();
1295
- // Report the tempo actually used, so a caller can log why the cuts fit.
1296
- buffer.__groove = {
1297
- bpm, bar, fit: Number(fit.toFixed(3)), cues: cues.length,
1298
- bars: totalBars, groove: spec.groove, variants: variants.length,
1299
- };
1300
- return buffer;
1301
- }
1302
-
1303
- /** Audition a score through the speakers. */
1304
- function previewMusic(promptOrPreset, duration, options = {}) {
1305
- const ctx = new AudioContext();
1306
- let stopped = false;
1307
- let source = null;
1308
-
1309
- const pending = renderMusic(promptOrPreset, { ...options, duration, sampleRate: ctx.sampleRate })
1310
- .then((buffer) => {
1311
- if (stopped) { void ctx.close(); return; }
1312
- source = ctx.createBufferSource();
1313
- source.buffer = buffer;
1314
- source.connect(ctx.destination);
1315
- source.onended = () => { void ctx.close(); };
1316
- source.start(0);
1317
- })
1318
- .catch(() => { void ctx.close(); });
1319
-
1320
- return {
1321
- stop() {
1322
- stopped = true;
1323
- try { if (source) source.stop(); } catch { /* already ended */ }
1324
- void pending;
1325
- void ctx.close();
1326
- },
1327
- };
1328
- }
1329
-
1330
- /** Decode a user-supplied file and fit it to the clip length. */
1331
- async function audioFileToBuffer(file, duration, sampleRate) {
1332
- const bytes = await file.arrayBuffer();
1333
- const ctx = new OfflineAudioContext(2, Math.ceil(duration * (sampleRate || 44100)), sampleRate || 44100);
1334
- const decoded = await ctx.decodeAudioData(bytes);
1335
- const targetLength = Math.ceil(duration * decoded.sampleRate);
1336
- const out = ctx.createBuffer(2, targetLength, decoded.sampleRate);
1337
-
1338
- for (let ch = 0; ch < 2; ch++) {
1339
- const from = decoded.getChannelData(Math.min(ch, decoded.numberOfChannels - 1));
1340
- const to = out.getChannelData(ch);
1341
- const take = Math.min(targetLength, from.length);
1342
- to.set(from.subarray(0, take));
1343
- if (take < targetLength && from.length > 0) {
1344
- let offset = take;
1345
- while (offset < targetLength) {
1346
- const chunk = Math.min(from.length, targetLength - offset);
1347
- to.set(from.subarray(0, chunk), offset);
1348
- offset += chunk;
1349
- }
1350
- }
1351
- }
1352
-
1353
- const fadeLen = Math.min(Math.floor(out.sampleRate * 0.4), targetLength);
1354
- for (let ch = 0; ch < 2; ch++) {
1355
- const data = out.getChannelData(ch);
1356
- for (let i = 0; i < fadeLen; i++) data[targetLength - 1 - i] *= i / fadeLen;
1357
- }
1358
- return out;
1359
- }
1360
-
1361
-
1362
-
1363
-
1364
193
  /**
1365
194
  * Container preference, best first.
1366
195
  *
@@ -1557,8 +386,9 @@ async function audioFileToBuffer(file, duration, sampleRate) {
1557
386
 
1558
387
  /**
1559
388
  * Drive a detached document frame frame by frame and record it through MediaRecorder.
1560
- * @param opts.music - a preset id, or an AudioBuffer for a user-supplied track.
1561
- * Any audio is mixed at render time, so video and score start together.
389
+ * @param opts.scoreBuffer - 模型写的配乐,已经离线渲染好的 AudioBuffer。
390
+ * @param opts.musicBuffer - 用户自己选的音频文件,优先级高于 scoreBuffer。
391
+ * 两者都不给就是无声导出;这里不会凭空合成配乐。
1562
392
  * @param opts.cuts - cut times in seconds; the score is fitted and accented to them.
1563
393
  */
1564
394
  /**
@@ -1602,24 +432,6 @@ async function audioFileToBuffer(file, duration, sampleRate) {
1602
432
  ].filter(Boolean).join(''));
1603
433
  }
1604
434
 
1605
- /**
1606
- * Stable per-clip seed for the score engine.
1607
- *
1608
- * `renderMusic` falls back to one hardcoded seed, so before this every clip in
1609
- * every style drew the identical random sequence — the same melodic contours,
1610
- * the same noise and reverb buffers. Hashing the clip id keeps one clip's score
1611
- * reproducible while letting different clips differ.
1612
- */
1613
- function seedOf(text) {
1614
- let h = 2166136261;
1615
- const s = String(text || 'clip');
1616
- for (let i = 0; i < s.length; i += 1) {
1617
- h ^= s.charCodeAt(i);
1618
- h = Math.imul(h, 16777619);
1619
- }
1620
- return (h >>> 0) || 1;
1621
- }
1622
-
1623
435
  async function recordAnimation(iframe, opts) {
1624
436
  const win = iframe && iframe.contentWindow;
1625
437
  if (!win) throw new Error('预览还没准备好');
@@ -1633,28 +445,16 @@ async function audioFileToBuffer(file, duration, sampleRate) {
1633
445
  const videoTrack = stream.getVideoTracks()[0];
1634
446
  const tracks = [videoTrack];
1635
447
 
1636
- // Audio, when requested, is mixed into the same recording session so the two
1637
- // tracks share one clock.
448
+ // Audio is mixed into the same recording session so the two tracks share one clock.
449
+ // There is no engine behind this: the buffer must be the model's score (or a file
450
+ // the user picked). Missing it is an error, not a reason to synthesize something.
1638
451
  let audioCtx;
1639
452
  let source;
1640
453
  let audioBuffer;
1641
454
  let analyser;
1642
- const wantsMusic = opts.music && opts.music !== 'none';
1643
- if (wantsMusic || opts.musicBuffer) {
1644
- if (opts.musicBuffer) {
1645
- audioBuffer = opts.musicBuffer;
1646
- } else if (opts.scoreBuffer) {
1647
- // Reuse the buffer already rendered for auditioning: same audio as the preview.
1648
- audioBuffer = opts.scoreBuffer;
1649
- } else {
1650
- if (opts.onStage) opts.onStage('正在合成配乐…');
1651
- audioBuffer = await renderMusic(opts.music, {
1652
- duration: opts.duration,
1653
- seed: opts.musicSeed ?? seedOf(opts.clipId),
1654
- cuts: opts.cuts,
1655
- cues: opts.cues,
1656
- });
1657
- }
455
+ const wantsAudio = opts.scoreBuffer || opts.musicBuffer;
456
+ if (wantsAudio) {
457
+ audioBuffer = opts.musicBuffer || opts.scoreBuffer;
1658
458
  audioCtx = new AudioContext();
1659
459
  const destination = audioCtx.createMediaStreamDestination();
1660
460
  source = audioCtx.createBufferSource();
@@ -2094,25 +894,30 @@ async function audioFileToBuffer(file, duration, sampleRate) {
2094
894
  }, [stopAudio]);
2095
895
 
2096
896
  // Render the score once so auditioning is instant and export reuses the buffer.
897
+ // 只有模型写的 score_js 会被渲染:没有兜底引擎,拿不到就是没声音,并说明原因。
2097
898
  useEffect(() => {
2098
899
  if (custom) { scoreRef.current = custom; return undefined; }
2099
- const hasModelScore = !!anim.scoreUrl;
2100
- if (!hasModelScore && (!music || music === 'none')) { scoreRef.current = null; return undefined; }
900
+ if (music === 'none') { scoreRef.current = null; setScoreBuffer(null); return undefined; }
901
+ if (!anim.scoreUrl) {
902
+ scoreRef.current = null;
903
+ setScoreBuffer(null);
904
+ setNote('这一版没有配乐代码,无法出声;请让 Agent 重新提交 score_js');
905
+ return undefined;
906
+ }
2101
907
  let cancelled = false;
2102
908
  scoreRef.current = null;
2103
909
 
2104
- const build = hasModelScore
2105
- // Prefer the score the model wrote for this clip; the engine is the fallback.
2106
- ? () => renderModelScore(anim.scoreUrl, { duration, cuts, cues, beats })
2107
- .then((buf) => buf || renderMusic(music || 'cinematic', { duration, cuts, cues, seed: seedOf(anim.id) }))
2108
- : () => renderMusic(music, { duration, cuts, cues, seed: seedOf(anim.id) });
2109
-
2110
- build()
910
+ renderModelScore(anim.scoreUrl, { duration, cuts, cues, beats })
2111
911
  .then((buf) => {
2112
912
  if (cancelled) return;
913
+ if (!buf) {
914
+ setNote('配乐代码没有渲染出声音,请让 Agent 修 score_js');
915
+ setScoreBuffer(null);
916
+ return;
917
+ }
2113
918
  scoreRef.current = buf;
2114
919
  // Hand the buffer to the timeline so it can draw the true waveform.
2115
- setScoreBuffer(buf || null);
920
+ setScoreBuffer(buf);
2116
921
  })
2117
922
  .catch(() => {});
2118
923
  return () => { cancelled = true; };
@@ -2166,18 +971,20 @@ async function audioFileToBuffer(file, duration, sampleRate) {
2166
971
  setStage('');
2167
972
  try {
2168
973
  // The score may still be rendering when export is pressed; wait for it so the
2169
- // exported audio is the same one the preview played.
974
+ // exported audio is the same one the preview played. 没有兜底引擎:等不到就报错。
2170
975
  let score = scoreRef.current;
2171
976
  if (!score && !custom && anim.scoreUrl) {
2172
977
  setStage('正在合成配乐…');
2173
978
  score = await renderModelScore(anim.scoreUrl, { duration, cuts, cues, beats }).catch(() => null);
2174
979
  }
980
+ if (!custom && music !== 'none' && !score) {
981
+ throw new Error('配乐还没合成出来,无法导出。请让 Agent 重新提交 score_js。');
982
+ }
2175
983
  const { blob, container, hasAudio, audioPeak } = await recordAnimation(frameRef.current, {
2176
984
  duration,
2177
985
  fps,
2178
986
  cuts,
2179
987
  container: activeFormat,
2180
- music: custom ? 'none' : music,
2181
988
  musicBuffer: custom || undefined,
2182
989
  scoreBuffer: score || undefined,
2183
990
  clipId: anim.id,
@@ -2509,6 +1316,10 @@ async function audioFileToBuffer(file, duration, sampleRate) {
2509
1316
  // to the real edits rather than to a fixed tempo.
2510
1317
  const board = state.storyboards.find((b) => b.id === args.storyboard_id)
2511
1318
  || state.storyboards[state.storyboards.length - 1];
1319
+ // The card can only play the model's score if it knows where it lives.
1320
+ // 先读回执里的「配乐地址」,读不到就直接按 id 拼:配乐现在是必填项,每次保存
1321
+ // 都一定写了 <id>.score.js,所以这个地址永远有效——不再依赖回执文案的措辞。
1322
+ const scoreUrl = (new RegExp('配乐地址:([^\\s]+)').exec(text) || [])[1] || `${API}/score/${id}`;
2512
1323
  return {
2513
1324
  ...state,
2514
1325
  animations: [...state.animations, {
@@ -2519,7 +1330,10 @@ async function audioFileToBuffer(file, duration, sampleRate) {
2519
1330
  duration: Number(args.duration) || 8,
2520
1331
  fps: Number(args.fps) || 30,
2521
1332
  music: String(args.music || (board && board.music) || ''),
1333
+ scoreUrl,
2522
1334
  cuts: board ? board.cuts || [] : [],
1335
+ cues: Array.isArray(args.cues) ? args.cues : [],
1336
+ beats: Array.isArray(args.beats) ? args.beats : [],
2523
1337
  warnings: warn ? warn.split(';').map((s) => s.trim()).filter(Boolean) : [],
2524
1338
  }],
2525
1339
  };
@@ -2621,6 +1435,113 @@ async function audioFileToBuffer(file, duration, sampleRate) {
2621
1435
  );
2622
1436
  }
2623
1437
 
1438
+ /**
1439
+ * 样式库视图:把 193 种已上架风格连同参考图一次列全。
1440
+ *
1441
+ * 目录来自 Host 的 /code-animation-api/styles,参考图走 /still/<id>,两者都只
1442
+ * 认已上架的风格,所以这里看到的就是 Agent 能选到的那一批。
1443
+ *
1444
+ * 它注册成 conversation.view,也就是「对话 / 轨迹」旁边多出来的那个页签。
1445
+ */
1446
+ function StyleLibrary({ sessionId, useSessions }) {
1447
+ const selector = useMemo(() => (state) => {
1448
+ const value = state.byId[sessionId]?.projectionValues?.agentPreset;
1449
+ return typeof value === 'string' ? value : undefined;
1450
+ }, [sessionId]);
1451
+ const preset = useSessions(selector);
1452
+ const [data, setData] = useState(null);
1453
+ const [error, setError] = useState('');
1454
+ const [query, setQuery] = useState('');
1455
+ const [series, setSeries] = useState('*');
1456
+ const [zoom, setZoom] = useState('');
1457
+
1458
+ useEffect(() => {
1459
+ let alive = true;
1460
+ fetch(`${API}/styles`)
1461
+ .then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
1462
+ .then((payload) => { if (alive) setData(payload); })
1463
+ .catch((cause) => { if (alive) setError(cause.message || '目录加载失败'); });
1464
+ return () => { alive = false; };
1465
+ }, []);
1466
+
1467
+ useEffect(() => {
1468
+ const onKey = (event) => { if (event.key === 'Escape') setZoom(''); };
1469
+ window.addEventListener('keydown', onKey);
1470
+ return () => window.removeEventListener('keydown', onKey);
1471
+ }, []);
1472
+
1473
+ const all = (data && data.styles) || [];
1474
+ const list = useMemo(() => {
1475
+ const keyword = query.trim().toLowerCase();
1476
+ return all.filter((style) => {
1477
+ if (series !== '*' && style.series !== series) return false;
1478
+ if (!keyword) return true;
1479
+ return [style.id, style.name, style.en, style.description, style.fit, (style.tags || []).join(' ')]
1480
+ .join(' ').toLowerCase().includes(keyword);
1481
+ });
1482
+ }, [all, query, series]);
1483
+
1484
+ const chips = [{ id: '*', name: '全部' }, ...((data && data.series) || [])];
1485
+
1486
+ // 这个页签对所有会话都注册,但只有创作模式的会话有内容可看。
1487
+ if (preset !== 'code-animation') {
1488
+ return h('div', { className: 'cas-lib-view' },
1489
+ h('style', null, CSS),
1490
+ h('div', { className: 'cas-lib-empty' }, '样式库只在创作模式的会话里可用'));
1491
+ }
1492
+
1493
+ return h('div', { className: 'cas-lib-view' },
1494
+ h('style', null, CSS),
1495
+ h('div', { className: 'cas-lib-head' },
1496
+ h('span', { className: 'cas-lib-title' }, '样式库'),
1497
+ h('span', { className: 'cas-lib-count' }, error || `${list.length} / ${all.length}`),
1498
+ h('input', {
1499
+ className: 'cas-lib-search',
1500
+ placeholder: '搜编号、名字、气质或题材,例如 水墨 / 赛博 / B3',
1501
+ value: query,
1502
+ onChange: (event) => setQuery(event.target.value),
1503
+ }),
1504
+ ),
1505
+ h('div', { className: 'cas-lib-chips' },
1506
+ chips.map((chip) => h('button', {
1507
+ key: chip.id,
1508
+ type: 'button',
1509
+ className: 'cas-chip',
1510
+ 'data-on': series === chip.id ? '1' : '0',
1511
+ onClick: () => setSeries(chip.id),
1512
+ }, chip.id === '*' ? chip.name : `${chip.id} ${chip.name}`)),
1513
+ ),
1514
+ h('div', { className: 'cas-lib-body' },
1515
+ error
1516
+ ? h('div', { className: 'cas-lib-empty' }, `目录加载失败:${error}`)
1517
+ : !data
1518
+ ? h('div', { className: 'cas-lib-empty' }, '正在加载…')
1519
+ : !list.length
1520
+ ? h('div', { className: 'cas-lib-empty' }, '没有匹配的风格')
1521
+ : h('div', { className: 'cas-lib-grid' },
1522
+ list.map((style) => h('button', {
1523
+ key: style.id,
1524
+ type: 'button',
1525
+ className: 'cas-lib-card',
1526
+ title: [style.description, style.fit].filter(Boolean).join(' · '),
1527
+ onClick: () => setZoom(style.stillUrl),
1528
+ },
1529
+ h('img', { src: style.stillUrl, alt: `${style.id} ${style.name}`, loading: 'lazy' }),
1530
+ h('div', { className: 'cas-lib-cap' },
1531
+ h('span', { className: 'cas-lib-id' }, style.id),
1532
+ h('span', { className: 'cas-lib-name' }, style.name),
1533
+ h('span', { className: 'cas-lib-desc' }, style.description || style.fit || ''),
1534
+ ),
1535
+ ))),
1536
+ ),
1537
+ zoom
1538
+ ? h('div', {
1539
+ className: 'cas-lightbox',
1540
+ onClick: () => setZoom(''),
1541
+ }, h('img', { src: zoom, alt: '' }))
1542
+ : null);
1543
+ }
1544
+
2624
1545
  return {
2625
1546
  inject: ['slots', 'uiConversation'],
2626
1547
  apply(ctx) {
@@ -2644,6 +1565,16 @@ async function audioFileToBuffer(file, duration, sampleRate) {
2644
1565
  key: 'save_storyboard',
2645
1566
  }, StoryboardRow);
2646
1567
  });
1568
+
1569
+ // 样式库做成「对话 / 轨迹」旁边的第三个页签:view 槽的每个注册就是页签行
1570
+ // 里的一项,内容由同一个组件渲染。非创作模式的会话也能点开,里面只说明
1571
+ // 它属于创作模式,不报错也不空白。
1572
+ ctx.slots.inject('conversation.view', () => ctx.slots.register({
1573
+ name: 'conversation.view',
1574
+ id: 'styles',
1575
+ order: 20,
1576
+ label: '样式库',
1577
+ }, StyleLibrary));
2647
1578
  },
2648
1579
  };
2649
1580
  },