@onjmin/dtm 0.1.66 → 0.1.68

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.js CHANGED
@@ -109,6 +109,7 @@ __export(index_exports, {
109
109
  playNote: () => playNote,
110
110
  playPlacements: () => playPlacements,
111
111
  playSingingMML: () => playSingingMML,
112
+ resolveLoopPoint: () => resolveLoopPoint,
112
113
  setBackgroundActive: () => setBackgroundActive,
113
114
  setDrawOffset: () => setDrawOffset,
114
115
  shiftNotes: () => shiftNotes,
@@ -2775,54 +2776,73 @@ var createSingingVoices = (ctx, destination, options = {}) => {
2775
2776
  forEachSungNote(track, (note, prevVowel) => {
2776
2777
  items.push({ note, prevVowel });
2777
2778
  });
2779
+ if (items.length === 0) return;
2778
2780
  const peak = Math.max(1e-4, track.volume);
2779
- for (const { note, prevVowel } of items) {
2780
- if (session !== streamSession) return;
2781
- while (note.startSec - (ctx.currentTime - anchorTime) > STREAM_LOOKAHEAD_SEC) {
2782
- await new Promise((resolve) => setTimeout(resolve, STREAM_POLL_MS));
2781
+ const loopStartSec = opts?.loopStartSec ?? 0;
2782
+ let loopOffsetSec = 0;
2783
+ let pass = 0;
2784
+ do {
2785
+ for (const { note, prevVowel } of items) {
2783
2786
  if (session !== streamSession) return;
2784
- }
2785
- if (opts?.isAudible && !opts.isAudible(track)) continue;
2786
- const t0 = anchorTime + note.startSec;
2787
- if (model.renderToCache && model.scheduleCached) {
2788
- const renderToCache = model.renderToCache;
2789
- const scheduleCached = model.scheduleCached;
2790
- void (async () => {
2791
- const key = await renderToCache(
2792
- note.syllable,
2793
- prevVowel,
2794
- note.pitch,
2795
- note.durationSec * 1e3
2796
- );
2787
+ if (pass > 0 && note.startSec < loopStartSec - 1e-4) {
2788
+ continue;
2789
+ }
2790
+ if (opts?.loopLengthSec && opts.loopLengthSec > 0 && note.startSec >= loopStartSec + opts.loopLengthSec - 1e-4) {
2791
+ continue;
2792
+ }
2793
+ const startSec = note.startSec + loopOffsetSec;
2794
+ while (startSec - (ctx.currentTime - anchorTime) > STREAM_LOOKAHEAD_SEC) {
2795
+ await new Promise((resolve) => setTimeout(resolve, STREAM_POLL_MS));
2797
2796
  if (session !== streamSession) return;
2798
- if (key) {
2799
- const delay = ctx.currentTime - t0;
2800
- if (delay < 0.05) {
2801
- scheduleCached(key, t0, peak, track.pan);
2802
- opts?.onScheduled?.(track, note, t0);
2803
- } else {
2804
- console.warn(
2805
- `[dtm] Synthesizer late skip: ${note.syllable.kana} at ${note.startSec}s (delayed by ${delay.toFixed(3)}s)`
2806
- );
2807
- opts?.onLateSkip?.(note, delay);
2797
+ }
2798
+ if (opts?.isAudible && !opts.isAudible(track)) continue;
2799
+ const t0 = anchorTime + startSec;
2800
+ if (model.renderToCache && model.scheduleCached) {
2801
+ const renderToCache = model.renderToCache;
2802
+ const scheduleCached = model.scheduleCached;
2803
+ void (async () => {
2804
+ const key = await renderToCache(
2805
+ note.syllable,
2806
+ prevVowel,
2807
+ note.pitch,
2808
+ note.durationSec * 1e3
2809
+ );
2810
+ if (session !== streamSession) return;
2811
+ if (key) {
2812
+ const delay = ctx.currentTime - t0;
2813
+ if (delay < 0.05) {
2814
+ scheduleCached(key, t0, peak, track.pan);
2815
+ opts?.onScheduled?.(track, note, t0);
2816
+ } else {
2817
+ console.warn(
2818
+ `[dtm] Synthesizer late skip: ${note.syllable.kana} at ${startSec}s (delayed by ${delay.toFixed(3)}s)`
2819
+ );
2820
+ opts?.onLateSkip?.(note, delay);
2821
+ }
2808
2822
  }
2809
- }
2810
- })();
2823
+ })();
2824
+ } else {
2825
+ const when = t0 - ctx.currentTime;
2826
+ model(note.syllable, {
2827
+ trackId: "",
2828
+ pitch: note.pitch,
2829
+ velocity: 100,
2830
+ volume: peak,
2831
+ when,
2832
+ duration: note.durationSec,
2833
+ pan: track.pan
2834
+ });
2835
+ opts?.onScheduled?.(track, note, t0);
2836
+ await new Promise((resolve) => setTimeout(resolve, 0));
2837
+ }
2838
+ }
2839
+ if (opts?.loopLengthSec && opts.loopLengthSec > 0) {
2840
+ loopOffsetSec += opts.loopLengthSec;
2841
+ pass++;
2811
2842
  } else {
2812
- const when = t0 - ctx.currentTime;
2813
- model(note.syllable, {
2814
- trackId: "",
2815
- pitch: note.pitch,
2816
- velocity: 100,
2817
- volume: peak,
2818
- when,
2819
- duration: note.durationSec,
2820
- pan: track.pan
2821
- });
2822
- opts?.onScheduled?.(track, note, t0);
2823
- await new Promise((resolve) => setTimeout(resolve, 0));
2843
+ break;
2824
2844
  }
2825
- }
2845
+ } while (session === streamSession);
2826
2846
  };
2827
2847
  for (const track of tracks) void runTrack(track);
2828
2848
  };
@@ -4795,6 +4815,58 @@ var DAW_CSS = `
4795
4815
  margin-left: 8px;
4796
4816
  font-weight: bold;
4797
4817
  }
4818
+ .dtm-player-seek-row {
4819
+ display: flex;
4820
+ align-items: center;
4821
+ gap: 8px;
4822
+ }
4823
+ .dtm-player-seek {
4824
+ flex: 1 1 auto;
4825
+ min-width: 0;
4826
+ height: 12px;
4827
+ margin: 0;
4828
+ background: transparent;
4829
+ cursor: pointer;
4830
+ -webkit-appearance: none;
4831
+ appearance: none;
4832
+ }
4833
+ .dtm-player-seek:disabled { cursor: default; opacity: 0.4; }
4834
+ .dtm-player-seek::-webkit-slider-runnable-track {
4835
+ height: 6px;
4836
+ background: var(--dtm-border2);
4837
+ border: 2px solid var(--c-black);
4838
+ }
4839
+ .dtm-player-seek::-moz-range-track {
4840
+ height: 6px;
4841
+ background: var(--dtm-border2);
4842
+ border: 2px solid var(--c-black);
4843
+ }
4844
+ .dtm-player-seek::-webkit-slider-thumb {
4845
+ -webkit-appearance: none;
4846
+ width: 12px;
4847
+ height: 16px;
4848
+ margin-top: -7px;
4849
+ background: var(--dtm-primary);
4850
+ border: 2px solid var(--c-black);
4851
+ box-shadow: 2px 2px 0 var(--c-black);
4852
+ cursor: pointer;
4853
+ }
4854
+ .dtm-player-seek::-moz-range-thumb {
4855
+ width: 12px;
4856
+ height: 16px;
4857
+ background: var(--dtm-primary);
4858
+ border: 2px solid var(--c-black);
4859
+ box-shadow: 2px 2px 0 var(--c-black);
4860
+ cursor: pointer;
4861
+ border-radius: 0;
4862
+ }
4863
+ .dtm-player-time {
4864
+ flex: 0 0 auto;
4865
+ font-family: 'k8x12', monospace;
4866
+ font-size: 11px;
4867
+ color: var(--dtm-text);
4868
+ white-space: nowrap;
4869
+ }
4798
4870
  .dtm-player-dots {
4799
4871
  margin-left: auto;
4800
4872
  display: flex;
@@ -6071,6 +6143,31 @@ var mountMmlPlayer = (target, mml, options = {}) => {
6071
6143
  beatRow.appendChild(chordEl);
6072
6144
  head.append(playBtn, beatRow, dots, mmlHeader);
6073
6145
  root.appendChild(head);
6146
+ const seekRow = doc.createElement("div");
6147
+ seekRow.className = "dtm-player-seek-row";
6148
+ const seekInput = doc.createElement("input");
6149
+ seekInput.type = "range";
6150
+ seekInput.className = "dtm-player-seek";
6151
+ seekInput.min = "0";
6152
+ seekInput.max = String(Math.max(1, maxStep));
6153
+ seekInput.step = "1";
6154
+ seekInput.value = "0";
6155
+ seekInput.disabled = trackIndices.length === 0;
6156
+ const timeEl = doc.createElement("span");
6157
+ timeEl.className = "dtm-player-time";
6158
+ timeEl.textContent = "0:00 / 0:00";
6159
+ seekRow.append(seekInput, timeEl);
6160
+ root.appendChild(seekRow);
6161
+ const formatTime = (step) => {
6162
+ const totalSec = Math.max(0, step * secondsPerStep);
6163
+ const m = Math.floor(totalSec / 60);
6164
+ const s = Math.floor(totalSec % 60);
6165
+ return `${m}:${String(s).padStart(2, "0")}`;
6166
+ };
6167
+ const updateTimeLabel = (step) => {
6168
+ timeEl.textContent = `${formatTime(step)} / ${formatTime(maxStep)}`;
6169
+ };
6170
+ updateTimeLabel(0);
6074
6171
  const msgArea = doc.createElement("div");
6075
6172
  msgArea.className = "dtm-player-message";
6076
6173
  msgArea.style.display = "none";
@@ -6289,10 +6386,15 @@ var mountMmlPlayer = (target, mml, options = {}) => {
6289
6386
  };
6290
6387
  const renderPlayhead = (step) => {
6291
6388
  const intStep = Math.floor(step);
6389
+ playStep = intStep;
6292
6390
  const beatIndex = Math.floor(step / STEPS_PER_BEAT2) % 4;
6293
6391
  for (let i = 0; i < 4; i++)
6294
6392
  beatDots[i].classList.toggle("dtm-player-beat-dot--on", i === beatIndex);
6295
6393
  barEl.textContent = String(Math.floor(step / STEPS_PER_BAR2) + 1);
6394
+ if (!isSeeking) {
6395
+ seekInput.value = String(Math.min(maxStep, Math.max(0, intStep)));
6396
+ updateTimeLabel(intStep);
6397
+ }
6296
6398
  const chordName = stepChords[intStep] ?? "";
6297
6399
  if (chordEl.textContent !== chordName) {
6298
6400
  chordEl.textContent = chordName;
@@ -6313,9 +6415,12 @@ var mountMmlPlayer = (target, mml, options = {}) => {
6313
6415
  }
6314
6416
  };
6315
6417
  const resetPlayhead = () => {
6418
+ playStep = 0;
6316
6419
  for (const d of beatDots) d.classList.remove("dtm-player-beat-dot--on");
6317
6420
  barEl.textContent = "-";
6318
6421
  chordEl.textContent = "";
6422
+ seekInput.value = "0";
6423
+ updateTimeLabel(0);
6319
6424
  for (const view of laneViews) {
6320
6425
  for (const t of view.tokens) t.el.classList.remove("is-active");
6321
6426
  view.lane.scrollLeft = 0;
@@ -6351,9 +6456,10 @@ var mountMmlPlayer = (target, mml, options = {}) => {
6351
6456
  });
6352
6457
  let playing = false;
6353
6458
  let skipSinging = false;
6459
+ let playStep = 0;
6354
6460
  const setPlayingUI = (on) => {
6355
6461
  playing = on;
6356
- playBtn.innerHTML = icon(on ? "stop" : "play", 12);
6462
+ playBtn.innerHTML = icon(on ? "pause" : "play", 12);
6357
6463
  playBtn.classList.toggle("dtm-player-play--stop", on);
6358
6464
  };
6359
6465
  const finish = () => {
@@ -6367,7 +6473,7 @@ var mountMmlPlayer = (target, mml, options = {}) => {
6367
6473
  masterVolume = volume;
6368
6474
  peekVoices()?.setVolume(trackVolume / 100 * (masterVolume / 100));
6369
6475
  };
6370
- const buildStreamTracks = () => [...lyricTracks.entries()].map(([index, lt]) => {
6476
+ const buildStreamTracks = (fromStep) => [...lyricTracks.entries()].map(([index, lt]) => {
6371
6477
  const seqTrack = seqTracks.find((t) => Number(t.id) === index);
6372
6478
  const sorted = [...seqTrack?.notes ?? []].sort(
6373
6479
  (a, b) => a.startStep - b.startStep
@@ -6378,10 +6484,11 @@ var mountMmlPlayer = (target, mml, options = {}) => {
6378
6484
  const notes = [];
6379
6485
  for (let i = 0; i < count; i++) {
6380
6486
  const n = sorted[i];
6487
+ if (n.startStep < fromStep) continue;
6381
6488
  notes.push({
6382
6489
  syllable: lt.syllables[i],
6383
6490
  pitch: n.pitch + semis,
6384
- startSec: n.startStep * secondsPerStep,
6491
+ startSec: (n.startStep - fromStep) * secondsPerStep,
6385
6492
  durationSec: n.durationSteps * secondsPerStep * gate
6386
6493
  });
6387
6494
  }
@@ -6393,9 +6500,9 @@ var mountMmlPlayer = (target, mml, options = {}) => {
6393
6500
  notes
6394
6501
  };
6395
6502
  });
6396
- const startWhenReady = async () => {
6503
+ const startWhenReady = async (fromStep) => {
6397
6504
  const streaming = voicesAvailable && lyricTracks.size > 0;
6398
- const tracks = streaming ? buildStreamTracks() : [];
6505
+ const tracks = streaming ? buildStreamTracks(fromStep) : [];
6399
6506
  if (streaming) {
6400
6507
  const v = ensureVoices();
6401
6508
  const overlay = showLoadingOverlay(body, {
@@ -6404,7 +6511,7 @@ var mountMmlPlayer = (target, mml, options = {}) => {
6404
6511
  if (!playing || activePlayer !== instance) return;
6405
6512
  skipSinging = true;
6406
6513
  overlay.remove();
6407
- seq.start(0);
6514
+ seq.start(fromStep);
6408
6515
  }
6409
6516
  });
6410
6517
  try {
@@ -6437,7 +6544,7 @@ var mountMmlPlayer = (target, mml, options = {}) => {
6437
6544
  }
6438
6545
  if (!playing || activePlayer !== instance || skipSinging) return;
6439
6546
  }
6440
- seq.start(0);
6547
+ seq.start(fromStep);
6441
6548
  if (streaming && !skipSinging) {
6442
6549
  const v = ensureVoices();
6443
6550
  v.setVolume(trackVolume / 100 * (masterVolume / 100));
@@ -6451,9 +6558,9 @@ var mountMmlPlayer = (target, mml, options = {}) => {
6451
6558
  });
6452
6559
  }
6453
6560
  };
6454
- const play = () => {
6561
+ const play = (fromStep = 0) => {
6455
6562
  if (playing || trackIndices.length === 0) return;
6456
- if (checkConsentAndShow(() => play())) return;
6563
+ if (checkConsentAndShow(() => play(fromStep))) return;
6457
6564
  if (activePlayer && activePlayer !== instance) activePlayer.stop();
6458
6565
  activePlayer = instance;
6459
6566
  skipSinging = false;
@@ -6469,7 +6576,7 @@ var mountMmlPlayer = (target, mml, options = {}) => {
6469
6576
  if (resumes.length > 0) await Promise.all(resumes);
6470
6577
  if (!playing || activePlayer !== instance) return;
6471
6578
  if (voicesAvailable && lyricTracks.size > 0) ensureVoices().reset();
6472
- await startWhenReady();
6579
+ await startWhenReady(fromStep);
6473
6580
  })();
6474
6581
  };
6475
6582
  const stop = () => {
@@ -6478,9 +6585,40 @@ var mountMmlPlayer = (target, mml, options = {}) => {
6478
6585
  peekVoices()?.stopStream();
6479
6586
  finish();
6480
6587
  };
6588
+ const pause = () => {
6589
+ if (!playing) return;
6590
+ seq.stop();
6591
+ peekVoices()?.stopStream();
6592
+ clearJumpTimers();
6593
+ setPlayingUI(false);
6594
+ if (activePlayer === instance) activePlayer = null;
6595
+ options.onStop?.();
6596
+ };
6597
+ let isSeeking = false;
6598
+ const seekTo = (step) => {
6599
+ const clamped = Math.min(maxStep, Math.max(0, Math.round(step)));
6600
+ renderPlayhead(clamped);
6601
+ if (playing) {
6602
+ seq.stop();
6603
+ peekVoices()?.stopStream();
6604
+ clearJumpTimers();
6605
+ setPlayingUI(false);
6606
+ play(clamped);
6607
+ }
6608
+ };
6609
+ seekInput.addEventListener("pointerdown", () => {
6610
+ isSeeking = true;
6611
+ });
6612
+ seekInput.addEventListener("input", () => {
6613
+ renderPlayhead(Number(seekInput.value));
6614
+ });
6615
+ seekInput.addEventListener("change", () => {
6616
+ isSeeking = false;
6617
+ seekTo(Number(seekInput.value));
6618
+ });
6481
6619
  playBtn.addEventListener("click", () => {
6482
- if (playing) stop();
6483
- else play();
6620
+ if (playing) pause();
6621
+ else play(Number(seekInput.value));
6484
6622
  });
6485
6623
  const destroy = () => {
6486
6624
  doc.removeEventListener("click", handleOutsideClick);
@@ -13010,12 +13148,234 @@ var mountDAW = (target, options = {}) => {
13010
13148
  };
13011
13149
 
13012
13150
  // src/headless-singing-player.ts
13013
- var playSingingMML = (_mml, _options = {}) => {
13014
- return Promise.reject(
13015
- new Error(
13016
- "playSingingMML is not implemented yet. See implementation notes at the top of headless-singing-player.ts."
13017
- )
13151
+ var STEPS_PER_BEAT4 = 48;
13152
+ var STEPS_PER_BAR3 = 192;
13153
+ var playSingingMML = async (mml, options = {}) => {
13154
+ const {
13155
+ placements,
13156
+ bpm: parsedBpm,
13157
+ meta,
13158
+ lyrics
13159
+ } = parseMML(mml, {
13160
+ collectLyrics: true
13161
+ });
13162
+ const lyricTracks = lyrics ?? /* @__PURE__ */ new Map();
13163
+ const customVocalByKey = new Map(
13164
+ parseCustomVocals(mml).map((d) => [d.key, d])
13165
+ );
13166
+ const bpm = parsedBpm ?? options.defaultBpm ?? DEFAULT_BPM;
13167
+ const secondsPerStep = 60 / bpm / STEPS_PER_BEAT4;
13168
+ const drumPatternDict = options.drumPatterns ?? DRUM_PATTERNS;
13169
+ const drumPattern = meta.drum ? drumPatternDict[meta.drum] ?? null : null;
13170
+ const drumVolume = meta.drumVolume ?? 80;
13171
+ const trackVolume = meta.volume ?? 100;
13172
+ let masterVolume = options.volume ?? 100;
13173
+ const trackIndices = [...new Set(placements.map((p) => p.trackIndex))].sort(
13174
+ (a, b) => a - b
13018
13175
  );
13176
+ const seqTracks = trackIndices.map((index) => {
13177
+ let id = 0;
13178
+ const notes = placements.filter((p) => p.trackIndex === index).map((p) => ({
13179
+ id: id++,
13180
+ startStep: p.startStep,
13181
+ durationSteps: p.durationSteps,
13182
+ pitch: p.pitch,
13183
+ velocity: p.velocity
13184
+ }));
13185
+ return {
13186
+ id: String(index),
13187
+ volume: trackVolume / 100 * masterVolume,
13188
+ notes
13189
+ };
13190
+ });
13191
+ const ownsCtx = !options.audioContext;
13192
+ const ctx = options.audioContext ?? new AudioContext();
13193
+ const destination = options.destination ?? ctx.destination;
13194
+ const useSynth = options.synth ?? !options.onPlayNote;
13195
+ const synth = useSynth ? createSynth(ctx, destination) : null;
13196
+ const pauseWhenHidden = options.pauseWhenHidden ?? ownsCtx;
13197
+ let playing = false;
13198
+ let destroyed = false;
13199
+ let voices = options.singingVoices ?? null;
13200
+ const buildStreamTracks = (fromStep) => [...lyricTracks.entries()].map(([index, lt]) => {
13201
+ const seqTrack = seqTracks.find((t) => Number(t.id) === index);
13202
+ const sorted = [...seqTrack?.notes ?? []].sort(
13203
+ (a, b) => a.startStep - b.startStep
13204
+ );
13205
+ const gate = (lt.gate ?? DEFAULT_GATE) / 100;
13206
+ const semis = (lt.octave ?? 0) * 12;
13207
+ const count = Math.min(sorted.length, lt.syllables.length);
13208
+ const notes = [];
13209
+ for (let i = 0; i < count; i++) {
13210
+ const n = sorted[i];
13211
+ if (n.startStep < fromStep) continue;
13212
+ notes.push({
13213
+ syllable: lt.syllables[i],
13214
+ pitch: n.pitch + semis,
13215
+ startSec: (n.startStep - fromStep) * secondsPerStep,
13216
+ durationSec: n.durationSteps * secondsPerStep * gate
13217
+ });
13218
+ }
13219
+ return {
13220
+ id: String(index),
13221
+ model: lt.model,
13222
+ volume: vocalVolumeToGain(lt.volume ?? DEFAULT_VOCAL_VOLUME),
13223
+ pan: panToStereo(lt.pan ?? DEFAULT_PAN),
13224
+ notes
13225
+ };
13226
+ });
13227
+ const seq = createSequencer({
13228
+ getTracks: () => seqTracks,
13229
+ getBpm: () => bpm,
13230
+ getPlayStartStep: () => 0,
13231
+ getDrumPattern: () => drumPattern,
13232
+ getSoloTrackId: () => null,
13233
+ getLoop: () => options.loop ?? false,
13234
+ cues: options.cues,
13235
+ onCue: options.onCue,
13236
+ getAudioTime: () => ctx.currentTime,
13237
+ onPlayNote: (e) => {
13238
+ const trackIdx = Number(e.trackId);
13239
+ if (lyricTracks.has(trackIdx)) return;
13240
+ options.onPlayNote?.(e);
13241
+ synth?.playNote(e);
13242
+ },
13243
+ onPlayDrum: (e) => {
13244
+ const velocity = e.velocity * (drumVolume / 100) * (trackVolume / 100) * (masterVolume / 100);
13245
+ options.onPlayDrum?.({ ...e, velocity });
13246
+ synth?.playDrum({ ...e, velocity });
13247
+ },
13248
+ onTick: (step) => {
13249
+ options.onTick?.(step);
13250
+ },
13251
+ onEnd: (_interrupted) => finish(),
13252
+ stepsPerBar: STEPS_PER_BAR3
13253
+ });
13254
+ const finish = () => {
13255
+ if (!playing) return;
13256
+ playing = false;
13257
+ voices?.stopStream();
13258
+ options.onStop?.();
13259
+ };
13260
+ const onVisibilityChange = () => {
13261
+ if (!playing) return;
13262
+ if (document.hidden) {
13263
+ void ctx.suspend();
13264
+ } else if (ctx.state === "suspended") {
13265
+ void ctx.resume();
13266
+ }
13267
+ };
13268
+ if (pauseWhenHidden && typeof document !== "undefined") {
13269
+ document.addEventListener("visibilitychange", onVisibilityChange);
13270
+ }
13271
+ const stop = () => {
13272
+ if (!playing) return;
13273
+ seq.stop();
13274
+ finish();
13275
+ };
13276
+ const setVolume = (volume) => {
13277
+ masterVolume = volume;
13278
+ const effectiveTrackVolume = trackVolume / 100 * masterVolume;
13279
+ for (const t of seqTracks) t.volume = effectiveTrackVolume;
13280
+ voices?.setVolume(trackVolume / 100 * (masterVolume / 100));
13281
+ };
13282
+ const suspend = () => ctx.suspend();
13283
+ const resume = () => ctx.resume();
13284
+ const destroy = () => {
13285
+ seq.stop();
13286
+ playing = false;
13287
+ destroyed = true;
13288
+ voices?.reset();
13289
+ if (pauseWhenHidden && typeof document !== "undefined") {
13290
+ document.removeEventListener("visibilitychange", onVisibilityChange);
13291
+ }
13292
+ if (ownsCtx && ctx.state !== "closed") {
13293
+ void ctx.close();
13294
+ }
13295
+ };
13296
+ const playback = {
13297
+ stop,
13298
+ isPlaying: () => playing,
13299
+ setVolume,
13300
+ suspend,
13301
+ resume,
13302
+ destroy
13303
+ };
13304
+ playing = true;
13305
+ try {
13306
+ const resumes = [];
13307
+ const r = options.onResumeAudio?.();
13308
+ if (r) resumes.push(Promise.resolve(r));
13309
+ if (ctx.state === "suspended") resumes.push(ctx.resume());
13310
+ if (resumes.length > 0) await Promise.all(resumes);
13311
+ if (!playing || destroyed) {
13312
+ return playback;
13313
+ }
13314
+ if (lyricTracks.size > 0) {
13315
+ if (!voices) {
13316
+ voices = createSingingVoices(ctx, destination, {
13317
+ voiceWorkerUrl: options.voiceWorkerUrl
13318
+ });
13319
+ }
13320
+ if (customVocalByKey.size > 0 && voices.registerVoicebanks) {
13321
+ voices.registerVoicebanks(
13322
+ Object.fromEntries([...customVocalByKey].map(([k, d]) => [k, d.url]))
13323
+ );
13324
+ }
13325
+ const streamTracks = buildStreamTracks(0);
13326
+ await voices.loadModels(streamTracks.map((t) => t.model));
13327
+ if (!playing || destroyed) {
13328
+ return playback;
13329
+ }
13330
+ await voices.warm(streamTracks, PREWARM_NOTES);
13331
+ if (!playing || destroyed) {
13332
+ return playback;
13333
+ }
13334
+ let loopLengthSec;
13335
+ let loopStartSec;
13336
+ const loopOption = options.loop ?? false;
13337
+ if (loopOption) {
13338
+ let loopStartStep = 0;
13339
+ let loopEndStep = -1;
13340
+ if (typeof loopOption === "object") {
13341
+ loopStartStep = loopOption.start ? resolveLoopPoint(
13342
+ loopOption.start,
13343
+ bpm,
13344
+ STEPS_PER_BAR3,
13345
+ secondsPerStep
13346
+ ) : 0;
13347
+ const endVal = loopOption.end ? resolveLoopPoint(
13348
+ loopOption.end,
13349
+ bpm,
13350
+ STEPS_PER_BAR3,
13351
+ secondsPerStep
13352
+ ) : null;
13353
+ loopEndStep = endVal !== null ? endVal : -1;
13354
+ }
13355
+ if (loopEndStep === -1) {
13356
+ let maxEndStep = 0;
13357
+ for (const p of placements) {
13358
+ maxEndStep = Math.max(maxEndStep, p.startStep + p.durationSteps);
13359
+ }
13360
+ loopEndStep = maxEndStep;
13361
+ }
13362
+ loopStartSec = loopStartStep * secondsPerStep;
13363
+ loopLengthSec = (loopEndStep - loopStartStep) * secondsPerStep;
13364
+ }
13365
+ seq.start(0);
13366
+ voices.setVolume(trackVolume / 100 * (masterVolume / 100));
13367
+ voices.startStream(streamTracks, seq.getStartTime(), {
13368
+ loopLengthSec,
13369
+ loopStartSec
13370
+ });
13371
+ } else {
13372
+ seq.start(0);
13373
+ }
13374
+ } catch (err2) {
13375
+ stop();
13376
+ throw err2;
13377
+ }
13378
+ return playback;
13019
13379
  };
13020
13380
 
13021
13381
  // src/piano-roll.ts
@@ -14375,6 +14735,7 @@ var createDtmStudio = async (options = {}) => {
14375
14735
  playNote,
14376
14736
  playPlacements,
14377
14737
  playSingingMML,
14738
+ resolveLoopPoint,
14378
14739
  setBackgroundActive,
14379
14740
  setDrawOffset,
14380
14741
  shiftNotes,