@onjmin/dtm 0.1.21 → 0.1.22

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/dist/index.mjs CHANGED
@@ -4177,6 +4177,18 @@ var VOICE_IMAGES = {
4177
4177
  var STEPS_PER_BEAT2 = 48;
4178
4178
  var PLAN_TIME = 0.5;
4179
4179
  var TICK_INTERVAL_MS = 20;
4180
+ var resolveLoopPoint = (point, bpm, stepsPerBar, sps) => {
4181
+ if ("step" in point) {
4182
+ return point.step;
4183
+ }
4184
+ if ("bar" in point) {
4185
+ return Math.max(0, point.bar - 1) * stepsPerBar;
4186
+ }
4187
+ if ("seconds" in point) {
4188
+ return point.seconds / sps;
4189
+ }
4190
+ return 0;
4191
+ };
4180
4192
  var createSequencer = (options) => {
4181
4193
  let timeline = [];
4182
4194
  let startTime = 0;
@@ -4186,28 +4198,74 @@ var createSequencer = (options) => {
4186
4198
  let active = false;
4187
4199
  let fromStepValue = 0;
4188
4200
  let trackVolumeMap = /* @__PURE__ */ new Map();
4201
+ let isLooping = false;
4202
+ let loopStartStep = 0;
4203
+ let loopEndStep = 0;
4204
+ let loopStartSec = 0;
4205
+ let loopEndSec = 0;
4206
+ let loopDurationSec = 0;
4207
+ let loopStartIndex = 0;
4208
+ let loopBase = 0;
4209
+ let lastPlayStep = 0;
4189
4210
  const secondsPerStep = () => 60 / options.getBpm() / STEPS_PER_BEAT2;
4211
+ const getWrappedPlayStep = (time, sps) => {
4212
+ if (!isLooping || loopDurationSec <= 0 || time < loopEndSec) {
4213
+ return fromStepValue + time / sps;
4214
+ }
4215
+ const elapsedInLoop = (time - loopEndSec) % loopDurationSec;
4216
+ return loopStartStep + elapsedInLoop / sps;
4217
+ };
4190
4218
  const buildTimeline = (fromStep) => {
4191
4219
  timeline = [];
4192
4220
  trackVolumeMap = /* @__PURE__ */ new Map();
4193
4221
  const sps = secondsPerStep();
4222
+ const bpm = options.getBpm();
4223
+ const stepsPerBar = options.stepsPerBar;
4224
+ const loopOption = options.getLoop?.() ?? false;
4225
+ isLooping = !!loopOption;
4226
+ if (typeof loopOption === "object") {
4227
+ loopStartStep = loopOption.start ? resolveLoopPoint(loopOption.start, bpm, stepsPerBar, sps) : 0;
4228
+ const endVal = loopOption.end ? resolveLoopPoint(loopOption.end, bpm, stepsPerBar, sps) : null;
4229
+ loopEndStep = endVal !== null ? endVal : -1;
4230
+ } else {
4231
+ loopStartStep = 0;
4232
+ loopEndStep = -1;
4233
+ }
4234
+ const startLimit = isLooping ? Math.min(fromStep, loopStartStep) : fromStep;
4235
+ let maxEndStep = 0;
4194
4236
  for (const track of options.getTracks()) {
4195
4237
  trackVolumeMap.set(track.id, track.volume);
4196
4238
  for (const note of track.notes) {
4239
+ if (note.startStep < startLimit) continue;
4197
4240
  const relativeStart = note.startStep - fromStep;
4198
- if (relativeStart < 0) continue;
4199
- const velocity = note.velocity ?? DEFAULT_PLAYBACK_VELOCITY;
4241
+ const when = relativeStart * sps;
4242
+ const duration = note.durationSteps * sps;
4243
+ maxEndStep = Math.max(maxEndStep, note.startStep + note.durationSteps);
4200
4244
  timeline.push({
4201
4245
  trackId: track.id,
4202
4246
  pitch: note.pitch,
4203
4247
  volume: track.volume / 100,
4204
- velocity,
4205
- when: relativeStart * sps,
4206
- duration: note.durationSteps * sps
4248
+ velocity: note.velocity ?? DEFAULT_PLAYBACK_VELOCITY,
4249
+ when,
4250
+ duration
4207
4251
  });
4208
4252
  }
4209
4253
  }
4210
4254
  timeline.sort((a, b) => a.when - b.when);
4255
+ if (loopEndStep === -1) {
4256
+ loopEndStep = maxEndStep;
4257
+ }
4258
+ loopStartSec = (loopStartStep - fromStep) * sps;
4259
+ loopEndSec = (loopEndStep - fromStep) * sps;
4260
+ loopDurationSec = loopEndSec - loopStartSec;
4261
+ loopStartIndex = 0;
4262
+ while (loopStartIndex < timeline.length) {
4263
+ const noteStartStep = fromStep + timeline[loopStartIndex].when / sps;
4264
+ if (noteStartStep >= loopStartStep - 1e-4) {
4265
+ break;
4266
+ }
4267
+ loopStartIndex++;
4268
+ }
4211
4269
  };
4212
4270
  const scheduleTick = () => {
4213
4271
  const sps = secondsPerStep();
@@ -4216,9 +4274,16 @@ var createSequencer = (options) => {
4216
4274
  for (const track of options.getTracks()) {
4217
4275
  trackVolumeMap.set(track.id, track.volume);
4218
4276
  }
4219
- while (nowIndex < timeline.length) {
4220
- const ev = timeline[nowIndex];
4221
- const _when = ev.when - time;
4277
+ while (true) {
4278
+ let ev = timeline[nowIndex];
4279
+ if (nowIndex >= timeline.length || isLooping && ev && ev.when >= loopEndSec) {
4280
+ if (!isLooping || loopDurationSec <= 0) break;
4281
+ nowIndex = loopStartIndex;
4282
+ loopBase += loopDurationSec;
4283
+ ev = timeline[nowIndex];
4284
+ }
4285
+ if (!ev) break;
4286
+ const _when = ev.when + loopBase - time;
4222
4287
  if (_when > PLAN_TIME) break;
4223
4288
  nowIndex++;
4224
4289
  if (soloId && ev.trackId !== soloId) continue;
@@ -4236,7 +4301,7 @@ var createSequencer = (options) => {
4236
4301
  const pattern = options.getDrumPattern();
4237
4302
  if (pattern && pattern.length > 0) {
4238
4303
  const { stepsPerBar } = options;
4239
- const currentStep = (fromStepValue * sps + (options.getAudioTime() - startTime)) / sps;
4304
+ const currentStep = getWrappedPlayStep(time, sps);
4240
4305
  const currentStepInBar = currentStep % stepsPerBar;
4241
4306
  const nextStep = currentStepInBar + 4;
4242
4307
  const crossedBar = currentStepInBar < 4;
@@ -4253,19 +4318,44 @@ var createSequencer = (options) => {
4253
4318
  });
4254
4319
  }
4255
4320
  }
4256
- const last = timeline[timeline.length - 1];
4257
- const lastWhen = last?.when ?? 0;
4258
- const lastDuration = last?.duration ?? 0;
4259
- if (nowIndex >= timeline.length && time > lastWhen + lastDuration + 0.1) {
4260
- stop();
4261
- options.onEnd();
4321
+ if (time >= 0) {
4322
+ const currentStep = getWrappedPlayStep(time, sps);
4323
+ if (options.cues && options.cues.length > 0 && options.onCue) {
4324
+ const bpm = options.getBpm();
4325
+ const stepsPerBar = options.stepsPerBar;
4326
+ const isCueCrossed = (cueStep, prevStep, currStep) => {
4327
+ if (currStep >= prevStep) {
4328
+ return cueStep > prevStep && cueStep <= currStep;
4329
+ } else {
4330
+ const reachedEnd = cueStep > prevStep && cueStep <= loopEndStep;
4331
+ const startedNew = cueStep >= loopStartStep && cueStep <= currStep;
4332
+ return reachedEnd || startedNew;
4333
+ }
4334
+ };
4335
+ for (const cue of options.cues) {
4336
+ const cueStep = resolveLoopPoint(cue.time, bpm, stepsPerBar, sps);
4337
+ if (isCueCrossed(cueStep, lastPlayStep, currentStep)) {
4338
+ options.onCue(cue.id);
4339
+ }
4340
+ }
4341
+ }
4342
+ lastPlayStep = currentStep;
4343
+ }
4344
+ if (!isLooping) {
4345
+ const last = timeline[timeline.length - 1];
4346
+ const lastWhen = last?.when ?? 0;
4347
+ const lastDuration = last?.duration ?? 0;
4348
+ if (nowIndex >= timeline.length && time > lastWhen + lastDuration + 0.1) {
4349
+ stop();
4350
+ options.onEnd();
4351
+ }
4262
4352
  }
4263
4353
  };
4264
4354
  const animate = () => {
4265
4355
  if (!active) return;
4266
4356
  const sps = secondsPerStep();
4267
4357
  const time = options.getAudioTime() - startTime;
4268
- options.onTick(fromStepValue + time / sps);
4358
+ options.onTick(getWrappedPlayStep(time, sps));
4269
4359
  animationId = requestAnimationFrame(animate);
4270
4360
  };
4271
4361
  const stop = () => {
@@ -4287,7 +4377,17 @@ var createSequencer = (options) => {
4287
4377
  if (timeline.length === 0 && !options.getDrumPattern()?.length) return;
4288
4378
  active = true;
4289
4379
  startTime = options.getAudioTime() + START_DELAY;
4380
+ const sps = secondsPerStep();
4290
4381
  nowIndex = 0;
4382
+ while (nowIndex < timeline.length) {
4383
+ const noteStartStep = fromStepValue + timeline[nowIndex].when / sps;
4384
+ if (noteStartStep >= fromStepValue - 1e-4) {
4385
+ break;
4386
+ }
4387
+ nowIndex++;
4388
+ }
4389
+ loopBase = 0;
4390
+ lastPlayStep = fromStepValue - 1e-4;
4291
4391
  intervalId = setInterval(scheduleTick, TICK_INTERVAL_MS);
4292
4392
  animationId = requestAnimationFrame(animate);
4293
4393
  };
@@ -4299,6 +4399,73 @@ var createSequencer = (options) => {
4299
4399
  };
4300
4400
  };
4301
4401
 
4402
+ // src/synth.ts
4403
+ var freqFromPitch = (pitch) => 440 * 2 ** ((pitch - 69) / 12);
4404
+ var createSynth = (ctx, destination = ctx.destination) => {
4405
+ const playNote = (e) => {
4406
+ const osc = ctx.createOscillator();
4407
+ const gain = ctx.createGain();
4408
+ osc.type = "square";
4409
+ osc.frequency.value = freqFromPitch(e.pitch);
4410
+ const t0 = ctx.currentTime + e.when;
4411
+ const peak = Math.max(1e-4, 0.06 * e.volume * 1.5);
4412
+ gain.gain.setValueAtTime(peak, t0);
4413
+ gain.gain.exponentialRampToValueAtTime(1e-3, t0 + e.duration);
4414
+ osc.connect(gain);
4415
+ if (typeof ctx.createStereoPanner === "function" && e.pan) {
4416
+ const panner = ctx.createStereoPanner();
4417
+ panner.pan.value = Math.max(-1, Math.min(1, e.pan));
4418
+ gain.connect(panner);
4419
+ panner.connect(destination);
4420
+ } else {
4421
+ gain.connect(destination);
4422
+ }
4423
+ osc.start(t0);
4424
+ osc.stop(t0 + e.duration + 0.02);
4425
+ };
4426
+ const playDrum = (e) => {
4427
+ const t0 = ctx.currentTime + e.when;
4428
+ const vol = Math.max(1e-4, Math.min(1, e.velocity));
4429
+ const isKick = e.pitch === 35 || e.pitch === 36;
4430
+ const isSnareLike = e.pitch === 38 || e.pitch === 39 || e.pitch === 40;
4431
+ if (isKick) {
4432
+ const osc = ctx.createOscillator();
4433
+ const g2 = ctx.createGain();
4434
+ osc.frequency.setValueAtTime(150, t0);
4435
+ osc.frequency.exponentialRampToValueAtTime(50, t0 + 0.12);
4436
+ g2.gain.setValueAtTime(vol * 0.9, t0);
4437
+ g2.gain.exponentialRampToValueAtTime(1e-3, t0 + 0.18);
4438
+ osc.connect(g2).connect(destination);
4439
+ osc.start(t0);
4440
+ osc.stop(t0 + 0.2);
4441
+ osc.onended = () => osc.disconnect();
4442
+ return;
4443
+ }
4444
+ const dur = isSnareLike ? 0.18 : 0.05;
4445
+ const length = Math.max(1, Math.floor(ctx.sampleRate * dur));
4446
+ const buffer = ctx.createBuffer(1, length, ctx.sampleRate);
4447
+ const data = buffer.getChannelData(0);
4448
+ for (let i = 0; i < length; i++) data[i] = Math.random() * 2 - 1;
4449
+ const src = ctx.createBufferSource();
4450
+ src.buffer = buffer;
4451
+ const filter = ctx.createBiquadFilter();
4452
+ filter.type = isSnareLike ? "bandpass" : "highpass";
4453
+ filter.frequency.value = isSnareLike ? 2e3 : 8e3;
4454
+ const g = ctx.createGain();
4455
+ g.gain.setValueAtTime(vol * (isSnareLike ? 0.7 : 0.4), t0);
4456
+ g.gain.exponentialRampToValueAtTime(1e-3, t0 + dur);
4457
+ src.connect(filter).connect(g).connect(destination);
4458
+ src.start(t0);
4459
+ src.stop(t0 + dur);
4460
+ src.onended = () => {
4461
+ src.disconnect();
4462
+ filter.disconnect();
4463
+ g.disconnect();
4464
+ };
4465
+ };
4466
+ return { playNote, playDrum };
4467
+ };
4468
+
4302
4469
  // src/styles.ts
4303
4470
  var STYLE_ID = "dtm-daw-styles";
4304
4471
  var DAW_CSS = `
@@ -4969,6 +5136,13 @@ var DAW_CSS = `
4969
5136
  font-size: 13px;
4970
5137
  line-height: 1.6;
4971
5138
  }
5139
+ .dtm-modal-body a {
5140
+ color: var(--dtm-primary);
5141
+ text-decoration: underline;
5142
+ }
5143
+ .dtm-modal-body a:hover {
5144
+ color: var(--dtm-accent);
5145
+ }
4972
5146
  .dtm-modal-body h4 {
4973
5147
  margin: 12px 0 6px 0;
4974
5148
  color: var(--dtm-primary);
@@ -5423,7 +5597,6 @@ var showBalloon = (balloonEl) => {
5423
5597
  hideActiveBalloon();
5424
5598
  }, 3e3);
5425
5599
  };
5426
- var freqFromPitch = (pitch) => 440 * 2 ** ((pitch - 69) / 12);
5427
5600
  var mountMmlPlayer = (target, mml, options = {}) => {
5428
5601
  injectStyles(target.ownerDocument ?? document);
5429
5602
  const {
@@ -5494,68 +5667,10 @@ var mountMmlPlayer = (target, mml, options = {}) => {
5494
5667
  if (!audioCtx) audioCtx = new AudioContext();
5495
5668
  return audioCtx;
5496
5669
  };
5497
- const synthPlay = (e) => {
5498
- const ctx = ensureCtx();
5499
- const osc = ctx.createOscillator();
5500
- const gain = ctx.createGain();
5501
- osc.type = "square";
5502
- osc.frequency.value = freqFromPitch(e.pitch);
5503
- const t0 = ctx.currentTime + e.when;
5504
- const peak = Math.max(1e-4, 0.06 * e.volume * 1.5);
5505
- gain.gain.setValueAtTime(peak, t0);
5506
- gain.gain.exponentialRampToValueAtTime(1e-3, t0 + e.duration);
5507
- osc.connect(gain);
5508
- if (typeof ctx.createStereoPanner === "function" && e.pan) {
5509
- const panner = ctx.createStereoPanner();
5510
- panner.pan.value = Math.max(-1, Math.min(1, e.pan));
5511
- gain.connect(panner);
5512
- panner.connect(ctx.destination);
5513
- } else {
5514
- gain.connect(ctx.destination);
5515
- }
5516
- osc.start(t0);
5517
- osc.stop(t0 + e.duration + 0.02);
5518
- };
5519
- const drumSynth = (e) => {
5520
- const ctx = ensureCtx();
5521
- const t0 = ctx.currentTime + e.when;
5522
- const vol = Math.max(1e-4, Math.min(1, e.velocity));
5523
- const isKick = e.pitch === 35 || e.pitch === 36;
5524
- const isSnareLike = e.pitch === 38 || e.pitch === 39 || e.pitch === 40;
5525
- if (isKick) {
5526
- const osc = ctx.createOscillator();
5527
- const g2 = ctx.createGain();
5528
- osc.frequency.setValueAtTime(150, t0);
5529
- osc.frequency.exponentialRampToValueAtTime(50, t0 + 0.12);
5530
- g2.gain.setValueAtTime(vol * 0.9, t0);
5531
- g2.gain.exponentialRampToValueAtTime(1e-3, t0 + 0.18);
5532
- osc.connect(g2).connect(ctx.destination);
5533
- osc.start(t0);
5534
- osc.stop(t0 + 0.2);
5535
- osc.onended = () => osc.disconnect();
5536
- return;
5537
- }
5538
- const dur = isSnareLike ? 0.18 : 0.05;
5539
- const length = Math.max(1, Math.floor(ctx.sampleRate * dur));
5540
- const buffer = ctx.createBuffer(1, length, ctx.sampleRate);
5541
- const data = buffer.getChannelData(0);
5542
- for (let i = 0; i < length; i++) data[i] = Math.random() * 2 - 1;
5543
- const src = ctx.createBufferSource();
5544
- src.buffer = buffer;
5545
- const filter = ctx.createBiquadFilter();
5546
- filter.type = isSnareLike ? "bandpass" : "highpass";
5547
- filter.frequency.value = isSnareLike ? 2e3 : 8e3;
5548
- const g = ctx.createGain();
5549
- g.gain.setValueAtTime(vol * (isSnareLike ? 0.7 : 0.4), t0);
5550
- g.gain.exponentialRampToValueAtTime(1e-3, t0 + dur);
5551
- src.connect(filter).connect(g).connect(ctx.destination);
5552
- src.start(t0);
5553
- src.stop(t0 + dur);
5554
- src.onended = () => {
5555
- src.disconnect();
5556
- filter.disconnect();
5557
- g.disconnect();
5558
- };
5670
+ let synthInstance = null;
5671
+ const ensureSynth = () => {
5672
+ if (!synthInstance) synthInstance = createSynth(ensureCtx());
5673
+ return synthInstance;
5559
5674
  };
5560
5675
  let voices = null;
5561
5676
  const ensureVoices = () => {
@@ -5925,12 +6040,12 @@ var mountMmlPlayer = (target, mml, options = {}) => {
5925
6040
  if (em) jumpEmojiAt(em, e.when);
5926
6041
  if (lyricTracks.has(trackIdx)) return;
5927
6042
  options.onPlayNote?.(e);
5928
- if (useSynth) synthPlay(e);
6043
+ if (useSynth) ensureSynth().playNote(e);
5929
6044
  },
5930
6045
  onPlayDrum: (e) => {
5931
6046
  const velocity = e.velocity * (trackVolume / 100);
5932
6047
  options.onPlayDrum?.({ ...e, velocity });
5933
- if (useSynth) drumSynth({ ...e, velocity });
6048
+ if (useSynth) ensureSynth().playDrum({ ...e, velocity });
5934
6049
  },
5935
6050
  onTick: (step) => {
5936
6051
  renderPlayhead(step);
@@ -6005,13 +6120,19 @@ var mountMmlPlayer = (target, mml, options = {}) => {
6005
6120
  if (activePlayer && activePlayer !== instance) activePlayer.stop();
6006
6121
  activePlayer = instance;
6007
6122
  setPlayingUI(true);
6008
- void options.onResumeAudio?.();
6009
- if (useSynth) {
6010
- const ctx = ensureCtx();
6011
- if (ctx.state === "suspended") void ctx.resume();
6012
- }
6013
- if (voicesAvailable && lyricTracks.size > 0) ensureVoices().reset();
6014
- void startWhenReady();
6123
+ void (async () => {
6124
+ const resumes = [];
6125
+ const r = options.onResumeAudio?.();
6126
+ if (r) resumes.push(r);
6127
+ if (useSynth) {
6128
+ const ctx = ensureCtx();
6129
+ if (ctx.state === "suspended") resumes.push(ctx.resume());
6130
+ }
6131
+ if (resumes.length > 0) await Promise.all(resumes);
6132
+ if (!playing || activePlayer !== instance) return;
6133
+ if (voicesAvailable && lyricTracks.size > 0) ensureVoices().reset();
6134
+ await startWhenReady();
6135
+ })();
6015
6136
  };
6016
6137
  const stop = () => {
6017
6138
  if (!playing) return;
@@ -7026,8 +7147,8 @@ var mountDAW = (target, options = {}) => {
7026
7147
  stepsPerBar: renderConfig.stepsPerBar
7027
7148
  });
7028
7149
  const play = async () => {
7029
- options.onResumeAudio?.();
7030
7150
  if (playbackState === "playing") return;
7151
+ await options.onResumeAudio?.();
7031
7152
  const fromStep = playbackState === "paused" ? pausedPlayStep : playStartStep;
7032
7153
  options.singingVoices?.reset();
7033
7154
  const lyricMap = buildLyricsMap();
@@ -8184,6 +8305,123 @@ var INSTRUMENT_PRESETS = {
8184
8305
  }
8185
8306
  };
8186
8307
 
8308
+ // src/headless-player.ts
8309
+ var STEPS_PER_BAR2 = 192;
8310
+ var playMML = (mml, options = {}) => {
8311
+ const { placements, bpm: parsedBpm, meta } = parseMML(mml);
8312
+ const bpm = parsedBpm ?? options.defaultBpm ?? DEFAULT_BPM;
8313
+ const drumPatternDict = options.drumPatterns ?? DRUM_PATTERNS;
8314
+ const drumPattern = meta.drum ? drumPatternDict[meta.drum] ?? null : null;
8315
+ let masterVolume = meta.volume ?? options.volume ?? 100;
8316
+ const trackIndices = [...new Set(placements.map((p) => p.trackIndex))].sort(
8317
+ (a, b) => a - b
8318
+ );
8319
+ const seqTracks = trackIndices.map((index) => {
8320
+ let id = 0;
8321
+ const notes = placements.filter((p) => p.trackIndex === index).map((p) => ({
8322
+ id: id++,
8323
+ startStep: p.startStep,
8324
+ durationSteps: p.durationSteps,
8325
+ pitch: p.pitch,
8326
+ velocity: 100
8327
+ }));
8328
+ return { id: String(index), volume: masterVolume, notes };
8329
+ });
8330
+ const ownsCtx = !options.audioContext;
8331
+ const ctx = options.audioContext ?? new AudioContext();
8332
+ const destination = options.destination ?? ctx.destination;
8333
+ const useSynth = options.synth ?? !options.onPlayNote;
8334
+ const synth = useSynth ? createSynth(ctx, destination) : null;
8335
+ const pauseWhenHidden = options.pauseWhenHidden ?? ownsCtx;
8336
+ let playing = false;
8337
+ const seq = createSequencer({
8338
+ getTracks: () => seqTracks,
8339
+ getBpm: () => bpm,
8340
+ getPlayStartStep: () => 0,
8341
+ getDrumPattern: () => drumPattern,
8342
+ getSoloTrackId: () => null,
8343
+ getLoop: () => options.loop ?? false,
8344
+ cues: options.cues,
8345
+ onCue: options.onCue,
8346
+ getAudioTime: () => ctx.currentTime,
8347
+ onPlayNote: (e) => {
8348
+ options.onPlayNote?.(e);
8349
+ synth?.playNote(e);
8350
+ },
8351
+ onPlayDrum: (e) => {
8352
+ const velocity = e.velocity * (masterVolume / 100);
8353
+ options.onPlayDrum?.({ ...e, velocity });
8354
+ synth?.playDrum({ ...e, velocity });
8355
+ },
8356
+ onTick: () => {
8357
+ },
8358
+ onEnd: () => finish(),
8359
+ stepsPerBar: STEPS_PER_BAR2
8360
+ });
8361
+ const finish = () => {
8362
+ if (!playing) return;
8363
+ playing = false;
8364
+ options.onStop?.();
8365
+ };
8366
+ const onVisibilityChange = () => {
8367
+ if (!playing) return;
8368
+ if (document.hidden) {
8369
+ void ctx.suspend();
8370
+ } else if (ctx.state === "suspended") {
8371
+ void ctx.resume();
8372
+ }
8373
+ };
8374
+ if (pauseWhenHidden && typeof document !== "undefined") {
8375
+ document.addEventListener("visibilitychange", onVisibilityChange);
8376
+ }
8377
+ playing = true;
8378
+ void (async () => {
8379
+ const resumes = [];
8380
+ const r = options.onResumeAudio?.();
8381
+ if (r) resumes.push(r);
8382
+ if (ctx.state === "suspended") resumes.push(ctx.resume());
8383
+ if (resumes.length > 0) await Promise.all(resumes);
8384
+ if (!playing) return;
8385
+ seq.start(0);
8386
+ })();
8387
+ const stop = () => {
8388
+ if (!playing) return;
8389
+ seq.stop();
8390
+ finish();
8391
+ };
8392
+ const setVolume = (volume) => {
8393
+ masterVolume = volume;
8394
+ for (const t of seqTracks) t.volume = volume;
8395
+ };
8396
+ const suspend = () => ctx.suspend();
8397
+ const resume = () => ctx.resume();
8398
+ const destroy = () => {
8399
+ seq.stop();
8400
+ playing = false;
8401
+ if (pauseWhenHidden && typeof document !== "undefined") {
8402
+ document.removeEventListener("visibilitychange", onVisibilityChange);
8403
+ }
8404
+ if (ownsCtx) void ctx.close();
8405
+ };
8406
+ return {
8407
+ stop,
8408
+ isPlaying: () => playing,
8409
+ setVolume,
8410
+ suspend,
8411
+ resume,
8412
+ destroy
8413
+ };
8414
+ };
8415
+
8416
+ // src/headless-singing-player.ts
8417
+ var playSingingMML = (_mml, _options = {}) => {
8418
+ return Promise.reject(
8419
+ new Error(
8420
+ "playSingingMML is not implemented yet. See implementation notes at the top of headless-singing-player.ts."
8421
+ )
8422
+ );
8423
+ };
8424
+
8187
8425
  // src/piano-roll.ts
8188
8426
  var createPianoRoll = (options, handlers) => {
8189
8427
  const {
@@ -8635,7 +8873,23 @@ var DEFAULT_CDN = {
8635
8873
  soundFontList: "https://rpgen3.github.io/soundfont/mjs/surikov/SoundFont_list.mjs"
8636
8874
  };
8637
8875
  var SOUNDFONT_NAME = "FluidR3_GM_sf2_file";
8638
- var TRACK_ROLES = ["melody", "submelody", "bass", "chord"];
8876
+ var TRACK_ROLES = [
8877
+ "melody",
8878
+ "submelody",
8879
+ "bass",
8880
+ "chord",
8881
+ "t4",
8882
+ "t5",
8883
+ "t6",
8884
+ "t7",
8885
+ "t8",
8886
+ "t9",
8887
+ "t10",
8888
+ "t11",
8889
+ "t12",
8890
+ "t13",
8891
+ "t14"
8892
+ ];
8639
8893
  var resolveDefaultVoiceWorkerUrl = () => {
8640
8894
  try {
8641
8895
  return new URL("./voice-worker.js", import.meta.url).href;
@@ -8667,7 +8921,8 @@ var createDtmStudio = async (options = {}) => {
8667
8921
  drumGain.gain.value = options.drumVolume ?? 1;
8668
8922
  drumGain.connect(audioCtx.destination);
8669
8923
  const resumeAudio = () => {
8670
- if (audioCtx.state === "suspended") void audioCtx.resume();
8924
+ if (audioCtx.state === "suspended") return audioCtx.resume();
8925
+ return Promise.resolve();
8671
8926
  };
8672
8927
  const eng = options.engines ?? {};
8673
8928
  const [SoundFont, SoundFont_drum, SoundFont_list] = await Promise.all([
@@ -8771,36 +9026,44 @@ var createDtmStudio = async (options = {}) => {
8771
9026
  })();
8772
9027
  let nameToKey = {};
8773
9028
  const soundFonts = /* @__PURE__ */ new Map();
8774
- const loadedKeyByTrack = /* @__PURE__ */ new Map();
8775
- const loadSoundFont = async (instrumentKey, trackId) => {
8776
- if (loadedKeyByTrack.get(trackId) === instrumentKey) return;
8777
- try {
8778
- const fullName = `${instrumentKey}_${SOUNDFONT_NAME}`;
8779
- soundFonts.set(
8780
- trackId,
8781
- await SoundFont.load({
8782
- ctx: audioCtx,
8783
- fontName: `_tone_${fullName}`,
8784
- url: SoundFont.toURL(fullName)
8785
- })
8786
- );
8787
- loadedKeyByTrack.set(trackId, instrumentKey);
8788
- } catch (e) {
9029
+ const loadingByKey = /* @__PURE__ */ new Map();
9030
+ const loadInstrument = (instrumentKey) => {
9031
+ if (soundFonts.has(instrumentKey)) return Promise.resolve();
9032
+ const inflight = loadingByKey.get(instrumentKey);
9033
+ if (inflight) return inflight;
9034
+ const fullName = `${instrumentKey}_${SOUNDFONT_NAME}`;
9035
+ const p = SoundFont.load({
9036
+ ctx: audioCtx,
9037
+ fontName: `_tone_${fullName}`,
9038
+ url: SoundFont.toURL(fullName)
9039
+ }).then((sf) => {
9040
+ soundFonts.set(instrumentKey, sf);
9041
+ }).catch((e) => {
8789
9042
  console.error(`[dtm] \u697D\u5668 "${instrumentKey}" \u306E\u8AAD\u307F\u8FBC\u307F\u306B\u5931\u6557`, e);
8790
- }
9043
+ }).finally(() => {
9044
+ loadingByKey.delete(instrumentKey);
9045
+ });
9046
+ loadingByKey.set(instrumentKey, p);
9047
+ return p;
8791
9048
  };
8792
9049
  const defaultPreset = options.defaultPreset ?? "retro_game";
8793
9050
  const instrumentNameFor = (preset, trackId) => preset[trackId] ?? preset.melody;
9051
+ const resolveSoundFont = (presetKey, trackId) => {
9052
+ const preset = INSTRUMENT_PRESETS[presetKey];
9053
+ if (!preset) return void 0;
9054
+ const key = nameToKey[instrumentNameFor(preset, trackId)];
9055
+ return key ? soundFonts.get(key) : void 0;
9056
+ };
8794
9057
  const loadPreset = async (presetKey, trackIds = [...TRACK_ROLES]) => {
8795
9058
  const preset = INSTRUMENT_PRESETS[presetKey];
8796
9059
  if (!preset) return;
8797
9060
  await listReady;
8798
- await Promise.all(
8799
- trackIds.map((trackId) => {
8800
- const key = nameToKey[instrumentNameFor(preset, trackId)];
8801
- return key ? loadSoundFont(key, trackId) : Promise.resolve();
8802
- })
8803
- );
9061
+ const keys = /* @__PURE__ */ new Set();
9062
+ for (const trackId of trackIds) {
9063
+ const key = nameToKey[instrumentNameFor(preset, trackId)];
9064
+ if (key) keys.add(key);
9065
+ }
9066
+ await Promise.all([...keys].map((key) => loadInstrument(key)));
8804
9067
  };
8805
9068
  const applyPreset = async (daw, presetKey, trackIds, loadingTarget) => {
8806
9069
  const wasPlaying = daw.getPlaybackState() === "playing";
@@ -8870,18 +9133,6 @@ var createDtmStudio = async (options = {}) => {
8870
9133
  await listReady;
8871
9134
  nameToKey = await buildNameToKeyMapping();
8872
9135
  await Promise.all([drumReady, loadPreset(defaultPreset)]);
8873
- const playNote = (e) => {
8874
- const sf = soundFonts.get(e.trackId);
8875
- if (!sf) return;
8876
- sf.play({
8877
- ctx: audioCtx,
8878
- destination: masterGain,
8879
- pitch: e.pitch,
8880
- volume: e.volume,
8881
- when: e.when,
8882
- duration: e.duration
8883
- });
8884
- };
8885
9136
  const playDrum = (e) => {
8886
9137
  if (!SoundFont_drum.font) return;
8887
9138
  SoundFont_drum.play({
@@ -8893,19 +9144,6 @@ var createDtmStudio = async (options = {}) => {
8893
9144
  duration: e.duration
8894
9145
  });
8895
9146
  };
8896
- const sfForPlayerTrack = (trackId) => soundFonts.get(TRACK_ROLES[Number(trackId)] ?? "") ?? soundFonts.get(`t${trackId}`);
8897
- const playPlayerNote = (e) => {
8898
- const sf = sfForPlayerTrack(e.trackId);
8899
- if (!sf) return;
8900
- sf.play({
8901
- ctx: audioCtx,
8902
- destination: masterGain,
8903
- pitch: e.pitch,
8904
- volume: e.volume,
8905
- when: e.when,
8906
- duration: e.duration
8907
- });
8908
- };
8909
9147
  const editorPresetSelects = /* @__PURE__ */ new WeakMap();
8910
9148
  const mountedEditors = [];
8911
9149
  const mountedPlayers = [];
@@ -8914,6 +9152,20 @@ var createDtmStudio = async (options = {}) => {
8914
9152
  const { preset, presetUI, ...dawOverrides } = opts;
8915
9153
  const tracks = dawOverrides.tracks ?? TRACKS_SIMPLE;
8916
9154
  const trackIds = tracks.map((t) => t.id);
9155
+ const presetKey = preset && INSTRUMENT_PRESETS[preset] ? preset : defaultPreset;
9156
+ let editorPreset = presetKey;
9157
+ const playNote = (e) => {
9158
+ const sf = resolveSoundFont(editorPreset, e.trackId);
9159
+ if (!sf) return;
9160
+ sf.play({
9161
+ ctx: audioCtx,
9162
+ destination: masterGain,
9163
+ pitch: e.pitch,
9164
+ volume: e.volume,
9165
+ when: e.when,
9166
+ duration: e.duration
9167
+ });
9168
+ };
8917
9169
  const base = {
8918
9170
  getAudioTime: () => audioCtx.currentTime,
8919
9171
  onResumeAudio: resumeAudio,
@@ -8926,7 +9178,6 @@ var createDtmStudio = async (options = {}) => {
8926
9178
  };
8927
9179
  const daw = mountDAW(target, base);
8928
9180
  mountedEditors.push(daw);
8929
- const presetKey = preset && INSTRUMENT_PRESETS[preset] ? preset : defaultPreset;
8930
9181
  const wantPresetUI = presetUI ?? features.presetUI;
8931
9182
  let presetSelect = null;
8932
9183
  if (wantPresetUI) {
@@ -8937,7 +9188,11 @@ var createDtmStudio = async (options = {}) => {
8937
9188
  getTrackIds: () => trackIds,
8938
9189
  value: presetKey,
8939
9190
  loadingTarget: rollEl ?? target,
8940
- position: "prepend"
9191
+ position: "prepend",
9192
+ // 楽器変更時、このエディタの発音解決が使うプリセットも追従させる。
9193
+ onChange: (key) => {
9194
+ editorPreset = key;
9195
+ }
8941
9196
  });
8942
9197
  editorPresetSelects.set(target, presetSelect);
8943
9198
  }
@@ -9041,10 +9296,28 @@ var createDtmStudio = async (options = {}) => {
9041
9296
  return instance;
9042
9297
  };
9043
9298
  const mountPlayer = (target, mml, opts = {}) => {
9044
- const meta = parseMML(mml, {}).meta ?? {};
9045
- if (meta.instrument && INSTRUMENT_PRESETS[meta.instrument]) {
9046
- void loadPreset(meta.instrument);
9047
- }
9299
+ const parsed = parseMML(mml, {});
9300
+ const meta = parsed.meta ?? {};
9301
+ const playerPreset = meta.instrument && INSTRUMENT_PRESETS[meta.instrument] ? meta.instrument : defaultPreset;
9302
+ const trackIndices = [
9303
+ ...new Set(parsed.placements.map((p) => p.trackIndex))
9304
+ ];
9305
+ const trackIds = trackIndices.map((idx) => TRACK_ROLES[idx] ?? `t${idx}`);
9306
+ const loadTrackIds = trackIds.length > 0 ? trackIds : [...TRACK_ROLES];
9307
+ void loadPreset(playerPreset, loadTrackIds);
9308
+ const playPlayerNote = (e) => {
9309
+ const role = TRACK_ROLES[Number(e.trackId)] ?? `t${e.trackId}`;
9310
+ const sf = resolveSoundFont(playerPreset, role);
9311
+ if (!sf) return;
9312
+ sf.play({
9313
+ ctx: audioCtx,
9314
+ destination: masterGain,
9315
+ pitch: e.pitch,
9316
+ volume: e.volume,
9317
+ when: e.when,
9318
+ duration: e.duration
9319
+ });
9320
+ };
9048
9321
  const player = mountMmlPlayer(target, mml, {
9049
9322
  getAudioTime: () => audioCtx.currentTime,
9050
9323
  onResumeAudio: resumeAudio,
@@ -9122,6 +9395,7 @@ export {
9122
9395
  createPianoRoll,
9123
9396
  createSequencer,
9124
9397
  createSingingVoices,
9398
+ createSynth,
9125
9399
  createVoiceRegistry,
9126
9400
  decomposeToMonophonic,
9127
9401
  drawGrid,
@@ -9135,6 +9409,7 @@ export {
9135
9409
  extractMidiPlacementsByTrack,
9136
9410
  fetchSoundFontList,
9137
9411
  formatMmlMeta,
9412
+ freqFromPitch,
9138
9413
  generateRandomPattern,
9139
9414
  getDrawOffset,
9140
9415
  getGridCanvas,
@@ -9157,6 +9432,8 @@ export {
9157
9432
  parseLyrics,
9158
9433
  parseMML,
9159
9434
  parseMmlMeta,
9435
+ playMML,
9436
+ playSingingMML,
9160
9437
  setDrawOffset,
9161
9438
  setupRecorder,
9162
9439
  shiftNotes,