@onjmin/dtm 0.1.67 → 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/README.md CHANGED
@@ -67,6 +67,7 @@ const bgm = playMML("@0 t120 o5 l8 ccggaag4 ffeeddc4", {
67
67
  });
68
68
  bgm.setVolume(50);
69
69
 
70
+ ```ts
70
71
  const chordPlayer = studio.mountChordPlayer(chordEl, "| C | G | Am | F |", {
71
72
  volume: 80,
72
73
  });
@@ -75,6 +76,22 @@ chordPlayer.setVolume(65);
75
76
 
76
77
  ---
77
78
 
79
+ ## 再生機能・API 一覧
80
+
81
+ 用途や UI の有無、歌声対応の有無に応じた各種再生関数が用意されています。
82
+
83
+ | 関数名 | UI描画 (DOM) | 歌声対応 (`@@n`) | 戻り値 | 主な用途と効果 |
84
+ | --- | --- | --- | --- | --- |
85
+ | `playMML(mml, options)` | 不要 | 非対応 | `MmlPlayback` | 楽器・ドラムの MML ヘッドレス再生。軽量内蔵シンセで BGM シームレスループや Cues 同期イベントを発火。 |
86
+ | `playSingingMML(mml, options)` | 不要 | **対応** | `Promise<MmlPlayback>` | 歌声付き MML のヘッドレス再生。画面なしで `.koe` / `klatt` 歌声モデルをプリロードし、伴奏と同期再生。 |
87
+ | `playChords(chordStr, options)` | 不要 | 非対応 | `MmlPlayback` | コード進行のヘッドレス再生。`"\| C \| G \| Am \| F \|"` などの文字列からアルペジオ等の伴奏音を鳴らす。 |
88
+ | `playNote(options)` | 不要 | 非対応 | `void` | 簡易単音発音。SE や音高確認のためのテスト発音。 |
89
+ | `mountMmlPlayer(target, mml, options)` | **必要** | **対応** | `MmlPlayerInstance` | 再生専用 UI ビュー。トークン帯のハイライト、オートスクロール、歌声キャラクター表示を含む埋め込みプレイヤー。 |
90
+ | `mountChordPlayer(target, chordStr, options)` | **必要** | 非対応 | `MmlPlayerInstance` | コード進行再生専用 UI コンポーネント。コードネーム表示と試聴操作。 |
91
+ | `createDtmStudio()` / `mountEditor` | **必要** | **対応** | `DtmStudio` / `DawInstance` | フル機能ピアノロールエディタ UI。SoundFont 演奏、マウス打ち込み編集、歌声合成、録音機能を提供。 |
92
+
93
+ ---
94
+
78
95
  ## モード(`simple` / `advanced`)
79
96
 
80
97
  トラック構成と MIDI の取り込み方が異なる 2 つのモードがあります。`mode` オプションで切り替え、合わせて `tracks` に対応するトラック構成(`TRACKS_SIMPLE` / `TRACKS_ADVANCED`)を渡します。
@@ -235,7 +252,28 @@ const bgm = playMML(mml, {
235
252
  });
236
253
  ```
237
254
 
238
- ### 2. コード進行ヘッドレス再生 (`playChords`)
255
+ ### 2. 歌声付き MML ヘッドレス再生 (`playSingingMML`)
256
+
257
+ 歌声トラック(`@@n`)を含む MML を画面なしで再生するための関数です。歌声モデル(`klatt` または UTAU `.koe` 音源)の非同期プリロード・頭出し合成を行ってから再生を開始するため、`Promise<MmlPlayback>` を返します。
258
+
259
+ ```ts
260
+ import { playSingingMML } from "@onjmin/dtm";
261
+
262
+ const bgm = await playSingingMML("@@klatt カエルのウタガ;\nt120 o4 c d e f;", {
263
+ loop: true, // シームレスループ対応(伴奏と歌声が同期して永久ループ)
264
+ volume: 80,
265
+ voiceWorkerUrl: "./voice-worker.js", // オプション(Worker を指定するとメインスレッドの負荷を軽減)
266
+ });
267
+
268
+ bgm.setVolume(50); // 再生中も音量を即時反映
269
+ bgm.stop(); // 停止
270
+ bgm.destroy(); // 停止+モデルと AudioContext の解放
271
+ ```
272
+
273
+ - 楽器・ドラムの再生機能に加えて、`@@n` トラックの歌声を自動でロード・ストリーミング再生します。
274
+ - `loop: true` や特定範囲の `loop` 指定時も、伴奏と歌声がピッタリ同期してシームレスにループします。
275
+
276
+ ### 3. コード進行ヘッドレス再生 (`playChords`)
239
277
 
240
278
  コード進行テキストを渡して、伴奏パターン(軽量シンセ)のみをヘッドレスで鳴らすための関数です。
241
279
 
@@ -261,8 +299,7 @@ chords.destroy(); // 停止+AudioContextの解放
261
299
  - `"yatsume"`: 八つ目(特定のリズムパターン)
262
300
  - `"alternating"`: 交互に伴奏音を鳴らす
263
301
 
264
- > 歌声合成(`@@n` 歌詞トラック)はヘッドレス再生では未対応です(楽器・ドラムのみ)。
265
- > 歌声が必要なら `mountMmlPlayer` / `createDtmStudio` を使ってください。
302
+ > 歌声合成(`@@n` 歌詞トラック)を含むヘッドレス再生には `playSingingMML` を使用してください。楽器・ドラムのみの軽量再生には `playMML` を使用できます。
266
303
 
267
304
  ---
268
305
 
package/dist/index.d.mts CHANGED
@@ -373,6 +373,10 @@ type StreamPlaybackOptions = {
373
373
  * t0 は発音予定の AudioContext 絶対時刻。
374
374
  */
375
375
  onScheduled?: (track: StreamVoiceTrack, note: StreamVoiceNote, t0: number) => void;
376
+ /** シームレスループ用の1周の長さ(秒)。指定時は曲末に達したら音節インデックスを先頭に戻し内部オフセットへ加算する */
377
+ loopLengthSec?: number;
378
+ /** ループ再開位置(秒)。省略時は 0 */
379
+ loopStartSec?: number;
376
380
  };
377
381
  /**
378
382
  * 歌唱モデルをまとめて管理し、koeデモ式の「先読みストリーミング合成」で歌わせる高レベルヘルパ。
@@ -1010,59 +1014,12 @@ type PlayChordsOptions = PlayMmlOptions & {
1010
1014
  declare const playChords: (chordStr: string, options?: PlayChordsOptions) => MmlPlayback;
1011
1015
 
1012
1016
  /**
1013
- * playSingingMML — 歌声付き(@@n 歌詞トラック)のヘッドレス MML 再生。【未実装スタブ】
1017
+ * playSingingMML — 歌声付き(@@n 歌詞トラック)のヘッドレス MML 再生。
1014
1018
  *
1015
1019
  * 楽器・ドラムのみの {@link playMML} とは別関数として切り出す。歌声は重い WORLD 再合成・
1016
1020
  * worker のホスティング・非同期プリロードを伴うため、軽量な playMML に混ぜず分離する方針。
1017
1021
  * 中身は実質「mountMmlPlayer の音響経路(sequencer + 内蔵synth + 歌声ストリーム配線)から
1018
- * DOM を抜いたもの」になる予定。
1019
- *
1020
- * ─────────────────────────────────────────────────────────────────────────
1021
- * 実装メモ / 懸念事項(実装する人へ)
1022
- * ─────────────────────────────────────────────────────────────────────────
1023
- *
1024
- * ■ 全体の構成(実装方針)
1025
- * 1. parseMML(mml, { collectLyrics: true }) で placements / lyrics / meta を取る。
1026
- * 2. 楽器トラックは playMML と同じく seqTracks 化 → createSequencer + createSynth で再生。
1027
- * 3. 歌声は createSingingVoices(ctx, destination, { voiceWorkerUrl }) を生成し、
1028
- * mml-player.ts の buildStreamTracks 相当で StreamVoiceTrack[] を構築。
1029
- * 4. loadModels → warm を await(ここがローディング相当)。完了後に
1030
- * seq.start(0) と voices.startStream(tracks, seq.getStartTime()) を「同じアンカー」で開始。
1031
- * ※ 既存の正準実装は mml-player.ts の startWhenReady()。ほぼそのまま流用できる。
1032
- *
1033
- * ■ 非同期プリロードがあるため、この関数は Promise を返す(fire-and-forget にしない)。
1034
- * loadModels/warm の完了を待ってから resolve する。待機中に stop された場合は起動しない
1035
- * ガードが要る(mml-player.ts の `if (!playing || activePlayer !== instance) return;` 相当)。
1036
- *
1037
- * ■ worker URL は利用側がホストする(必須)。createSingingVoices の voiceWorkerUrl に渡す。
1038
- * 省略時はメインスレッド合成にフォールバックするが、BGM 用途では worker 必須を推奨。
1039
- * あるいは既存の singingVoices インスタンスを注入できるようにする(createDtmStudio と同様)。
1040
- *
1041
- * ■【最大の懸念】シームレスループと歌声ストリームが噛み合わない
1042
- * - 今回 sequencer に入れたループは「先読みタイムラインを永久に再アーム」する方式
1043
- * (sequencer.ts の loopBase += loopLengthSec)。楽器はこれで継ぎ目なくループする。
1044
- * - 一方 startStream は【1セッション制】。startStream を呼び直すと streamSession が増えて
1045
- * 前のループが中断される(lyrics.ts:1402 / 1421)。よって「次周を裏で先行スケジュール」は
1046
- * 前周をキャンセルしてしまうため不可。
1047
- * - さらに先読みは STREAM_LOOKAHEAD_SEC = 1.5 秒(lyrics.ts:1276)。曲末ぎりぎりで
1048
- * startStream を再発行すると、まだ合成待ちだった末尾〜1.5秒の歌が毎周ドロップする。
1049
- * → 「境界で呼び直す」方式は継ぎ目が汚れる/歌が欠ける。採用しないこと。
1050
- *
1051
- * ■【推奨する解】startStream 自体をループ対応にする(lyrics.ts の小改修)
1052
- * sequencer.ts と同じ要領で、startStream({ loopLengthSec }) を受け取り、runTrack の
1053
- * items を一巡したら i=0 に戻して内部オフセットへ loopLengthSec を加算する。
1054
- * 1セッションのまま先読みが途切れず、継ぎ目もドロップも出ない。loopLengthSec は楽器側
1055
- * (sequencer の loopLengthSec)と同一値を共有すれば、伴奏と歌が同周期で永久に揃う。
1056
- * ※ この改修が入るまでは loop:true を歌入りで使うとズレる。下のガード参照。
1057
- *
1058
- * ■ AudioContext / visibility は playMML と同じ方針(ctx 所有権で suspend 権限を分ける)。
1059
- * ただし注入 ctx + 注入 destination を createSingingVoices にもそのまま渡すこと
1060
- * (歌声と楽器を同じミキサーへ流す)。
1061
- *
1062
- * ■ stop/destroy では seq.stop() に加えて voices.stopStream()(+ 内部生成なら ctx.close)
1063
- * とモデルの後始末(reset)を忘れない。
1064
- *
1065
- * 関連: {@link playMML}(楽器のみ), mml-player.ts(DOM版の正準実装), lyrics.ts(歌声合成)。
1022
+ * DOM を抜いたもの」。
1066
1023
  */
1067
1024
 
1068
1025
  type PlaySingingMmlOptions = PlayMmlOptions & {
@@ -1078,11 +1035,13 @@ type PlaySingingMmlOptions = PlayMmlOptions & {
1078
1035
  singingVoices?: SingingVoices;
1079
1036
  };
1080
1037
  /**
1081
- * 歌声付き MML を画面なしで再生する。【未実装】
1038
+ * 歌声付き MML を画面なしで再生する。
1082
1039
  *
1083
- * @throws 現状は常に未実装エラーを投げる。実装方針は本ファイル冒頭の実装メモを参照。
1040
+ * @param mml MML文字列
1041
+ * @param options 再生オプション
1042
+ * @returns MmlPlayback コントロールオブジェクトを含む Promise
1084
1043
  */
1085
- declare const playSingingMML: (_mml: string, _options?: PlaySingingMmlOptions) => Promise<MmlPlayback>;
1044
+ declare const playSingingMML: (mml: string, options?: PlaySingingMmlOptions) => Promise<MmlPlayback>;
1086
1045
 
1087
1046
  /**
1088
1047
  * インラインSVGアイコン。外部アイコンフォント(MDI等)への依存を避けるため、
@@ -1667,6 +1626,7 @@ type Sequencer = {
1667
1626
  */
1668
1627
  getStartTime: () => number;
1669
1628
  };
1629
+ declare const resolveLoopPoint: (point: LoopPoint, _bpm: number, stepsPerBar: number, sps: number) => number;
1670
1630
  declare const createSequencer: (options: SequencerOptions) => Sequencer;
1671
1631
 
1672
1632
  type SoundFontInstance = {
@@ -1967,4 +1927,4 @@ declare const createSynth: (ctx: AudioContext, destination?: AudioNode, tone?: S
1967
1927
 
1968
1928
  declare const VOICE_IMAGES: Record<string, string>;
1969
1929
 
1970
- export { type AddNoteOptions, type ApplyChordOptions, type ChordPatternType, type ChordPlacement, type ChordPlayerInstance, type ConsumedSyllable, type CoreEventHandlers, type CustomVocalDef, DAW_CSS, DEFAULT_BPM, DEFAULT_GATE, DEFAULT_PAN, DEFAULT_PLAYBACK_VELOCITY, DEFAULT_STEPS_PER_BAR, DEFAULT_VELOCITY, DEFAULT_VOCAL_VOLUME, DRUM_FONT, DRUM_KEYS, DRUM_PATTERNS, type DawInstance, type DawMode, type DawOptions, type DawViewState, type DrumPattern, type DtmStudio, type DtmStudioEngines, type DtmStudioOptions, type ExportMidiOptions, GM_INSTRUMENT_NAMES, INSTRUMENT_PRESETS, type InstrumentPreset, KOE_BASE_URL, KOE_VOICEBANKS, KOE_VOICEBANK_LABELS, KOE_VOICEBANK_TERMS, type KoeVoiceOptions, LinkedList, type LoopConfig, type LoopPoint, type LyricSyllable, type LyricSyncData, type LyricTrack, type LyricsConductor, MAX_VOCAL_VOLUME, MMLCore, type MMLDisplayToken, type MMLNotePlacement, MML_END_MARKER, type MidiExtraction, type MidiNotePlacement, MidiSearchClient, type MidiSearchConfig, type MidiTrackAnalysis, type MmlMeta, type MmlPlayback, type MmlPlayerInstance, type MmlPlayerOptions, type ModeSwitchInstance, type ModeSwitchOptions, type MountChordPlayerOptions, type MountEditorOptions, type MountPlayerOptions, type Note, type NoteData, type NoteRemove, PITCH_MAP, PREWARM_NOTES, type ParseMMLOptions, type ParseMidiFn, type ParsedMML, type PianoRollInstance, type PianoRollOptions, type PicotuneSearchParams, type PicotuneSong, type PitchToken, type PlayChordsOptions, type PlayDrumEvent, type PlayMmlOptions, type PlayNoteEvent, type PlayNoteOptions, type PlayPlacementsOptions, type PlaySingingMmlOptions, type PlaybackCue, type PlaybackState, type PresetSelectInstance, type PresetSelectOptions, type PreviewSoundCallback, type RenderConfig, type Sequencer, type SequencerOptions, type SequencerTrack, type SingingVoices, type SingingVoicesOptions, type StreamPlaybackOptions, type StreamVoiceNote, type StreamVoiceTrack, type Synth, TRACKS_ADVANCED, TRACKS_SIMPLE, type ToolMode, type TrackConfig, VOICE_IMAGES, VOICE_IMAGE_KEY, type VoiceModel, type VoiceRegistry, analyzeMidiTracks, applyHarmonicFilter, applyMonophonic, buildChordPlacements, buildNameToKeyMapping, collectPitchTokens, createAudioContext, createDtmStudio, createKlattVoice, createKoeVoice, createLyricsConductor, createPianoRoll, createSequencer, createSingingVoices, createSynth, createVoiceRegistry, decodeMml, decomposeToMonophonic, drawGrid, drawHeader, drawKeyboard, drawNotes, drawSelectedNotes, drawSelectionRect, encodeMml, exportMIDI, extractMidiPlacements, extractMidiPlacementsByTrack, formatMmlMeta, freqFromPitch, generateRandomPattern, getDrawOffset, getGridCanvas, getGridContext, getGridPosition, getHeaderCanvas, getMidiBPM, getRenderConfig, getXY, icon, init, injectStyles, isChordHeavyTrack, isPlausibleMidiTranscription, isValidHttpUrl, koeUrl, mountChordPlayer, mountDAW, mountMmlPlayer, normalizeLyrics, onClick, panToStereo, parseCustomVocals, parseLyrics, parseMML, parseMmlMeta, playChords, playMML, playNote, playPlacements, playSingingMML, setBackgroundActive, setDrawOffset, shiftNotes, showLoadingOverlay, stripCustomVocals, stripLyrics, stripMmlMeta, vocalVolumeToGain };
1930
+ export { type AddNoteOptions, type ApplyChordOptions, type ChordPatternType, type ChordPlacement, type ChordPlayerInstance, type ConsumedSyllable, type CoreEventHandlers, type CustomVocalDef, DAW_CSS, DEFAULT_BPM, DEFAULT_GATE, DEFAULT_PAN, DEFAULT_PLAYBACK_VELOCITY, DEFAULT_STEPS_PER_BAR, DEFAULT_VELOCITY, DEFAULT_VOCAL_VOLUME, DRUM_FONT, DRUM_KEYS, DRUM_PATTERNS, type DawInstance, type DawMode, type DawOptions, type DawViewState, type DrumPattern, type DtmStudio, type DtmStudioEngines, type DtmStudioOptions, type ExportMidiOptions, GM_INSTRUMENT_NAMES, INSTRUMENT_PRESETS, type InstrumentPreset, KOE_BASE_URL, KOE_VOICEBANKS, KOE_VOICEBANK_LABELS, KOE_VOICEBANK_TERMS, type KoeVoiceOptions, LinkedList, type LoopConfig, type LoopPoint, type LyricSyllable, type LyricSyncData, type LyricTrack, type LyricsConductor, MAX_VOCAL_VOLUME, MMLCore, type MMLDisplayToken, type MMLNotePlacement, MML_END_MARKER, type MidiExtraction, type MidiNotePlacement, MidiSearchClient, type MidiSearchConfig, type MidiTrackAnalysis, type MmlMeta, type MmlPlayback, type MmlPlayerInstance, type MmlPlayerOptions, type ModeSwitchInstance, type ModeSwitchOptions, type MountChordPlayerOptions, type MountEditorOptions, type MountPlayerOptions, type Note, type NoteData, type NoteRemove, PITCH_MAP, PREWARM_NOTES, type ParseMMLOptions, type ParseMidiFn, type ParsedMML, type PianoRollInstance, type PianoRollOptions, type PicotuneSearchParams, type PicotuneSong, type PitchToken, type PlayChordsOptions, type PlayDrumEvent, type PlayMmlOptions, type PlayNoteEvent, type PlayNoteOptions, type PlayPlacementsOptions, type PlaySingingMmlOptions, type PlaybackCue, type PlaybackState, type PresetSelectInstance, type PresetSelectOptions, type PreviewSoundCallback, type RenderConfig, type Sequencer, type SequencerOptions, type SequencerTrack, type SingingVoices, type SingingVoicesOptions, type StreamPlaybackOptions, type StreamVoiceNote, type StreamVoiceTrack, type Synth, TRACKS_ADVANCED, TRACKS_SIMPLE, type ToolMode, type TrackConfig, VOICE_IMAGES, VOICE_IMAGE_KEY, type VoiceModel, type VoiceRegistry, analyzeMidiTracks, applyHarmonicFilter, applyMonophonic, buildChordPlacements, buildNameToKeyMapping, collectPitchTokens, createAudioContext, createDtmStudio, createKlattVoice, createKoeVoice, createLyricsConductor, createPianoRoll, createSequencer, createSingingVoices, createSynth, createVoiceRegistry, decodeMml, decomposeToMonophonic, drawGrid, drawHeader, drawKeyboard, drawNotes, drawSelectedNotes, drawSelectionRect, encodeMml, exportMIDI, extractMidiPlacements, extractMidiPlacementsByTrack, formatMmlMeta, freqFromPitch, generateRandomPattern, getDrawOffset, getGridCanvas, getGridContext, getGridPosition, getHeaderCanvas, getMidiBPM, getRenderConfig, getXY, icon, init, injectStyles, isChordHeavyTrack, isPlausibleMidiTranscription, isValidHttpUrl, koeUrl, mountChordPlayer, mountDAW, mountMmlPlayer, normalizeLyrics, onClick, panToStereo, parseCustomVocals, parseLyrics, parseMML, parseMmlMeta, playChords, playMML, playNote, playPlacements, playSingingMML, resolveLoopPoint, setBackgroundActive, setDrawOffset, shiftNotes, showLoadingOverlay, stripCustomVocals, stripLyrics, stripMmlMeta, vocalVolumeToGain };
package/dist/index.d.ts CHANGED
@@ -373,6 +373,10 @@ type StreamPlaybackOptions = {
373
373
  * t0 は発音予定の AudioContext 絶対時刻。
374
374
  */
375
375
  onScheduled?: (track: StreamVoiceTrack, note: StreamVoiceNote, t0: number) => void;
376
+ /** シームレスループ用の1周の長さ(秒)。指定時は曲末に達したら音節インデックスを先頭に戻し内部オフセットへ加算する */
377
+ loopLengthSec?: number;
378
+ /** ループ再開位置(秒)。省略時は 0 */
379
+ loopStartSec?: number;
376
380
  };
377
381
  /**
378
382
  * 歌唱モデルをまとめて管理し、koeデモ式の「先読みストリーミング合成」で歌わせる高レベルヘルパ。
@@ -1010,59 +1014,12 @@ type PlayChordsOptions = PlayMmlOptions & {
1010
1014
  declare const playChords: (chordStr: string, options?: PlayChordsOptions) => MmlPlayback;
1011
1015
 
1012
1016
  /**
1013
- * playSingingMML — 歌声付き(@@n 歌詞トラック)のヘッドレス MML 再生。【未実装スタブ】
1017
+ * playSingingMML — 歌声付き(@@n 歌詞トラック)のヘッドレス MML 再生。
1014
1018
  *
1015
1019
  * 楽器・ドラムのみの {@link playMML} とは別関数として切り出す。歌声は重い WORLD 再合成・
1016
1020
  * worker のホスティング・非同期プリロードを伴うため、軽量な playMML に混ぜず分離する方針。
1017
1021
  * 中身は実質「mountMmlPlayer の音響経路(sequencer + 内蔵synth + 歌声ストリーム配線)から
1018
- * DOM を抜いたもの」になる予定。
1019
- *
1020
- * ─────────────────────────────────────────────────────────────────────────
1021
- * 実装メモ / 懸念事項(実装する人へ)
1022
- * ─────────────────────────────────────────────────────────────────────────
1023
- *
1024
- * ■ 全体の構成(実装方針)
1025
- * 1. parseMML(mml, { collectLyrics: true }) で placements / lyrics / meta を取る。
1026
- * 2. 楽器トラックは playMML と同じく seqTracks 化 → createSequencer + createSynth で再生。
1027
- * 3. 歌声は createSingingVoices(ctx, destination, { voiceWorkerUrl }) を生成し、
1028
- * mml-player.ts の buildStreamTracks 相当で StreamVoiceTrack[] を構築。
1029
- * 4. loadModels → warm を await(ここがローディング相当)。完了後に
1030
- * seq.start(0) と voices.startStream(tracks, seq.getStartTime()) を「同じアンカー」で開始。
1031
- * ※ 既存の正準実装は mml-player.ts の startWhenReady()。ほぼそのまま流用できる。
1032
- *
1033
- * ■ 非同期プリロードがあるため、この関数は Promise を返す(fire-and-forget にしない)。
1034
- * loadModels/warm の完了を待ってから resolve する。待機中に stop された場合は起動しない
1035
- * ガードが要る(mml-player.ts の `if (!playing || activePlayer !== instance) return;` 相当)。
1036
- *
1037
- * ■ worker URL は利用側がホストする(必須)。createSingingVoices の voiceWorkerUrl に渡す。
1038
- * 省略時はメインスレッド合成にフォールバックするが、BGM 用途では worker 必須を推奨。
1039
- * あるいは既存の singingVoices インスタンスを注入できるようにする(createDtmStudio と同様)。
1040
- *
1041
- * ■【最大の懸念】シームレスループと歌声ストリームが噛み合わない
1042
- * - 今回 sequencer に入れたループは「先読みタイムラインを永久に再アーム」する方式
1043
- * (sequencer.ts の loopBase += loopLengthSec)。楽器はこれで継ぎ目なくループする。
1044
- * - 一方 startStream は【1セッション制】。startStream を呼び直すと streamSession が増えて
1045
- * 前のループが中断される(lyrics.ts:1402 / 1421)。よって「次周を裏で先行スケジュール」は
1046
- * 前周をキャンセルしてしまうため不可。
1047
- * - さらに先読みは STREAM_LOOKAHEAD_SEC = 1.5 秒(lyrics.ts:1276)。曲末ぎりぎりで
1048
- * startStream を再発行すると、まだ合成待ちだった末尾〜1.5秒の歌が毎周ドロップする。
1049
- * → 「境界で呼び直す」方式は継ぎ目が汚れる/歌が欠ける。採用しないこと。
1050
- *
1051
- * ■【推奨する解】startStream 自体をループ対応にする(lyrics.ts の小改修)
1052
- * sequencer.ts と同じ要領で、startStream({ loopLengthSec }) を受け取り、runTrack の
1053
- * items を一巡したら i=0 に戻して内部オフセットへ loopLengthSec を加算する。
1054
- * 1セッションのまま先読みが途切れず、継ぎ目もドロップも出ない。loopLengthSec は楽器側
1055
- * (sequencer の loopLengthSec)と同一値を共有すれば、伴奏と歌が同周期で永久に揃う。
1056
- * ※ この改修が入るまでは loop:true を歌入りで使うとズレる。下のガード参照。
1057
- *
1058
- * ■ AudioContext / visibility は playMML と同じ方針(ctx 所有権で suspend 権限を分ける)。
1059
- * ただし注入 ctx + 注入 destination を createSingingVoices にもそのまま渡すこと
1060
- * (歌声と楽器を同じミキサーへ流す)。
1061
- *
1062
- * ■ stop/destroy では seq.stop() に加えて voices.stopStream()(+ 内部生成なら ctx.close)
1063
- * とモデルの後始末(reset)を忘れない。
1064
- *
1065
- * 関連: {@link playMML}(楽器のみ), mml-player.ts(DOM版の正準実装), lyrics.ts(歌声合成)。
1022
+ * DOM を抜いたもの」。
1066
1023
  */
1067
1024
 
1068
1025
  type PlaySingingMmlOptions = PlayMmlOptions & {
@@ -1078,11 +1035,13 @@ type PlaySingingMmlOptions = PlayMmlOptions & {
1078
1035
  singingVoices?: SingingVoices;
1079
1036
  };
1080
1037
  /**
1081
- * 歌声付き MML を画面なしで再生する。【未実装】
1038
+ * 歌声付き MML を画面なしで再生する。
1082
1039
  *
1083
- * @throws 現状は常に未実装エラーを投げる。実装方針は本ファイル冒頭の実装メモを参照。
1040
+ * @param mml MML文字列
1041
+ * @param options 再生オプション
1042
+ * @returns MmlPlayback コントロールオブジェクトを含む Promise
1084
1043
  */
1085
- declare const playSingingMML: (_mml: string, _options?: PlaySingingMmlOptions) => Promise<MmlPlayback>;
1044
+ declare const playSingingMML: (mml: string, options?: PlaySingingMmlOptions) => Promise<MmlPlayback>;
1086
1045
 
1087
1046
  /**
1088
1047
  * インラインSVGアイコン。外部アイコンフォント(MDI等)への依存を避けるため、
@@ -1667,6 +1626,7 @@ type Sequencer = {
1667
1626
  */
1668
1627
  getStartTime: () => number;
1669
1628
  };
1629
+ declare const resolveLoopPoint: (point: LoopPoint, _bpm: number, stepsPerBar: number, sps: number) => number;
1670
1630
  declare const createSequencer: (options: SequencerOptions) => Sequencer;
1671
1631
 
1672
1632
  type SoundFontInstance = {
@@ -1967,4 +1927,4 @@ declare const createSynth: (ctx: AudioContext, destination?: AudioNode, tone?: S
1967
1927
 
1968
1928
  declare const VOICE_IMAGES: Record<string, string>;
1969
1929
 
1970
- export { type AddNoteOptions, type ApplyChordOptions, type ChordPatternType, type ChordPlacement, type ChordPlayerInstance, type ConsumedSyllable, type CoreEventHandlers, type CustomVocalDef, DAW_CSS, DEFAULT_BPM, DEFAULT_GATE, DEFAULT_PAN, DEFAULT_PLAYBACK_VELOCITY, DEFAULT_STEPS_PER_BAR, DEFAULT_VELOCITY, DEFAULT_VOCAL_VOLUME, DRUM_FONT, DRUM_KEYS, DRUM_PATTERNS, type DawInstance, type DawMode, type DawOptions, type DawViewState, type DrumPattern, type DtmStudio, type DtmStudioEngines, type DtmStudioOptions, type ExportMidiOptions, GM_INSTRUMENT_NAMES, INSTRUMENT_PRESETS, type InstrumentPreset, KOE_BASE_URL, KOE_VOICEBANKS, KOE_VOICEBANK_LABELS, KOE_VOICEBANK_TERMS, type KoeVoiceOptions, LinkedList, type LoopConfig, type LoopPoint, type LyricSyllable, type LyricSyncData, type LyricTrack, type LyricsConductor, MAX_VOCAL_VOLUME, MMLCore, type MMLDisplayToken, type MMLNotePlacement, MML_END_MARKER, type MidiExtraction, type MidiNotePlacement, MidiSearchClient, type MidiSearchConfig, type MidiTrackAnalysis, type MmlMeta, type MmlPlayback, type MmlPlayerInstance, type MmlPlayerOptions, type ModeSwitchInstance, type ModeSwitchOptions, type MountChordPlayerOptions, type MountEditorOptions, type MountPlayerOptions, type Note, type NoteData, type NoteRemove, PITCH_MAP, PREWARM_NOTES, type ParseMMLOptions, type ParseMidiFn, type ParsedMML, type PianoRollInstance, type PianoRollOptions, type PicotuneSearchParams, type PicotuneSong, type PitchToken, type PlayChordsOptions, type PlayDrumEvent, type PlayMmlOptions, type PlayNoteEvent, type PlayNoteOptions, type PlayPlacementsOptions, type PlaySingingMmlOptions, type PlaybackCue, type PlaybackState, type PresetSelectInstance, type PresetSelectOptions, type PreviewSoundCallback, type RenderConfig, type Sequencer, type SequencerOptions, type SequencerTrack, type SingingVoices, type SingingVoicesOptions, type StreamPlaybackOptions, type StreamVoiceNote, type StreamVoiceTrack, type Synth, TRACKS_ADVANCED, TRACKS_SIMPLE, type ToolMode, type TrackConfig, VOICE_IMAGES, VOICE_IMAGE_KEY, type VoiceModel, type VoiceRegistry, analyzeMidiTracks, applyHarmonicFilter, applyMonophonic, buildChordPlacements, buildNameToKeyMapping, collectPitchTokens, createAudioContext, createDtmStudio, createKlattVoice, createKoeVoice, createLyricsConductor, createPianoRoll, createSequencer, createSingingVoices, createSynth, createVoiceRegistry, decodeMml, decomposeToMonophonic, drawGrid, drawHeader, drawKeyboard, drawNotes, drawSelectedNotes, drawSelectionRect, encodeMml, exportMIDI, extractMidiPlacements, extractMidiPlacementsByTrack, formatMmlMeta, freqFromPitch, generateRandomPattern, getDrawOffset, getGridCanvas, getGridContext, getGridPosition, getHeaderCanvas, getMidiBPM, getRenderConfig, getXY, icon, init, injectStyles, isChordHeavyTrack, isPlausibleMidiTranscription, isValidHttpUrl, koeUrl, mountChordPlayer, mountDAW, mountMmlPlayer, normalizeLyrics, onClick, panToStereo, parseCustomVocals, parseLyrics, parseMML, parseMmlMeta, playChords, playMML, playNote, playPlacements, playSingingMML, setBackgroundActive, setDrawOffset, shiftNotes, showLoadingOverlay, stripCustomVocals, stripLyrics, stripMmlMeta, vocalVolumeToGain };
1930
+ export { type AddNoteOptions, type ApplyChordOptions, type ChordPatternType, type ChordPlacement, type ChordPlayerInstance, type ConsumedSyllable, type CoreEventHandlers, type CustomVocalDef, DAW_CSS, DEFAULT_BPM, DEFAULT_GATE, DEFAULT_PAN, DEFAULT_PLAYBACK_VELOCITY, DEFAULT_STEPS_PER_BAR, DEFAULT_VELOCITY, DEFAULT_VOCAL_VOLUME, DRUM_FONT, DRUM_KEYS, DRUM_PATTERNS, type DawInstance, type DawMode, type DawOptions, type DawViewState, type DrumPattern, type DtmStudio, type DtmStudioEngines, type DtmStudioOptions, type ExportMidiOptions, GM_INSTRUMENT_NAMES, INSTRUMENT_PRESETS, type InstrumentPreset, KOE_BASE_URL, KOE_VOICEBANKS, KOE_VOICEBANK_LABELS, KOE_VOICEBANK_TERMS, type KoeVoiceOptions, LinkedList, type LoopConfig, type LoopPoint, type LyricSyllable, type LyricSyncData, type LyricTrack, type LyricsConductor, MAX_VOCAL_VOLUME, MMLCore, type MMLDisplayToken, type MMLNotePlacement, MML_END_MARKER, type MidiExtraction, type MidiNotePlacement, MidiSearchClient, type MidiSearchConfig, type MidiTrackAnalysis, type MmlMeta, type MmlPlayback, type MmlPlayerInstance, type MmlPlayerOptions, type ModeSwitchInstance, type ModeSwitchOptions, type MountChordPlayerOptions, type MountEditorOptions, type MountPlayerOptions, type Note, type NoteData, type NoteRemove, PITCH_MAP, PREWARM_NOTES, type ParseMMLOptions, type ParseMidiFn, type ParsedMML, type PianoRollInstance, type PianoRollOptions, type PicotuneSearchParams, type PicotuneSong, type PitchToken, type PlayChordsOptions, type PlayDrumEvent, type PlayMmlOptions, type PlayNoteEvent, type PlayNoteOptions, type PlayPlacementsOptions, type PlaySingingMmlOptions, type PlaybackCue, type PlaybackState, type PresetSelectInstance, type PresetSelectOptions, type PreviewSoundCallback, type RenderConfig, type Sequencer, type SequencerOptions, type SequencerTrack, type SingingVoices, type SingingVoicesOptions, type StreamPlaybackOptions, type StreamVoiceNote, type StreamVoiceTrack, type Synth, TRACKS_ADVANCED, TRACKS_SIMPLE, type ToolMode, type TrackConfig, VOICE_IMAGES, VOICE_IMAGE_KEY, type VoiceModel, type VoiceRegistry, analyzeMidiTracks, applyHarmonicFilter, applyMonophonic, buildChordPlacements, buildNameToKeyMapping, collectPitchTokens, createAudioContext, createDtmStudio, createKlattVoice, createKoeVoice, createLyricsConductor, createPianoRoll, createSequencer, createSingingVoices, createSynth, createVoiceRegistry, decodeMml, decomposeToMonophonic, drawGrid, drawHeader, drawKeyboard, drawNotes, drawSelectedNotes, drawSelectionRect, encodeMml, exportMIDI, extractMidiPlacements, extractMidiPlacementsByTrack, formatMmlMeta, freqFromPitch, generateRandomPattern, getDrawOffset, getGridCanvas, getGridContext, getGridPosition, getHeaderCanvas, getMidiBPM, getRenderConfig, getXY, icon, init, injectStyles, isChordHeavyTrack, isPlausibleMidiTranscription, isValidHttpUrl, koeUrl, mountChordPlayer, mountDAW, mountMmlPlayer, normalizeLyrics, onClick, panToStereo, parseCustomVocals, parseLyrics, parseMML, parseMmlMeta, playChords, playMML, playNote, playPlacements, playSingingMML, resolveLoopPoint, setBackgroundActive, setDrawOffset, shiftNotes, showLoadingOverlay, stripCustomVocals, stripLyrics, stripMmlMeta, vocalVolumeToGain };
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
  };
@@ -13128,12 +13148,234 @@ var mountDAW = (target, options = {}) => {
13128
13148
  };
13129
13149
 
13130
13150
  // src/headless-singing-player.ts
13131
- var playSingingMML = (_mml, _options = {}) => {
13132
- return Promise.reject(
13133
- new Error(
13134
- "playSingingMML is not implemented yet. See implementation notes at the top of headless-singing-player.ts."
13135
- )
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])
13136
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
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;
13137
13379
  };
13138
13380
 
13139
13381
  // src/piano-roll.ts
@@ -14493,6 +14735,7 @@ var createDtmStudio = async (options = {}) => {
14493
14735
  playNote,
14494
14736
  playPlacements,
14495
14737
  playSingingMML,
14738
+ resolveLoopPoint,
14496
14739
  setBackgroundActive,
14497
14740
  setDrawOffset,
14498
14741
  shiftNotes,
package/dist/index.mjs CHANGED
@@ -2653,54 +2653,73 @@ var createSingingVoices = (ctx, destination, options = {}) => {
2653
2653
  forEachSungNote(track, (note, prevVowel) => {
2654
2654
  items.push({ note, prevVowel });
2655
2655
  });
2656
+ if (items.length === 0) return;
2656
2657
  const peak = Math.max(1e-4, track.volume);
2657
- for (const { note, prevVowel } of items) {
2658
- if (session !== streamSession) return;
2659
- while (note.startSec - (ctx.currentTime - anchorTime) > STREAM_LOOKAHEAD_SEC) {
2660
- await new Promise((resolve) => setTimeout(resolve, STREAM_POLL_MS));
2658
+ const loopStartSec = opts?.loopStartSec ?? 0;
2659
+ let loopOffsetSec = 0;
2660
+ let pass = 0;
2661
+ do {
2662
+ for (const { note, prevVowel } of items) {
2661
2663
  if (session !== streamSession) return;
2662
- }
2663
- if (opts?.isAudible && !opts.isAudible(track)) continue;
2664
- const t0 = anchorTime + note.startSec;
2665
- if (model.renderToCache && model.scheduleCached) {
2666
- const renderToCache = model.renderToCache;
2667
- const scheduleCached = model.scheduleCached;
2668
- void (async () => {
2669
- const key = await renderToCache(
2670
- note.syllable,
2671
- prevVowel,
2672
- note.pitch,
2673
- note.durationSec * 1e3
2674
- );
2664
+ if (pass > 0 && note.startSec < loopStartSec - 1e-4) {
2665
+ continue;
2666
+ }
2667
+ if (opts?.loopLengthSec && opts.loopLengthSec > 0 && note.startSec >= loopStartSec + opts.loopLengthSec - 1e-4) {
2668
+ continue;
2669
+ }
2670
+ const startSec = note.startSec + loopOffsetSec;
2671
+ while (startSec - (ctx.currentTime - anchorTime) > STREAM_LOOKAHEAD_SEC) {
2672
+ await new Promise((resolve) => setTimeout(resolve, STREAM_POLL_MS));
2675
2673
  if (session !== streamSession) return;
2676
- if (key) {
2677
- const delay = ctx.currentTime - t0;
2678
- if (delay < 0.05) {
2679
- scheduleCached(key, t0, peak, track.pan);
2680
- opts?.onScheduled?.(track, note, t0);
2681
- } else {
2682
- console.warn(
2683
- `[dtm] Synthesizer late skip: ${note.syllable.kana} at ${note.startSec}s (delayed by ${delay.toFixed(3)}s)`
2684
- );
2685
- opts?.onLateSkip?.(note, delay);
2674
+ }
2675
+ if (opts?.isAudible && !opts.isAudible(track)) continue;
2676
+ const t0 = anchorTime + startSec;
2677
+ if (model.renderToCache && model.scheduleCached) {
2678
+ const renderToCache = model.renderToCache;
2679
+ const scheduleCached = model.scheduleCached;
2680
+ void (async () => {
2681
+ const key = await renderToCache(
2682
+ note.syllable,
2683
+ prevVowel,
2684
+ note.pitch,
2685
+ note.durationSec * 1e3
2686
+ );
2687
+ if (session !== streamSession) return;
2688
+ if (key) {
2689
+ const delay = ctx.currentTime - t0;
2690
+ if (delay < 0.05) {
2691
+ scheduleCached(key, t0, peak, track.pan);
2692
+ opts?.onScheduled?.(track, note, t0);
2693
+ } else {
2694
+ console.warn(
2695
+ `[dtm] Synthesizer late skip: ${note.syllable.kana} at ${startSec}s (delayed by ${delay.toFixed(3)}s)`
2696
+ );
2697
+ opts?.onLateSkip?.(note, delay);
2698
+ }
2686
2699
  }
2687
- }
2688
- })();
2700
+ })();
2701
+ } else {
2702
+ const when = t0 - ctx.currentTime;
2703
+ model(note.syllable, {
2704
+ trackId: "",
2705
+ pitch: note.pitch,
2706
+ velocity: 100,
2707
+ volume: peak,
2708
+ when,
2709
+ duration: note.durationSec,
2710
+ pan: track.pan
2711
+ });
2712
+ opts?.onScheduled?.(track, note, t0);
2713
+ await new Promise((resolve) => setTimeout(resolve, 0));
2714
+ }
2715
+ }
2716
+ if (opts?.loopLengthSec && opts.loopLengthSec > 0) {
2717
+ loopOffsetSec += opts.loopLengthSec;
2718
+ pass++;
2689
2719
  } else {
2690
- const when = t0 - ctx.currentTime;
2691
- model(note.syllable, {
2692
- trackId: "",
2693
- pitch: note.pitch,
2694
- velocity: 100,
2695
- volume: peak,
2696
- when,
2697
- duration: note.durationSec,
2698
- pan: track.pan
2699
- });
2700
- opts?.onScheduled?.(track, note, t0);
2701
- await new Promise((resolve) => setTimeout(resolve, 0));
2720
+ break;
2702
2721
  }
2703
- }
2722
+ } while (session === streamSession);
2704
2723
  };
2705
2724
  for (const track of tracks) void runTrack(track);
2706
2725
  };
@@ -13006,12 +13025,234 @@ var mountDAW = (target, options = {}) => {
13006
13025
  };
13007
13026
 
13008
13027
  // src/headless-singing-player.ts
13009
- var playSingingMML = (_mml, _options = {}) => {
13010
- return Promise.reject(
13011
- new Error(
13012
- "playSingingMML is not implemented yet. See implementation notes at the top of headless-singing-player.ts."
13013
- )
13028
+ var STEPS_PER_BEAT4 = 48;
13029
+ var STEPS_PER_BAR3 = 192;
13030
+ var playSingingMML = async (mml, options = {}) => {
13031
+ const {
13032
+ placements,
13033
+ bpm: parsedBpm,
13034
+ meta,
13035
+ lyrics
13036
+ } = parseMML(mml, {
13037
+ collectLyrics: true
13038
+ });
13039
+ const lyricTracks = lyrics ?? /* @__PURE__ */ new Map();
13040
+ const customVocalByKey = new Map(
13041
+ parseCustomVocals(mml).map((d) => [d.key, d])
13014
13042
  );
13043
+ const bpm = parsedBpm ?? options.defaultBpm ?? DEFAULT_BPM;
13044
+ const secondsPerStep = 60 / bpm / STEPS_PER_BEAT4;
13045
+ const drumPatternDict = options.drumPatterns ?? DRUM_PATTERNS;
13046
+ const drumPattern = meta.drum ? drumPatternDict[meta.drum] ?? null : null;
13047
+ const drumVolume = meta.drumVolume ?? 80;
13048
+ const trackVolume = meta.volume ?? 100;
13049
+ let masterVolume = options.volume ?? 100;
13050
+ const trackIndices = [...new Set(placements.map((p) => p.trackIndex))].sort(
13051
+ (a, b) => a - b
13052
+ );
13053
+ const seqTracks = trackIndices.map((index) => {
13054
+ let id = 0;
13055
+ const notes = placements.filter((p) => p.trackIndex === index).map((p) => ({
13056
+ id: id++,
13057
+ startStep: p.startStep,
13058
+ durationSteps: p.durationSteps,
13059
+ pitch: p.pitch,
13060
+ velocity: p.velocity
13061
+ }));
13062
+ return {
13063
+ id: String(index),
13064
+ volume: trackVolume / 100 * masterVolume,
13065
+ notes
13066
+ };
13067
+ });
13068
+ const ownsCtx = !options.audioContext;
13069
+ const ctx = options.audioContext ?? new AudioContext();
13070
+ const destination = options.destination ?? ctx.destination;
13071
+ const useSynth = options.synth ?? !options.onPlayNote;
13072
+ const synth = useSynth ? createSynth(ctx, destination) : null;
13073
+ const pauseWhenHidden = options.pauseWhenHidden ?? ownsCtx;
13074
+ let playing = false;
13075
+ let destroyed = false;
13076
+ let voices = options.singingVoices ?? null;
13077
+ const buildStreamTracks = (fromStep) => [...lyricTracks.entries()].map(([index, lt]) => {
13078
+ const seqTrack = seqTracks.find((t) => Number(t.id) === index);
13079
+ const sorted = [...seqTrack?.notes ?? []].sort(
13080
+ (a, b) => a.startStep - b.startStep
13081
+ );
13082
+ const gate = (lt.gate ?? DEFAULT_GATE) / 100;
13083
+ const semis = (lt.octave ?? 0) * 12;
13084
+ const count = Math.min(sorted.length, lt.syllables.length);
13085
+ const notes = [];
13086
+ for (let i = 0; i < count; i++) {
13087
+ const n = sorted[i];
13088
+ if (n.startStep < fromStep) continue;
13089
+ notes.push({
13090
+ syllable: lt.syllables[i],
13091
+ pitch: n.pitch + semis,
13092
+ startSec: (n.startStep - fromStep) * secondsPerStep,
13093
+ durationSec: n.durationSteps * secondsPerStep * gate
13094
+ });
13095
+ }
13096
+ return {
13097
+ id: String(index),
13098
+ model: lt.model,
13099
+ volume: vocalVolumeToGain(lt.volume ?? DEFAULT_VOCAL_VOLUME),
13100
+ pan: panToStereo(lt.pan ?? DEFAULT_PAN),
13101
+ notes
13102
+ };
13103
+ });
13104
+ const seq = createSequencer({
13105
+ getTracks: () => seqTracks,
13106
+ getBpm: () => bpm,
13107
+ getPlayStartStep: () => 0,
13108
+ getDrumPattern: () => drumPattern,
13109
+ getSoloTrackId: () => null,
13110
+ getLoop: () => options.loop ?? false,
13111
+ cues: options.cues,
13112
+ onCue: options.onCue,
13113
+ getAudioTime: () => ctx.currentTime,
13114
+ onPlayNote: (e) => {
13115
+ const trackIdx = Number(e.trackId);
13116
+ if (lyricTracks.has(trackIdx)) return;
13117
+ options.onPlayNote?.(e);
13118
+ synth?.playNote(e);
13119
+ },
13120
+ onPlayDrum: (e) => {
13121
+ const velocity = e.velocity * (drumVolume / 100) * (trackVolume / 100) * (masterVolume / 100);
13122
+ options.onPlayDrum?.({ ...e, velocity });
13123
+ synth?.playDrum({ ...e, velocity });
13124
+ },
13125
+ onTick: (step) => {
13126
+ options.onTick?.(step);
13127
+ },
13128
+ onEnd: (_interrupted) => finish(),
13129
+ stepsPerBar: STEPS_PER_BAR3
13130
+ });
13131
+ const finish = () => {
13132
+ if (!playing) return;
13133
+ playing = false;
13134
+ voices?.stopStream();
13135
+ options.onStop?.();
13136
+ };
13137
+ const onVisibilityChange = () => {
13138
+ if (!playing) return;
13139
+ if (document.hidden) {
13140
+ void ctx.suspend();
13141
+ } else if (ctx.state === "suspended") {
13142
+ void ctx.resume();
13143
+ }
13144
+ };
13145
+ if (pauseWhenHidden && typeof document !== "undefined") {
13146
+ document.addEventListener("visibilitychange", onVisibilityChange);
13147
+ }
13148
+ const stop = () => {
13149
+ if (!playing) return;
13150
+ seq.stop();
13151
+ finish();
13152
+ };
13153
+ const setVolume = (volume) => {
13154
+ masterVolume = volume;
13155
+ const effectiveTrackVolume = trackVolume / 100 * masterVolume;
13156
+ for (const t of seqTracks) t.volume = effectiveTrackVolume;
13157
+ voices?.setVolume(trackVolume / 100 * (masterVolume / 100));
13158
+ };
13159
+ const suspend = () => ctx.suspend();
13160
+ const resume = () => ctx.resume();
13161
+ const destroy = () => {
13162
+ seq.stop();
13163
+ playing = false;
13164
+ destroyed = true;
13165
+ voices?.reset();
13166
+ if (pauseWhenHidden && typeof document !== "undefined") {
13167
+ document.removeEventListener("visibilitychange", onVisibilityChange);
13168
+ }
13169
+ if (ownsCtx && ctx.state !== "closed") {
13170
+ void ctx.close();
13171
+ }
13172
+ };
13173
+ const playback = {
13174
+ stop,
13175
+ isPlaying: () => playing,
13176
+ setVolume,
13177
+ suspend,
13178
+ resume,
13179
+ destroy
13180
+ };
13181
+ playing = true;
13182
+ try {
13183
+ const resumes = [];
13184
+ const r = options.onResumeAudio?.();
13185
+ if (r) resumes.push(Promise.resolve(r));
13186
+ if (ctx.state === "suspended") resumes.push(ctx.resume());
13187
+ if (resumes.length > 0) await Promise.all(resumes);
13188
+ if (!playing || destroyed) {
13189
+ return playback;
13190
+ }
13191
+ if (lyricTracks.size > 0) {
13192
+ if (!voices) {
13193
+ voices = createSingingVoices(ctx, destination, {
13194
+ voiceWorkerUrl: options.voiceWorkerUrl
13195
+ });
13196
+ }
13197
+ if (customVocalByKey.size > 0 && voices.registerVoicebanks) {
13198
+ voices.registerVoicebanks(
13199
+ Object.fromEntries([...customVocalByKey].map(([k, d]) => [k, d.url]))
13200
+ );
13201
+ }
13202
+ const streamTracks = buildStreamTracks(0);
13203
+ await voices.loadModels(streamTracks.map((t) => t.model));
13204
+ if (!playing || destroyed) {
13205
+ return playback;
13206
+ }
13207
+ await voices.warm(streamTracks, PREWARM_NOTES);
13208
+ if (!playing || destroyed) {
13209
+ return playback;
13210
+ }
13211
+ let loopLengthSec;
13212
+ let loopStartSec;
13213
+ const loopOption = options.loop ?? false;
13214
+ if (loopOption) {
13215
+ let loopStartStep = 0;
13216
+ let loopEndStep = -1;
13217
+ if (typeof loopOption === "object") {
13218
+ loopStartStep = loopOption.start ? resolveLoopPoint(
13219
+ loopOption.start,
13220
+ bpm,
13221
+ STEPS_PER_BAR3,
13222
+ secondsPerStep
13223
+ ) : 0;
13224
+ const endVal = loopOption.end ? resolveLoopPoint(
13225
+ loopOption.end,
13226
+ bpm,
13227
+ STEPS_PER_BAR3,
13228
+ secondsPerStep
13229
+ ) : null;
13230
+ loopEndStep = endVal !== null ? endVal : -1;
13231
+ }
13232
+ if (loopEndStep === -1) {
13233
+ let maxEndStep = 0;
13234
+ for (const p of placements) {
13235
+ maxEndStep = Math.max(maxEndStep, p.startStep + p.durationSteps);
13236
+ }
13237
+ loopEndStep = maxEndStep;
13238
+ }
13239
+ loopStartSec = loopStartStep * secondsPerStep;
13240
+ loopLengthSec = (loopEndStep - loopStartStep) * secondsPerStep;
13241
+ }
13242
+ seq.start(0);
13243
+ voices.setVolume(trackVolume / 100 * (masterVolume / 100));
13244
+ voices.startStream(streamTracks, seq.getStartTime(), {
13245
+ loopLengthSec,
13246
+ loopStartSec
13247
+ });
13248
+ } else {
13249
+ seq.start(0);
13250
+ }
13251
+ } catch (err2) {
13252
+ stop();
13253
+ throw err2;
13254
+ }
13255
+ return playback;
13015
13256
  };
13016
13257
 
13017
13258
  // src/piano-roll.ts
@@ -14369,6 +14610,7 @@ export {
14369
14610
  playNote,
14370
14611
  playPlacements,
14371
14612
  playSingingMML,
14613
+ resolveLoopPoint,
14372
14614
  setBackgroundActive,
14373
14615
  setDrawOffset,
14374
14616
  shiftNotes,
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "author": "onjmin",
3
3
  "license": "MIT",
4
4
  "name": "@onjmin/dtm",
5
- "version": "0.1.67",
5
+ "version": "0.1.68",
6
6
  "description": "MMLを中間言語に用いた、モバイルファーストなDAW / ピアノロール打ち込みコンポーネント",
7
7
  "homepage": "https://onjmin.github.io/dtm",
8
8
  "repository": {