@onjmin/dtm 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -190,7 +190,7 @@ var buildChordPlacements = (options) => {
190
190
  if (notes.length === 0) return;
191
191
  const startStep = barIndex * chordLength;
192
192
  notes.forEach((noteOffset, i) => {
193
- const stepOffset = i * 2;
193
+ const stepOffset = i * 3;
194
194
  placements.push({
195
195
  startStep: startStep + stepOffset,
196
196
  pitch: C3 + noteOffset + offset,
@@ -608,6 +608,1123 @@ var DRUM_PATTERNS = {
608
608
  ]
609
609
  };
610
610
 
611
+ // node_modules/.pnpm/@onjmin+koe@1.0.3/node_modules/@onjmin/koe/dist/index.js
612
+ var MAGIC = 1263486208;
613
+ function parseKoeHeader(headerBytes) {
614
+ const view = new DataView(headerBytes);
615
+ if (view.byteLength < 8 || view.getUint32(0, false) !== MAGIC) {
616
+ throw new Error("Not a .koe file (bad magic)");
617
+ }
618
+ return { jsonLength: view.getUint32(4, true) };
619
+ }
620
+ var pcmBase = (jsonLength) => 8 + jsonLength;
621
+ var BlobVoiceSource = class {
622
+ constructor(blob, base) {
623
+ this.blob = blob;
624
+ this.base = base;
625
+ }
626
+ blob;
627
+ base;
628
+ readBytes(offset, length) {
629
+ const start = this.base + offset;
630
+ return this.blob.slice(start, start + length).arrayBuffer();
631
+ }
632
+ };
633
+ var RangeVoiceSource = class {
634
+ constructor(url, base) {
635
+ this.url = url;
636
+ this.base = base;
637
+ }
638
+ url;
639
+ base;
640
+ async readBytes(offset, length) {
641
+ const start = this.base + offset;
642
+ const res = await fetch(this.url, {
643
+ headers: { Range: `bytes=${start}-${start + length - 1}` }
644
+ });
645
+ if (!res.ok && res.status !== 206) {
646
+ throw new Error(`.koe range request failed: ${res.status}`);
647
+ }
648
+ return res.arrayBuffer();
649
+ }
650
+ };
651
+ async function rangeFetch(url, start, length) {
652
+ const res = await fetch(url, {
653
+ headers: { Range: `bytes=${start}-${start + length - 1}` }
654
+ });
655
+ if (!res.ok && res.status !== 206)
656
+ throw new Error(`.koe fetch failed: ${res.status}`);
657
+ return res.arrayBuffer();
658
+ }
659
+ var VoiceBank = class _VoiceBank {
660
+ constructor(manifest, source) {
661
+ this.manifest = manifest;
662
+ this.source = source;
663
+ }
664
+ manifest;
665
+ source;
666
+ /**
667
+ * Parse a .koe archive header + manifest and bind a lazy PCM source.
668
+ * @param koe a Blob/File of the .koe archive, or a URL (served with Range support)
669
+ */
670
+ static async load(koe) {
671
+ if (typeof koe === "string") {
672
+ const header2 = await rangeFetch(koe, 0, 8);
673
+ const { jsonLength: jsonLength2 } = parseKoeHeader(header2);
674
+ const json2 = await rangeFetch(koe, 8, jsonLength2);
675
+ const manifest2 = JSON.parse(new TextDecoder().decode(json2));
676
+ return new _VoiceBank(
677
+ manifest2,
678
+ new RangeVoiceSource(koe, pcmBase(jsonLength2))
679
+ );
680
+ }
681
+ const header = await koe.slice(0, 8).arrayBuffer();
682
+ const { jsonLength } = parseKoeHeader(header);
683
+ const json = await koe.slice(8, 8 + jsonLength).arrayBuffer();
684
+ const manifest = JSON.parse(new TextDecoder().decode(json));
685
+ return new _VoiceBank(
686
+ manifest,
687
+ new BlobVoiceSource(koe, pcmBase(jsonLength))
688
+ );
689
+ }
690
+ /** True if the bank contains a phoneme under this alias. */
691
+ has(phoneme) {
692
+ return this.manifest.phonemes[phoneme] !== void 0;
693
+ }
694
+ /**
695
+ * Raw Int16 PCM bytes (48 kHz / mono) for a phoneme, or null if unknown.
696
+ * The returned ArrayBuffer is freshly allocated and safe to transfer to a
697
+ * worker / AudioWorklet.
698
+ */
699
+ async readPcmBytes(phoneme) {
700
+ const entry = this.manifest.phonemes[phoneme];
701
+ if (!entry) return null;
702
+ return this.source.readBytes(entry.offset, entry.length * 2);
703
+ }
704
+ /**
705
+ * A phoneme's PCM as a Float64Array normalised to [-1, 1], or null if unknown.
706
+ * Intended for external analysis / resynthesis such as the WORLD vocoder.
707
+ */
708
+ async getPcm(phoneme) {
709
+ const buf = await this.readPcmBytes(phoneme);
710
+ if (!buf) return null;
711
+ const int16 = new Int16Array(buf);
712
+ const f64 = new Float64Array(int16.length);
713
+ for (let i = 0; i < int16.length; i++) f64[i] = int16[i] / 32768;
714
+ return f64;
715
+ }
716
+ };
717
+ var WORLDLINE_SAMPLE_RATE = 48e3;
718
+ var MIN_WORLDLINE_SAMPLES = 4096;
719
+ var SYNTH_REQ_SIZE = 120;
720
+ var WL_FRAME_MS = 10;
721
+ var samplesToMs = (samples) => samples / WORLDLINE_SAMPLE_RATE * 1e3;
722
+ function leadInFromEntry(entry) {
723
+ return {
724
+ preMs: samplesToMs(entry.pre || 0),
725
+ consonantMs: samplesToMs(entry.consonant || 0)
726
+ };
727
+ }
728
+ var moduleCache = /* @__PURE__ */ new Map();
729
+ function injectScript(src) {
730
+ return new Promise((resolve, reject) => {
731
+ const existing = document.querySelector(
732
+ `script[data-koe-worldline="${src}"]`
733
+ );
734
+ if (existing) {
735
+ resolve();
736
+ return;
737
+ }
738
+ const s = document.createElement("script");
739
+ s.src = src;
740
+ s.dataset.koeWorldline = src;
741
+ s.onload = () => resolve();
742
+ s.onerror = () => reject(new Error(`worldline: failed to load ${src}`));
743
+ document.head.appendChild(s);
744
+ });
745
+ }
746
+ function loadWasm(scriptUrl) {
747
+ const cached = moduleCache.get(scriptUrl);
748
+ if (cached) return cached;
749
+ const baseUrl = scriptUrl.slice(0, scriptUrl.lastIndexOf("/") + 1);
750
+ const instantiate = () => {
751
+ const factory = globalThis.WorldlineModule;
752
+ if (!factory)
753
+ throw new Error(
754
+ "worldline: WorldlineModule global was not defined by the script"
755
+ );
756
+ return factory({ locateFile: (f) => baseUrl + f });
757
+ };
758
+ let promise;
759
+ if (typeof document !== "undefined") {
760
+ promise = injectScript(scriptUrl).then(instantiate);
761
+ } else if (typeof globalThis.importScripts === "function") {
762
+ promise = Promise.resolve().then(() => {
763
+ globalThis.importScripts(scriptUrl);
764
+ return instantiate();
765
+ });
766
+ } else {
767
+ return Promise.reject(
768
+ new Error(
769
+ "Worldline.load requires a DOM or a classic Web Worker (importScripts) to load worldline.js"
770
+ )
771
+ );
772
+ }
773
+ moduleCache.set(scriptUrl, promise);
774
+ return promise;
775
+ }
776
+ var Worldline = class _Worldline {
777
+ constructor(wasm) {
778
+ this.wasm = wasm;
779
+ }
780
+ wasm;
781
+ sampleRate = WORLDLINE_SAMPLE_RATE;
782
+ /**
783
+ * Load + instantiate the worldline WASM module (deduped per scriptUrl).
784
+ *
785
+ * Works on the main thread (loads via `<script>`) and inside a classic Web
786
+ * Worker (loads via `importScripts`), so the heavy synthesis can run
787
+ * off-thread. The matching `worldline.wasm` is fetched next to scriptUrl.
788
+ */
789
+ static async load(options) {
790
+ return new _Worldline(await loadWasm(options.scriptUrl));
791
+ }
792
+ /**
793
+ * Render one note to Float32 PCM at 48 kHz.
794
+ *
795
+ * The output buffer is laid out as [lead-in/consonant ≈ preMs][vowel ≈
796
+ * durationMs], rendered from sample offset 0 (no leading silence). The vowel
797
+ * onset (the "beat") sits at ≈ preMs into the buffer, so a sequencer should
798
+ * place the buffer at `beatTime − preMs` and may trim/crossfade the lead-in.
799
+ *
800
+ * No internal crossfade is applied — apply fades externally.
801
+ *
802
+ * @returns Float32 PCM, or null when `pcm` is shorter than
803
+ * {@link MIN_WORLDLINE_SAMPLES} (too short for stable F0 analysis).
804
+ */
805
+ renderNote(params) {
806
+ const { pcm, pitch, durationMs, preMs, consonantMs, tempo = 120 } = params;
807
+ if (!pcm || pcm.length < MIN_WORLDLINE_SAMPLES) return null;
808
+ const WL = this.wasm;
809
+ const FS = WORLDLINE_SAMPLE_RATE;
810
+ const midiNote = Math.round(69 + 12 * Math.log2(pitch / 440));
811
+ const posMs = 0;
812
+ const reqLen = preMs + durationMs;
813
+ const cutMs = WL_FRAME_MS * 2;
814
+ const ps = WL._PhraseSynthNew();
815
+ if (!ps) return null;
816
+ const reqPtr = WL._malloc(SYNTH_REQ_SIZE);
817
+ if (!reqPtr) {
818
+ WL._PhraseSynthDelete(ps);
819
+ return null;
820
+ }
821
+ const samplePtr = WL._malloc(pcm.length * 8);
822
+ if (!samplePtr) {
823
+ WL._free(reqPtr);
824
+ WL._PhraseSynthDelete(ps);
825
+ return null;
826
+ }
827
+ WL.HEAPF64.set(pcm, samplePtr >> 3);
828
+ const sv = (off, val, type) => WL.setValue(reqPtr + off, val, type);
829
+ sv(0, FS, "i32");
830
+ sv(4, pcm.length, "i32");
831
+ sv(8, samplePtr, "*");
832
+ sv(12, 0, "i32");
833
+ sv(16, 0, "*");
834
+ sv(20, midiNote, "i32");
835
+ sv(24, 100, "double");
836
+ sv(32, 0, "double");
837
+ sv(40, reqLen, "double");
838
+ sv(48, consonantMs, "double");
839
+ sv(56, cutMs, "double");
840
+ sv(64, 100, "double");
841
+ sv(72, 0, "double");
842
+ sv(80, tempo, "double");
843
+ sv(88, 0, "i32");
844
+ sv(92, 0, "*");
845
+ sv(96, 0, "i32");
846
+ sv(100, 0, "i32");
847
+ sv(104, 100, "i32");
848
+ sv(108, 0, "i32");
849
+ sv(112, 0, "i32");
850
+ sv(116, 100, "i32");
851
+ WL._PhraseSynthAddRequest(ps, reqPtr, posMs, 0, reqLen, 0, 0, 0);
852
+ WL._free(samplePtr);
853
+ WL._free(reqPtr);
854
+ const totalMs = posMs + reqLen + WL_FRAME_MS * 2;
855
+ const nFrames = Math.ceil(totalMs / WL_FRAME_MS) + 4;
856
+ const f0Arr = new Float64Array(nFrames).fill(pitch);
857
+ const gArr = new Float64Array(nFrames).fill(0.5);
858
+ const tArr = new Float64Array(nFrames).fill(0.5);
859
+ const bArr = new Float64Array(nFrames).fill(0.5);
860
+ const vArr = new Float64Array(nFrames).fill(1);
861
+ const f0Ptr = WL._malloc(nFrames * 8);
862
+ const gPtr = WL._malloc(nFrames * 8);
863
+ const tPtr = WL._malloc(nFrames * 8);
864
+ const bPtr = WL._malloc(nFrames * 8);
865
+ const vPtr = WL._malloc(nFrames * 8);
866
+ if (!f0Ptr || !gPtr || !tPtr || !bPtr || !vPtr) {
867
+ if (f0Ptr) WL._free(f0Ptr);
868
+ if (gPtr) WL._free(gPtr);
869
+ if (tPtr) WL._free(tPtr);
870
+ if (bPtr) WL._free(bPtr);
871
+ if (vPtr) WL._free(vPtr);
872
+ WL._PhraseSynthDelete(ps);
873
+ return null;
874
+ }
875
+ WL.HEAPF64.set(f0Arr, f0Ptr >> 3);
876
+ WL.HEAPF64.set(gArr, gPtr >> 3);
877
+ WL.HEAPF64.set(tArr, tPtr >> 3);
878
+ WL.HEAPF64.set(bArr, bPtr >> 3);
879
+ WL.HEAPF64.set(vArr, vPtr >> 3);
880
+ WL._PhraseSynthSetCurves(
881
+ ps,
882
+ f0Ptr,
883
+ gPtr,
884
+ tPtr,
885
+ bPtr,
886
+ vPtr,
887
+ nFrames,
888
+ WL_FRAME_MS
889
+ );
890
+ WL._free(f0Ptr);
891
+ WL._free(gPtr);
892
+ WL._free(tPtr);
893
+ WL._free(bPtr);
894
+ WL._free(vPtr);
895
+ const yPtrPtr = WL._malloc(4);
896
+ if (!yPtrPtr) {
897
+ WL._PhraseSynthDelete(ps);
898
+ return null;
899
+ }
900
+ const outLen = WL._PhraseSynthSynth(ps, yPtrPtr, 0);
901
+ const yPtr = WL.getValue(yPtrPtr, "*");
902
+ const audio = outLen > 0 ? new Float32Array(WL.HEAPF32.buffer, yPtr, outLen).slice() : null;
903
+ WL._free(yPtrPtr);
904
+ WL._PhraseSynthDelete(ps);
905
+ return audio;
906
+ }
907
+ };
908
+
909
+ // src/lyrics.ts
910
+ var kanaTable = {
911
+ \u3042: ["", "a"],
912
+ \u3044: ["", "i"],
913
+ \u3046: ["", "u"],
914
+ \u3048: ["", "e"],
915
+ \u304A: ["", "o"],
916
+ \u304B: ["k", "a"],
917
+ \u304D: ["k", "i"],
918
+ \u304F: ["k", "u"],
919
+ \u3051: ["k", "e"],
920
+ \u3053: ["k", "o"],
921
+ \u3055: ["s", "a"],
922
+ \u3057: ["sh", "i"],
923
+ \u3059: ["s", "u"],
924
+ \u305B: ["s", "e"],
925
+ \u305D: ["s", "o"],
926
+ \u305F: ["t", "a"],
927
+ \u3061: ["ch", "i"],
928
+ \u3064: ["ts", "u"],
929
+ \u3066: ["t", "e"],
930
+ \u3068: ["t", "o"],
931
+ \u306A: ["n", "a"],
932
+ \u306B: ["n", "i"],
933
+ \u306C: ["n", "u"],
934
+ \u306D: ["n", "e"],
935
+ \u306E: ["n", "o"],
936
+ \u306F: ["h", "a"],
937
+ \u3072: ["h", "i"],
938
+ \u3075: ["f", "u"],
939
+ \u3078: ["h", "e"],
940
+ \u307B: ["h", "o"],
941
+ \u307E: ["m", "a"],
942
+ \u307F: ["m", "i"],
943
+ \u3080: ["m", "u"],
944
+ \u3081: ["m", "e"],
945
+ \u3082: ["m", "o"],
946
+ \u3084: ["y", "a"],
947
+ \u3086: ["y", "u"],
948
+ \u3088: ["y", "o"],
949
+ \u3089: ["r", "a"],
950
+ \u308A: ["r", "i"],
951
+ \u308B: ["r", "u"],
952
+ \u308C: ["r", "e"],
953
+ \u308D: ["r", "o"],
954
+ \u308F: ["w", "a"],
955
+ \u3092: ["w", "o"],
956
+ \u304C: ["g", "a"],
957
+ \u304E: ["g", "i"],
958
+ \u3050: ["g", "u"],
959
+ \u3052: ["g", "e"],
960
+ \u3054: ["g", "o"],
961
+ \u3056: ["z", "a"],
962
+ \u3058: ["j", "i"],
963
+ \u305A: ["z", "u"],
964
+ \u305C: ["z", "e"],
965
+ \u305E: ["z", "o"],
966
+ \u3060: ["d", "a"],
967
+ \u3062: ["j", "i"],
968
+ \u3065: ["z", "u"],
969
+ \u3067: ["d", "e"],
970
+ \u3069: ["d", "o"],
971
+ \u3070: ["b", "a"],
972
+ \u3073: ["b", "i"],
973
+ \u3076: ["b", "u"],
974
+ \u3079: ["b", "e"],
975
+ \u307C: ["b", "o"],
976
+ \u3071: ["p", "a"],
977
+ \u3074: ["p", "i"],
978
+ \u3077: ["p", "u"],
979
+ \u307A: ["p", "e"],
980
+ \u307D: ["p", "o"],
981
+ \u3093: ["N", "N"]
982
+ };
983
+ var SMALL_KANA = "\u3041\u3043\u3045\u3047\u3049\u3083\u3085\u3087\u3063";
984
+ var VOWEL_KANA = {
985
+ a: "\u3042",
986
+ i: "\u3044",
987
+ u: "\u3046",
988
+ e: "\u3048",
989
+ o: "\u304A"
990
+ };
991
+ var sanitizeText = (text) => text.normalize("NFKC").replace(/[ァ-ヶ]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 96)).replace(/[^ぁ-ゖー]/g, "");
992
+ var splitSyllables = (text) => {
993
+ const result = [];
994
+ for (const ch of text) {
995
+ if (result.length > 0 && SMALL_KANA.includes(ch)) {
996
+ result[result.length - 1] += ch;
997
+ } else {
998
+ result.push(ch);
999
+ }
1000
+ }
1001
+ return result;
1002
+ };
1003
+ var kanaToVowel = (kana) => {
1004
+ if (/[ぁゃ]/.test(kana)) return "a";
1005
+ if (/[ぃ]/.test(kana)) return "i";
1006
+ if (/[ぅゅ]/.test(kana)) return "u";
1007
+ if (/[ぇ]/.test(kana)) return "e";
1008
+ if (/[ぉょ]/.test(kana)) return "o";
1009
+ if (/[あかさたなはまやらわがざだばぱ]/.test(kana)) return "a";
1010
+ if (/[いきしちにひみりぎじぢびぴ]/.test(kana)) return "i";
1011
+ if (/[うくすつぬふむゆるぐずづぶぷ]/.test(kana)) return "u";
1012
+ if (/[えけせてねへめれげぜでべぺ]/.test(kana)) return "e";
1013
+ if (/[おこそとのほもよろごぞどぼぽ]/.test(kana)) return "o";
1014
+ return "";
1015
+ };
1016
+ var analyzeSyllable = (syllable) => {
1017
+ if (syllable === "\u30FC") return { kana: syllable, consonant: "-", vowel: "-" };
1018
+ if (syllable === "\u3063") return { kana: syllable, consonant: "Q", vowel: "" };
1019
+ const head = syllable[0];
1020
+ const row = kanaTable[head];
1021
+ const consonant = row ? row[0] : "";
1022
+ let vowel = row ? row[1] : kanaToVowel(head);
1023
+ if (syllable.length === 2 && syllable[1] !== "\u3063") {
1024
+ const v = kanaToVowel(syllable[1]);
1025
+ if (v) vowel = v;
1026
+ }
1027
+ return { kana: syllable, consonant, vowel };
1028
+ };
1029
+ var resolveLongVowels = (syllables) => {
1030
+ const result = [];
1031
+ let prevVowel = "";
1032
+ for (const syl of syllables) {
1033
+ if (syl.consonant === "-") {
1034
+ if (!prevVowel) continue;
1035
+ result.push({
1036
+ kana: VOWEL_KANA[prevVowel] ?? syl.kana,
1037
+ consonant: "",
1038
+ vowel: prevVowel
1039
+ });
1040
+ continue;
1041
+ }
1042
+ if (syl.vowel && syl.vowel !== "N") prevVowel = syl.vowel;
1043
+ result.push(syl);
1044
+ }
1045
+ return result;
1046
+ };
1047
+ var normalizeLyrics = (text) => resolveLongVowels(splitSyllables(sanitizeText(text)).map(analyzeSyllable));
1048
+ var normalizeLyricLines = (lines) => {
1049
+ const syllables = [];
1050
+ const lineBreaks = [];
1051
+ for (const line of lines) {
1052
+ const part = normalizeLyrics(line);
1053
+ if (part.length === 0) continue;
1054
+ if (syllables.length > 0) lineBreaks.push(syllables.length);
1055
+ syllables.push(...part);
1056
+ }
1057
+ return { syllables, lineBreaks };
1058
+ };
1059
+ var LYRIC_LINE = /^@@(\d+)\s+(.*)$/;
1060
+ var isLyricContinuation = (seg) => !/^[@#]/.test(seg);
1061
+ var splitSegments = (mml) => mml.split(/[;\n\r]+/).map((s) => s.trim()).filter((s) => s.length > 0);
1062
+ var clamp = (value, lo, hi) => Math.min(hi, Math.max(lo, value));
1063
+ var MAX_VOCAL_VOLUME = 400;
1064
+ var VOCAL_BOOST_DB_PER_PERCENT = 0.08;
1065
+ var vocalVolumeToGain = (v) => {
1066
+ if (v <= 0) return 0;
1067
+ if (v <= 100) return v / 100;
1068
+ return 10 ** ((v - 100) * VOCAL_BOOST_DB_PER_PERCENT / 20);
1069
+ };
1070
+ var parseLyrics = (mml) => {
1071
+ const tracks = /* @__PURE__ */ new Map();
1072
+ const segments = splitSegments(mml);
1073
+ for (let i = 0; i < segments.length; i++) {
1074
+ const m = segments[i].match(LYRIC_LINE);
1075
+ if (!m) continue;
1076
+ const trackId = Number.parseInt(m[1], 10);
1077
+ const tokens = m[2].trim().split(/\s+/);
1078
+ const modelToken = tokens.shift() ?? "";
1079
+ let volume = 300;
1080
+ let gate = 100;
1081
+ let pan = 64;
1082
+ const colon = modelToken.indexOf(":");
1083
+ const model = (colon === -1 ? modelToken : modelToken.slice(0, colon)).toLowerCase();
1084
+ if (colon !== -1) {
1085
+ const v = Number.parseInt(modelToken.slice(colon + 1), 10);
1086
+ if (Number.isFinite(v)) volume = clamp(v, 0, MAX_VOCAL_VOLUME);
1087
+ }
1088
+ const metaTokens = [modelToken];
1089
+ while (tokens.length > 0) {
1090
+ const v = /^v(\d+)$/.exec(tokens[0]);
1091
+ const q2 = /^q(\d+)$/.exec(tokens[0]);
1092
+ const p = /^p(\d+)$/.exec(tokens[0]);
1093
+ if (v) {
1094
+ volume = clamp(Number.parseInt(v[1], 10), 0, MAX_VOCAL_VOLUME);
1095
+ } else if (q2) {
1096
+ gate = clamp(Number.parseInt(q2[1], 10), 0, 100);
1097
+ } else if (p) {
1098
+ pan = clamp(Number.parseInt(p[1], 10), 0, 127);
1099
+ } else {
1100
+ break;
1101
+ }
1102
+ metaTokens.push(tokens.shift());
1103
+ }
1104
+ const lyricLines = [tokens.join(" ")];
1105
+ while (i + 1 < segments.length && isLyricContinuation(segments[i + 1])) {
1106
+ lyricLines.push(segments[++i]);
1107
+ }
1108
+ const { syllables, lineBreaks } = normalizeLyricLines(lyricLines);
1109
+ tracks.set(trackId, {
1110
+ trackId,
1111
+ model,
1112
+ volume,
1113
+ gate,
1114
+ pan,
1115
+ syllables,
1116
+ metaText: metaTokens.join(" "),
1117
+ ...lineBreaks.length > 0 ? { lineBreaks } : {}
1118
+ });
1119
+ }
1120
+ return tracks;
1121
+ };
1122
+ var stripLyrics = (mml) => {
1123
+ const segments = splitSegments(mml);
1124
+ const kept = [];
1125
+ for (let i = 0; i < segments.length; i++) {
1126
+ if (LYRIC_LINE.test(segments[i])) {
1127
+ while (i + 1 < segments.length && isLyricContinuation(segments[i + 1]))
1128
+ i++;
1129
+ continue;
1130
+ }
1131
+ kept.push(segments[i]);
1132
+ }
1133
+ return kept.join("\n");
1134
+ };
1135
+ var panToStereo = (pan) => Math.max(-1, Math.min(1, (pan - 64) / 64));
1136
+ var createLyricsConductor = (lyrics) => {
1137
+ const pointers = /* @__PURE__ */ new Map();
1138
+ const consume = (trackId) => {
1139
+ const track = lyrics.get(trackId);
1140
+ if (!track || track.syllables.length === 0) return null;
1141
+ const ptr = pointers.get(trackId) ?? 0;
1142
+ const syllable = track.syllables[ptr];
1143
+ if (!syllable) return null;
1144
+ pointers.set(trackId, ptr + 1);
1145
+ return {
1146
+ model: track.model,
1147
+ syllable,
1148
+ volume: vocalVolumeToGain(track.volume ?? 300),
1149
+ gate: (track.gate ?? 100) / 100,
1150
+ pan: panToStereo(track.pan ?? 64)
1151
+ };
1152
+ };
1153
+ const reset = () => pointers.clear();
1154
+ return { consume, reset };
1155
+ };
1156
+ var FORMANTS = {
1157
+ a: [800, 1200],
1158
+ i: [300, 2300],
1159
+ u: [350, 800],
1160
+ e: [500, 1900],
1161
+ o: [500, 900],
1162
+ // 撥音(ん)は鼻音寄りの低フォルマント
1163
+ N: [250, 1e3]
1164
+ };
1165
+ var midiToFreq = (m) => 440 * 2 ** ((m - 69) / 12);
1166
+ var createKlattVoice = (ctx, destination) => {
1167
+ const active = /* @__PURE__ */ new Set();
1168
+ const voice = (syllable, e) => {
1169
+ const t0 = ctx.currentTime + e.when;
1170
+ const peak = Math.max(1e-4, e.volume);
1171
+ if (syllable.vowel === "" || syllable.consonant === "Q") return;
1172
+ const [f1, f2] = FORMANTS[syllable.vowel] ?? FORMANTS.a;
1173
+ const attack = 0.02;
1174
+ const release = 0.06;
1175
+ const sustainEnd = t0 + Math.max(attack + 0.02, e.duration);
1176
+ let panner = null;
1177
+ let out = destination;
1178
+ if (typeof ctx.createStereoPanner === "function") {
1179
+ panner = ctx.createStereoPanner();
1180
+ panner.pan.value = Math.max(-1, Math.min(1, e.pan ?? 0));
1181
+ panner.connect(destination);
1182
+ out = panner;
1183
+ }
1184
+ const osc = ctx.createOscillator();
1185
+ osc.type = "sawtooth";
1186
+ osc.frequency.value = midiToFreq(e.pitch);
1187
+ const makeFormant = (freq, q2, gainScale) => {
1188
+ const filter = ctx.createBiquadFilter();
1189
+ filter.type = "bandpass";
1190
+ filter.frequency.value = freq;
1191
+ filter.Q.value = q2;
1192
+ const g = ctx.createGain();
1193
+ g.gain.value = gainScale;
1194
+ osc.connect(filter).connect(g);
1195
+ return g;
1196
+ };
1197
+ const env = ctx.createGain();
1198
+ env.gain.setValueAtTime(1e-4, t0);
1199
+ env.gain.exponentialRampToValueAtTime(peak, t0 + attack);
1200
+ env.gain.setValueAtTime(peak, sustainEnd);
1201
+ env.gain.exponentialRampToValueAtTime(1e-4, sustainEnd + release);
1202
+ const MAKEUP = 4;
1203
+ makeFormant(f1, 6, MAKEUP).connect(env);
1204
+ makeFormant(f2, 9, MAKEUP * 0.7).connect(env);
1205
+ env.connect(out);
1206
+ const fricatives = /* @__PURE__ */ new Set(["s", "sh", "ch", "ts", "h", "f"]);
1207
+ if (fricatives.has(syllable.consonant)) {
1208
+ const dur = 0.05;
1209
+ const length = Math.max(1, Math.floor(ctx.sampleRate * dur));
1210
+ const buffer = ctx.createBuffer(1, length, ctx.sampleRate);
1211
+ const data = buffer.getChannelData(0);
1212
+ for (let i = 0; i < length; i++) data[i] = Math.random() * 2 - 1;
1213
+ const src = ctx.createBufferSource();
1214
+ src.buffer = buffer;
1215
+ const hp = ctx.createBiquadFilter();
1216
+ hp.type = "highpass";
1217
+ hp.frequency.value = syllable.consonant === "sh" ? 3e3 : 4500;
1218
+ const ng = ctx.createGain();
1219
+ ng.gain.setValueAtTime(peak * 0.5, t0);
1220
+ ng.gain.exponentialRampToValueAtTime(1e-4, t0 + dur);
1221
+ src.connect(hp).connect(ng).connect(out);
1222
+ src.start(t0);
1223
+ src.stop(t0 + dur);
1224
+ active.add(src);
1225
+ src.onended = () => {
1226
+ active.delete(src);
1227
+ src.disconnect();
1228
+ hp.disconnect();
1229
+ ng.disconnect();
1230
+ };
1231
+ }
1232
+ osc.start(t0);
1233
+ osc.stop(sustainEnd + release + 0.02);
1234
+ active.add(osc);
1235
+ osc.onended = () => {
1236
+ active.delete(osc);
1237
+ osc.disconnect();
1238
+ panner?.disconnect();
1239
+ };
1240
+ };
1241
+ voice.stopAll = () => {
1242
+ for (const n of active) {
1243
+ try {
1244
+ n.stop();
1245
+ } catch {
1246
+ }
1247
+ n.disconnect();
1248
+ }
1249
+ active.clear();
1250
+ };
1251
+ return voice;
1252
+ };
1253
+ var KOE_BASE_URL = "https://pub-12482a6b5cbc4c9e906b2e1904cabae5.r2.dev";
1254
+ var KOE_VOICEBANKS = {
1255
+ tsukuyomi: "\u3064\u304F\u3088\u307F\u3061\u3083\u3093.koe",
1256
+ rino: "\u6625\u97F3\u30EA\u30CEver0.3.koe",
1257
+ roze: "\u675F\u97F3\u30ED\u30BCver0.\uFF151(\u591A\u97F3\u968E).koe",
1258
+ ruko: "\u6B32\u97F3\u30EB\u30B3\u2640\u6B4C\u9023\u7D9A\u97F3\u666E1.00.koe",
1259
+ teto: "\u91CD\u97F3\u30C6\u30C8\u5358\u72EC\u97F3.koe",
1260
+ shiyo: "\u9769\u547D\u30B7\u30E8.koe"
1261
+ };
1262
+ var KOE_VOICEBANK_LABELS = {
1263
+ tsukuyomi: "\u3064\u304F\u3088\u307F\u3061\u3083\u3093",
1264
+ rino: "\u6625\u97F3\u30EA\u30CE",
1265
+ roze: "\u675F\u97F3\u30ED\u30BC",
1266
+ ruko: "\u6B32\u97F3\u30EB\u30B3",
1267
+ teto: "\u91CD\u97F3\u30C6\u30C8",
1268
+ shiyo: "\u9769\u547D\u30B7\u30E8"
1269
+ };
1270
+ var koeUrl = (name, base = KOE_BASE_URL) => `${base}/${encodeURIComponent(name)}`;
1271
+ var DEFAULT_WORLDLINE_SCRIPT = "https://onjmin.github.io/koe/demo/world/worldline.js";
1272
+ var KOE_SAMPLE_RATE = 48e3;
1273
+ var expandSeparators = (candidate) => candidate.includes(" ") ? [candidate, candidate.replace(/ /g, "\u3000"), candidate.replace(/ /g, "")] : [candidate];
1274
+ var PITCH_SUFFIX = /_([A-G][#b]?-?\d+)$/;
1275
+ var NAME_SEMITONE = {
1276
+ c: 0,
1277
+ d: 2,
1278
+ e: 4,
1279
+ f: 5,
1280
+ g: 7,
1281
+ a: 9,
1282
+ b: 11
1283
+ };
1284
+ var pitchTokenToMidi = (token) => {
1285
+ const m = /^([A-Ga-g])([#b]?)(-?\d+)$/.exec(token);
1286
+ if (!m) return null;
1287
+ let semi = NAME_SEMITONE[m[1].toLowerCase()];
1288
+ if (m[2] === "#") semi++;
1289
+ else if (m[2] === "b") semi--;
1290
+ return (Number.parseInt(m[3], 10) + 1) * 12 + semi;
1291
+ };
1292
+ var collectPitchTokens = (aliases) => {
1293
+ const seen = /* @__PURE__ */ new Map();
1294
+ for (const a of aliases) {
1295
+ const m = PITCH_SUFFIX.exec(a);
1296
+ if (!m || seen.has(m[1])) continue;
1297
+ const midi = pitchTokenToMidi(m[1]);
1298
+ if (midi != null) seen.set(m[1], midi);
1299
+ }
1300
+ return [...seen].map(([token, midi]) => ({ token, midi }));
1301
+ };
1302
+ var resolveKoeAlias = (hasAlias, pitchTokens, syl, prevVowel, noteNum) => {
1303
+ const kana = syl.kana;
1304
+ const cons = syl.consonant === "N" ? "n" : syl.consonant;
1305
+ const vow = syl.vowel === "N" ? "" : syl.vowel;
1306
+ const romaji = `${cons}${vow}` || vow;
1307
+ const pv = prevVowel || "-";
1308
+ const raw = [
1309
+ // 連続音(VCV): 直前母音つき
1310
+ `${pv} ${kana}`,
1311
+ `${pv} ${romaji}`,
1312
+ // 単独音 / CVVC
1313
+ kana,
1314
+ romaji
1315
+ ];
1316
+ const vk = VOWEL_KANA[syl.vowel];
1317
+ if (vk) raw.push(`${pv} ${vk}`, vk, syl.vowel);
1318
+ if (syl.vowel === "N") raw.push("\u3093", "n", "N", `${pv} \u3093`);
1319
+ const seen = /* @__PURE__ */ new Set();
1320
+ const tryAlias = (candidate) => {
1321
+ for (const v of expandSeparators(candidate)) {
1322
+ if (seen.has(v)) continue;
1323
+ seen.add(v);
1324
+ if (hasAlias(v)) return v;
1325
+ }
1326
+ return null;
1327
+ };
1328
+ if (pitchTokens.length) {
1329
+ const nearest = pitchTokens.slice().sort((a, b) => Math.abs(a.midi - noteNum) - Math.abs(b.midi - noteNum));
1330
+ for (const { token } of nearest) {
1331
+ for (const base of raw) {
1332
+ const hit = tryAlias(`${base}_${token}`);
1333
+ if (hit) return hit;
1334
+ }
1335
+ }
1336
+ }
1337
+ for (const base of raw) {
1338
+ const hit = tryAlias(base);
1339
+ if (hit) return hit;
1340
+ }
1341
+ return null;
1342
+ };
1343
+ var createLocalBackend = async (options) => {
1344
+ const bank = await VoiceBank.load(options.koe);
1345
+ const worldline = options.lightweight ? null : await Worldline.load({
1346
+ scriptUrl: options.worldlineScriptUrl ?? DEFAULT_WORLDLINE_SCRIPT
1347
+ }).catch(() => null);
1348
+ const pcmCache = /* @__PURE__ */ new Map();
1349
+ const getPcm = (alias) => {
1350
+ let p = pcmCache.get(alias);
1351
+ if (!p) {
1352
+ p = bank.getPcm(alias);
1353
+ pcmCache.set(alias, p);
1354
+ }
1355
+ return p;
1356
+ };
1357
+ const renderAlias = async (alias, pitch, durationMs) => {
1358
+ const pcm = await getPcm(alias);
1359
+ if (!pcm || pcm.length === 0) return null;
1360
+ const entry = bank.manifest.phonemes[alias];
1361
+ const lead = leadInFromEntry(entry);
1362
+ const targetHz = midiToFreq(pitch);
1363
+ if (worldline) {
1364
+ const audio = worldline.renderNote({
1365
+ pcm,
1366
+ pitch: targetHz,
1367
+ durationMs,
1368
+ ...lead
1369
+ });
1370
+ if (audio) return { pcm: audio, preSec: lead.preMs / 1e3, rate: 1 };
1371
+ }
1372
+ const rate = entry.pitch > 0 ? targetHz / entry.pitch : 1;
1373
+ return {
1374
+ pcm: Float32Array.from(pcm),
1375
+ preSec: entry.pre / KOE_SAMPLE_RATE / rate,
1376
+ rate
1377
+ };
1378
+ };
1379
+ return {
1380
+ hasAlias: (a) => bank.has(a),
1381
+ pitchTokens: collectPitchTokens(Object.keys(bank.manifest.phonemes)),
1382
+ renderAlias,
1383
+ dispose: () => {
1384
+ }
1385
+ };
1386
+ };
1387
+ var spawnVoiceWorker = async (url) => {
1388
+ const sameOrigin = new URL(url, location.href).origin === location.origin;
1389
+ if (sameOrigin) return new Worker(url);
1390
+ const text = await fetch(url).then((r) => r.text());
1391
+ return new Worker(
1392
+ URL.createObjectURL(new Blob([text], { type: "text/javascript" }))
1393
+ );
1394
+ };
1395
+ var createWorkerBackend = async (workerUrl, options) => {
1396
+ const worker = await spawnVoiceWorker(workerUrl);
1397
+ const aliasSet = /* @__PURE__ */ new Set();
1398
+ const pending = /* @__PURE__ */ new Map();
1399
+ let reqId = 0;
1400
+ let onReady = null;
1401
+ let onFail = null;
1402
+ worker.onmessage = (ev) => {
1403
+ const m = ev.data;
1404
+ if (m.type === "ready") {
1405
+ for (const a of m.aliases) aliasSet.add(a);
1406
+ onReady?.();
1407
+ } else if (m.type === "error") {
1408
+ onFail?.(new Error(m.message));
1409
+ } else if (m.type === "rendered") {
1410
+ const cb = pending.get(m.id);
1411
+ if (cb) {
1412
+ pending.delete(m.id);
1413
+ cb(m);
1414
+ }
1415
+ }
1416
+ };
1417
+ worker.onerror = (e) => onFail?.(
1418
+ new Error(`voice worker error: ${e.message || e}`)
1419
+ );
1420
+ await new Promise((resolve, reject) => {
1421
+ onReady = resolve;
1422
+ onFail = reject;
1423
+ worker.postMessage({
1424
+ type: "init",
1425
+ koe: options.koe,
1426
+ worldlineScriptUrl: options.worldlineScriptUrl ?? DEFAULT_WORLDLINE_SCRIPT,
1427
+ lightweight: !!options.lightweight
1428
+ });
1429
+ });
1430
+ onReady = null;
1431
+ onFail = null;
1432
+ const renderAlias = (alias, pitch, durationMs) => new Promise((resolve) => {
1433
+ const id = ++reqId;
1434
+ pending.set(
1435
+ id,
1436
+ (m) => resolve(
1437
+ m.pcm ? { pcm: m.pcm, preSec: m.preSec ?? 0, rate: m.rate ?? 1 } : null
1438
+ )
1439
+ );
1440
+ worker.postMessage({
1441
+ type: "render",
1442
+ id,
1443
+ alias,
1444
+ pitch,
1445
+ durationMs
1446
+ });
1447
+ });
1448
+ return {
1449
+ hasAlias: (a) => aliasSet.has(a),
1450
+ pitchTokens: collectPitchTokens(aliasSet),
1451
+ renderAlias,
1452
+ dispose: () => worker.terminate()
1453
+ };
1454
+ };
1455
+ var createKoeVoice = async (ctx, destination, options) => {
1456
+ const backend = options.voiceWorkerUrl ? await createWorkerBackend(options.voiceWorkerUrl, options) : await createLocalBackend(options);
1457
+ const renderCache = /* @__PURE__ */ new Map();
1458
+ const inflight = /* @__PURE__ */ new Map();
1459
+ const active = /* @__PURE__ */ new Set();
1460
+ let prevVowel = "";
1461
+ const keyOf = (alias, pitch, durationMs) => `${alias}|${pitch}|${Math.round(durationMs / 10) * 10}`;
1462
+ const renderInto = (alias, pitch, durationMs) => {
1463
+ const key = keyOf(alias, pitch, durationMs);
1464
+ const existing = renderCache.get(key);
1465
+ if (existing !== void 0) return Promise.resolve(existing);
1466
+ const flying = inflight.get(key);
1467
+ if (flying) return flying;
1468
+ const p = (async () => {
1469
+ const out = await backend.renderAlias(alias, pitch, durationMs);
1470
+ let rendered = null;
1471
+ if (out) {
1472
+ const buf = ctx.createBuffer(1, out.pcm.length, KOE_SAMPLE_RATE);
1473
+ buf.copyToChannel(out.pcm, 0);
1474
+ rendered = { audio: buf, preSec: out.preSec, rate: out.rate };
1475
+ }
1476
+ renderCache.set(key, rendered);
1477
+ inflight.delete(key);
1478
+ return rendered;
1479
+ })();
1480
+ inflight.set(key, p);
1481
+ return p;
1482
+ };
1483
+ const LEADCAP_S = 0.09;
1484
+ const schedule = (r, t0, peak, pan) => {
1485
+ let out = destination;
1486
+ let panner = null;
1487
+ if (typeof ctx.createStereoPanner === "function") {
1488
+ panner = ctx.createStereoPanner();
1489
+ panner.pan.value = Math.max(-1, Math.min(1, pan));
1490
+ panner.connect(destination);
1491
+ out = panner;
1492
+ }
1493
+ const src = ctx.createBufferSource();
1494
+ src.buffer = r.audio;
1495
+ src.playbackRate.value = r.rate;
1496
+ const effPre = Math.min(r.preSec, LEADCAP_S);
1497
+ const skipS = r.preSec - effPre;
1498
+ const startAt = Math.max(ctx.currentTime + 1e-3, t0 - effPre);
1499
+ const playDurSec = r.audio.duration / r.rate - skipS;
1500
+ const endAt = startAt + playDurSec;
1501
+ const attack = 0.01;
1502
+ const release = 0.04;
1503
+ const env = ctx.createGain();
1504
+ env.gain.setValueAtTime(1e-4, startAt);
1505
+ env.gain.exponentialRampToValueAtTime(peak, startAt + attack);
1506
+ const fadeStart = Math.max(startAt + attack, endAt - release);
1507
+ env.gain.setValueAtTime(peak, fadeStart);
1508
+ env.gain.exponentialRampToValueAtTime(1e-4, endAt);
1509
+ src.connect(env).connect(out);
1510
+ src.start(startAt, skipS);
1511
+ src.stop(endAt + 0.02);
1512
+ active.add(src);
1513
+ src.onended = () => {
1514
+ active.delete(src);
1515
+ src.disconnect();
1516
+ env.disconnect();
1517
+ panner?.disconnect();
1518
+ };
1519
+ };
1520
+ const model = (syllable, e) => {
1521
+ if (syllable.consonant === "Q" || syllable.vowel === "") return;
1522
+ const alias = resolveKoeAlias(
1523
+ backend.hasAlias,
1524
+ backend.pitchTokens,
1525
+ syllable,
1526
+ prevVowel,
1527
+ e.pitch
1528
+ );
1529
+ if (syllable.vowel && syllable.vowel !== "N") prevVowel = syllable.vowel;
1530
+ if (!alias) return;
1531
+ const t0 = ctx.currentTime + e.when;
1532
+ const peak = Math.max(1e-4, e.volume);
1533
+ const pan = e.pan ?? 0;
1534
+ const durationMs = Math.max(60, e.duration * 1e3);
1535
+ void renderInto(alias, e.pitch, durationMs).then((r) => {
1536
+ if (r) schedule(r, t0, peak, pan);
1537
+ });
1538
+ };
1539
+ model.renderToCache = async (syllable, prevVowelArg, pitch, durationMs) => {
1540
+ if (syllable.consonant === "Q" || syllable.vowel === "") return null;
1541
+ const alias = resolveKoeAlias(
1542
+ backend.hasAlias,
1543
+ backend.pitchTokens,
1544
+ syllable,
1545
+ prevVowelArg,
1546
+ pitch
1547
+ );
1548
+ if (!alias) return null;
1549
+ const dMs = Math.max(60, durationMs);
1550
+ const r = await renderInto(alias, pitch, dMs);
1551
+ return r ? keyOf(alias, pitch, dMs) : null;
1552
+ };
1553
+ model.scheduleCached = (key, t0, peak, pan) => {
1554
+ const r = renderCache.get(key);
1555
+ if (r) schedule(r, t0, peak, pan);
1556
+ };
1557
+ model.stopAll = () => {
1558
+ for (const src of active) {
1559
+ try {
1560
+ src.stop();
1561
+ } catch {
1562
+ }
1563
+ src.disconnect();
1564
+ }
1565
+ active.clear();
1566
+ };
1567
+ model.reset = () => {
1568
+ prevVowel = "";
1569
+ };
1570
+ return model;
1571
+ };
1572
+ var PREWARM_NOTES = 3;
1573
+ var STREAM_LOOKAHEAD_SEC = 1.5;
1574
+ var STREAM_POLL_MS = 100;
1575
+ var FALLBACK_MODEL = "klatt";
1576
+ var createSingingVoices = (ctx, destination, options = {}) => {
1577
+ const catalog = {};
1578
+ for (const [k, file] of Object.entries(KOE_VOICEBANKS))
1579
+ catalog[k] = koeUrl(file);
1580
+ for (const [k, v] of Object.entries(options.voicebanks ?? {}))
1581
+ catalog[k.toLowerCase()] = v;
1582
+ let streamSession = 0;
1583
+ const loaded = /* @__PURE__ */ new Map([
1584
+ [FALLBACK_MODEL, createKlattVoice(ctx, destination)]
1585
+ ]);
1586
+ const loading = /* @__PURE__ */ new Map();
1587
+ const load = (model) => {
1588
+ const m = model.toLowerCase();
1589
+ const ready = loaded.get(m);
1590
+ if (ready) return Promise.resolve(ready);
1591
+ const inflight = loading.get(m);
1592
+ if (inflight) return inflight;
1593
+ const koe = catalog[m];
1594
+ if (!koe) return Promise.resolve(null);
1595
+ const p = (async () => {
1596
+ let source = koe;
1597
+ if (typeof source === "string") {
1598
+ const res = await fetch(source);
1599
+ if (!res.ok) {
1600
+ throw new Error(`fetch failed: ${res.statusText}`);
1601
+ }
1602
+ source = await res.blob();
1603
+ }
1604
+ return createKoeVoice(ctx, destination, {
1605
+ koe: source,
1606
+ worldlineScriptUrl: options.worldlineScriptUrl,
1607
+ lightweight: options.lightweight,
1608
+ voiceWorkerUrl: options.voiceWorkerUrl
1609
+ });
1610
+ })().then((v) => {
1611
+ loaded.set(m, v);
1612
+ return v;
1613
+ }).catch((err) => {
1614
+ console.warn(`[dtm] koe\u97F3\u6E90 "${m}" \u306E\u8AAD\u307F\u8FBC\u307F\u306B\u5931\u6557\u3057\u307E\u3057\u305F`, err);
1615
+ return null;
1616
+ });
1617
+ loading.set(m, p);
1618
+ return p;
1619
+ };
1620
+ const loadModels = async (models) => {
1621
+ const set = /* @__PURE__ */ new Set();
1622
+ for (const m of models) if (m) set.add(m.toLowerCase());
1623
+ await Promise.all([...set].map((m) => load(m)));
1624
+ };
1625
+ const forEachSungNote = (track, fn) => {
1626
+ let prevVowel = "";
1627
+ for (const note of track.notes) {
1628
+ const syl = note.syllable;
1629
+ if (syl.consonant === "Q" || syl.vowel === "") continue;
1630
+ fn(note, prevVowel);
1631
+ if (syl.vowel && syl.vowel !== "N") prevVowel = syl.vowel;
1632
+ }
1633
+ };
1634
+ const warm = async (tracks, count = PREWARM_NOTES) => {
1635
+ const promises = [];
1636
+ for (const track of tracks) {
1637
+ const m = loaded.get(track.model.toLowerCase());
1638
+ if (!m?.renderToCache) continue;
1639
+ let n = 0;
1640
+ forEachSungNote(track, (note, prevVowel) => {
1641
+ if (n >= count) return;
1642
+ n++;
1643
+ promises.push(
1644
+ m.renderToCache?.(
1645
+ note.syllable,
1646
+ prevVowel,
1647
+ note.pitch,
1648
+ note.durationSec * 1e3
1649
+ ) ?? Promise.resolve(null)
1650
+ );
1651
+ });
1652
+ }
1653
+ await Promise.all(promises);
1654
+ };
1655
+ const startStream = (tracks, anchorTime) => {
1656
+ const session = ++streamSession;
1657
+ const items = [];
1658
+ for (const track of tracks) {
1659
+ const model = loaded.get(track.model.toLowerCase());
1660
+ if (!model) continue;
1661
+ forEachSungNote(track, (note, prevVowel) => {
1662
+ items.push({
1663
+ model,
1664
+ note,
1665
+ prevVowel,
1666
+ volume: track.volume,
1667
+ pan: track.pan
1668
+ });
1669
+ });
1670
+ }
1671
+ items.sort((a, b) => a.note.startSec - b.note.startSec);
1672
+ void (async () => {
1673
+ for (const item of items) {
1674
+ if (session !== streamSession) return;
1675
+ while (item.note.startSec - (ctx.currentTime - anchorTime) > STREAM_LOOKAHEAD_SEC) {
1676
+ await new Promise((resolve) => setTimeout(resolve, STREAM_POLL_MS));
1677
+ if (session !== streamSession) return;
1678
+ }
1679
+ const t0 = anchorTime + item.note.startSec;
1680
+ const peak = Math.max(1e-4, item.volume);
1681
+ const { model, note } = item;
1682
+ if (model.renderToCache && model.scheduleCached) {
1683
+ const key = await model.renderToCache(
1684
+ note.syllable,
1685
+ item.prevVowel,
1686
+ note.pitch,
1687
+ note.durationSec * 1e3
1688
+ );
1689
+ if (session !== streamSession) return;
1690
+ if (key) model.scheduleCached(key, t0, peak, item.pan);
1691
+ } else {
1692
+ const when = t0 - ctx.currentTime;
1693
+ model(note.syllable, {
1694
+ trackId: "",
1695
+ pitch: note.pitch,
1696
+ velocity: 100,
1697
+ volume: peak,
1698
+ when,
1699
+ duration: note.durationSec,
1700
+ pan: item.pan
1701
+ });
1702
+ }
1703
+ await new Promise((resolve) => setTimeout(resolve, 0));
1704
+ }
1705
+ })();
1706
+ };
1707
+ const stopStream = () => {
1708
+ streamSession++;
1709
+ for (const v of loaded.values()) v.stopAll?.();
1710
+ };
1711
+ const reset = () => {
1712
+ stopStream();
1713
+ for (const v of loaded.values()) v.reset?.();
1714
+ };
1715
+ return { loadModels, warm, startStream, stopStream, reset };
1716
+ };
1717
+ var createVoiceRegistry = (models = {}, fallback = "klatt") => {
1718
+ const sing = (model, syllable, e) => {
1719
+ const fn = models[model] ?? models[fallback];
1720
+ fn?.(syllable, e);
1721
+ };
1722
+ const register = (name, m) => {
1723
+ models[name.toLowerCase()] = m;
1724
+ };
1725
+ return { sing, register };
1726
+ };
1727
+
611
1728
  // src/macros.ts
612
1729
  var SCALES = [
613
1730
  [0, 2, 4, 5, 7, 9, 11],
@@ -1656,6 +2773,8 @@ var MMLCore = class _MMLCore {
1656
2773
  const config = getRenderConfig();
1657
2774
  const total = config.stepsPerBar;
1658
2775
  const candidates = [
2776
+ { dur: "1.", s: total * 1.5 },
2777
+ // 付点全音符(最長。これ以上はタイ未対応のため表現不可)
1659
2778
  { dur: "1", s: total / 1 },
1660
2779
  { dur: "2.", s: total / 2 * 1.5 },
1661
2780
  { dur: "2", s: total / 2 },
@@ -1716,97 +2835,77 @@ var MMLCore = class _MMLCore {
1716
2835
  return { text: `o${octave}${name}`, currentOctave: octave };
1717
2836
  }
1718
2837
  /**
1719
- * MML生成(1/2小節パターンスキャン方式)
2838
+ * MML生成(単一パス・発音順スキャン)
2839
+ *
2840
+ * 以前は1/2小節ごとのウィンドウで走査していたが、それだと半小節境界をまたぐ音符が
2841
+ * 境界で切り詰められて「ぶつ切り」になり、境界直前に始まる音符は隙間が潰れて欠落し、
2842
+ * 歌詞(@@n)の音節割り当てがずれていた。
2843
+ * 全ノートを発音順に一度で処理し、次の発音までの距離だけを上限として
2844
+ * 各音符の長さを忠実に出力する(次の音符がなければ曲末まで伸ばせる)。
1720
2845
  */
1721
2846
  generateMML = (volumeOverride) => {
1722
2847
  const config = getRenderConfig();
1723
2848
  const vol = volumeOverride ?? this.volume;
1724
- const HALF_BAR = config.stepsPerBar / 2;
1725
- const header = `t${this.tempo} q50 v${vol}`;
2849
+ const header = `t${this.tempo} v${vol}`;
1726
2850
  const segments = [];
1727
2851
  let lastOctave = -1;
1728
2852
  let currentCursor = 0;
1729
2853
  if (this.notes.length === 0) return header;
1730
- const lastNote = this.notes[this.notes.length - 1];
1731
- const endStep = lastNote.startStep + lastNote.durationSteps;
1732
- const totalSteps = Math.ceil(endStep / HALF_BAR) * HALF_BAR;
1733
- for (let windowStart = 0; windowStart < totalSteps; windowStart += HALF_BAR) {
1734
- const windowEnd = windowStart + HALF_BAR;
1735
- const windowNotes = this.notes.filter(
1736
- (n) => n.startStep >= windowStart && n.startStep < windowEnd
1737
- );
1738
- if (windowNotes.length === 0) {
1739
- while (currentCursor < windowEnd) {
1740
- const gap = windowEnd - currentCursor;
1741
- if (gap <= 2) {
1742
- currentCursor = windowEnd;
1743
- break;
1744
- }
1745
- const { dur, steps } = this.findBestFitDuration(gap);
1746
- segments.push(`r${dur}`);
1747
- currentCursor += steps;
1748
- }
1749
- continue;
1750
- }
1751
- const notesByStep = /* @__PURE__ */ new Map();
1752
- windowNotes.forEach((n) => {
1753
- const list = notesByStep.get(n.startStep) || [];
1754
- list.push(n);
1755
- notesByStep.set(n.startStep, list);
1756
- });
1757
- const sortedSteps = Array.from(notesByStep.keys()).sort((a, b) => a - b);
1758
- for (let i = 0; i < sortedSteps.length; i++) {
1759
- const startStep = sortedSteps[i];
1760
- const notes = notesByStep.get(startStep);
1761
- if (!notes) continue;
1762
- while (currentCursor < startStep) {
1763
- const gap = startStep - currentCursor;
1764
- if (gap <= 2) {
1765
- currentCursor = startStep;
1766
- break;
1767
- }
1768
- const { dur, steps } = this.findBestFitDuration(gap);
1769
- segments.push(`r${dur}`);
1770
- currentCursor += steps;
1771
- }
1772
- const nextStart = sortedSteps[i + 1] ?? windowEnd;
1773
- const physicsLimit = nextStart - currentCursor;
1774
- const MIN_STEP = config.stepsPerBar / 64;
1775
- if (physicsLimit < MIN_STEP) {
1776
- currentCursor = startStep;
1777
- continue;
1778
- }
1779
- const idealDuration = notes[0].durationSteps;
1780
- const durStr = this.stepsToMMLDuration(idealDuration, physicsLimit);
1781
- const actualStepGenerated = this.getStepFromDottedMML(durStr);
1782
- if (notes.length > 1) {
1783
- const noteStrs = notes.map((n) => {
1784
- const oct = Math.floor(n.pitch / 12) - 1;
1785
- const name = PITCH_MAP[n.pitch % 12];
1786
- return `o${oct}${name}`;
1787
- });
1788
- segments.push(`[${noteStrs.join("")}]${durStr}`);
1789
- } else {
1790
- const { text, currentOctave } = this.getNoteWithOctave(
1791
- notes[0].pitch,
1792
- lastOctave
1793
- );
1794
- segments.push(`${text}${durStr}`);
1795
- lastOctave = currentOctave;
1796
- }
1797
- currentCursor += actualStepGenerated;
1798
- }
1799
- while (currentCursor < windowEnd) {
1800
- const gap = windowEnd - currentCursor;
2854
+ const endStep = Math.max(
2855
+ ...this.notes.map((n) => n.startStep + n.durationSteps)
2856
+ );
2857
+ const notesByStep = /* @__PURE__ */ new Map();
2858
+ for (const n of this.notes) {
2859
+ const list = notesByStep.get(n.startStep) ?? [];
2860
+ list.push(n);
2861
+ notesByStep.set(n.startStep, list);
2862
+ }
2863
+ const sortedSteps = Array.from(notesByStep.keys()).sort((a, b) => a - b);
2864
+ const fillRests = (until) => {
2865
+ while (currentCursor < until) {
2866
+ const gap = until - currentCursor;
1801
2867
  if (gap <= 2) {
1802
- currentCursor = windowEnd;
2868
+ currentCursor = until;
1803
2869
  break;
1804
2870
  }
1805
2871
  const { dur, steps } = this.findBestFitDuration(gap);
1806
2872
  segments.push(`r${dur}`);
1807
2873
  currentCursor += steps;
1808
2874
  }
2875
+ };
2876
+ const MIN_STEP = config.stepsPerBar / 64;
2877
+ for (let i = 0; i < sortedSteps.length; i++) {
2878
+ const startStep = sortedSteps[i];
2879
+ const notes = notesByStep.get(startStep);
2880
+ if (!notes) continue;
2881
+ fillRests(startStep);
2882
+ const nextStart = sortedSteps[i + 1] ?? endStep;
2883
+ const physicsLimit = nextStart - currentCursor;
2884
+ if (physicsLimit < MIN_STEP) {
2885
+ currentCursor = startStep;
2886
+ continue;
2887
+ }
2888
+ const idealDuration = notes[0].durationSteps;
2889
+ const durStr = this.stepsToMMLDuration(idealDuration, physicsLimit);
2890
+ const actualStepGenerated = this.getStepFromDottedMML(durStr);
2891
+ if (notes.length > 1) {
2892
+ const noteStrs = notes.map((n) => {
2893
+ const oct = Math.floor(n.pitch / 12) - 1;
2894
+ const name = PITCH_MAP[n.pitch % 12];
2895
+ return `o${oct}${name}`;
2896
+ });
2897
+ segments.push(`[${noteStrs.join("")}]${durStr}`);
2898
+ } else {
2899
+ const { text, currentOctave } = this.getNoteWithOctave(
2900
+ notes[0].pitch,
2901
+ lastOctave
2902
+ );
2903
+ segments.push(`${text}${durStr}`);
2904
+ lastOctave = currentOctave;
2905
+ }
2906
+ currentCursor += actualStepGenerated;
1809
2907
  }
2908
+ fillRests(endStep);
1810
2909
  return `${header} ${segments.join(" ")}`;
1811
2910
  };
1812
2911
  /**
@@ -1884,13 +2983,51 @@ var PITCH_MAP2 = {
1884
2983
  a: 9,
1885
2984
  b: 11
1886
2985
  };
1887
- var TRACK_INDEX_COUNT = 4;
2986
+ var clamp2 = (value, lo, hi) => Math.min(hi, Math.max(lo, value));
2987
+ var META_DIRECTIVE = /#(inst|drum|volume)=([\w-]+)/gi;
2988
+ var parseMmlMeta = (mml) => {
2989
+ const meta = {};
2990
+ for (const m of mml.matchAll(META_DIRECTIVE)) {
2991
+ const key = m[1].toLowerCase();
2992
+ if (key === "inst") meta.instrument = m[2];
2993
+ else if (key === "drum") meta.drum = m[2];
2994
+ else if (key === "volume") {
2995
+ const v = Number.parseInt(m[2], 10);
2996
+ if (!Number.isNaN(v)) meta.volume = v;
2997
+ }
2998
+ }
2999
+ return meta;
3000
+ };
3001
+ var stripMmlMeta = (mml) => mml.replace(META_DIRECTIVE, "");
3002
+ var formatMmlMeta = (meta) => {
3003
+ const parts = [];
3004
+ if (meta.instrument) parts.push(`#inst=${meta.instrument}`);
3005
+ if (meta.drum) parts.push(`#drum=${meta.drum}`);
3006
+ if (meta.volume !== void 0) parts.push(`#volume=${meta.volume}`);
3007
+ return parts.join(" ");
3008
+ };
1888
3009
  var parseMML = (mml, options = {}) => {
1889
3010
  const stepsPerBar = options.stepsPerBar ?? 192;
3011
+ const collectTokens = options.collectTokens ?? false;
3012
+ const collectLyrics = options.collectLyrics ?? false;
3013
+ const clampTrackCount = options.clampTrackCount;
1890
3014
  const placements = [];
3015
+ const tokenTracks = /* @__PURE__ */ new Map();
1891
3016
  let bpm = null;
1892
- if (!mml) return { placements, bpm };
1893
- const fullMML = mml.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "").replace(/[\n\r]+/g, " ").trim();
3017
+ if (!mml) {
3018
+ return {
3019
+ placements,
3020
+ bpm,
3021
+ tokenTracks: collectTokens ? tokenTracks : void 0,
3022
+ lyrics: collectLyrics ? /* @__PURE__ */ new Map() : void 0,
3023
+ meta: {}
3024
+ };
3025
+ }
3026
+ const noComments = mml.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
3027
+ const meta = parseMmlMeta(noComments);
3028
+ const noMeta = stripMmlMeta(noComments);
3029
+ const lyrics = collectLyrics ? parseLyrics(noMeta) : void 0;
3030
+ const fullMML = stripLyrics(noMeta).replace(/[\n\r]+/g, " ").trim();
1894
3031
  const parts = fullMML.split(/(@\d+)/).filter((p) => p.trim().length > 0);
1895
3032
  let trackIndex = 0;
1896
3033
  let octave = 4;
@@ -1900,7 +3037,7 @@ var parseMML = (mml, options = {}) => {
1900
3037
  const part = rawPart.trim();
1901
3038
  if (part.startsWith("@")) {
1902
3039
  let idx = Number.parseInt(part.substring(1), 10);
1903
- if (idx >= TRACK_INDEX_COUNT) idx = 2;
3040
+ if (clampTrackCount !== void 0 && idx >= clampTrackCount) idx = 2;
1904
3041
  trackIndex = idx;
1905
3042
  octave = 4;
1906
3043
  currentStep = 0;
@@ -1909,13 +3046,27 @@ var parseMML = (mml, options = {}) => {
1909
3046
  }
1910
3047
  const body = part.replace(/\s+/g, "").toLowerCase();
1911
3048
  let j = 0;
3049
+ const pushTok = (type, start, dur, from) => {
3050
+ if (!collectTokens) return;
3051
+ let arr = tokenTracks.get(trackIndex);
3052
+ if (!arr) {
3053
+ arr = [];
3054
+ tokenTracks.set(trackIndex, arr);
3055
+ }
3056
+ arr.push({
3057
+ text: body.slice(from, j),
3058
+ startStep: start,
3059
+ durationSteps: dur,
3060
+ type
3061
+ });
3062
+ };
1912
3063
  const parseLength = () => {
1913
3064
  let numStr = "";
1914
3065
  while (j < body.length && /\d/.test(body[j])) {
1915
3066
  numStr += body[j];
1916
3067
  j++;
1917
3068
  }
1918
- const len = numStr ? Number.parseInt(numStr, 10) : baseLength;
3069
+ const len = numStr ? clamp2(Number.parseInt(numStr, 10), 1, 64) : baseLength;
1919
3070
  let steps = Math.round(stepsPerBar / len);
1920
3071
  while (j < body.length && body[j] === ".") {
1921
3072
  steps = Math.round(steps * 1.5);
@@ -1925,6 +3076,7 @@ var parseMML = (mml, options = {}) => {
1925
3076
  };
1926
3077
  while (j < body.length) {
1927
3078
  const ch = body[j];
3079
+ const tokStart = j;
1928
3080
  if (ch === "o") {
1929
3081
  j++;
1930
3082
  let numStr = "";
@@ -1932,13 +3084,16 @@ var parseMML = (mml, options = {}) => {
1932
3084
  numStr += body[j];
1933
3085
  j++;
1934
3086
  }
1935
- octave = Number.parseInt(numStr, 10) || 4;
3087
+ octave = clamp2(Number.parseInt(numStr, 10) || 4, 0, 8);
3088
+ pushTok("octave", currentStep, 0, tokStart);
1936
3089
  } else if (ch === ">") {
1937
3090
  octave++;
1938
3091
  j++;
3092
+ pushTok("shift", currentStep, 0, tokStart);
1939
3093
  } else if (ch === "<") {
1940
3094
  octave--;
1941
3095
  j++;
3096
+ pushTok("shift", currentStep, 0, tokStart);
1942
3097
  } else if (ch === "l") {
1943
3098
  j++;
1944
3099
  let numStr = "";
@@ -1946,11 +3101,15 @@ var parseMML = (mml, options = {}) => {
1946
3101
  numStr += body[j];
1947
3102
  j++;
1948
3103
  }
1949
- baseLength = Number.parseInt(numStr, 10) || 16;
3104
+ baseLength = clamp2(Number.parseInt(numStr, 10) || 16, 1, 64);
3105
+ pushTok("length", currentStep, 0, tokStart);
1950
3106
  } else if (ch === "r") {
1951
3107
  j++;
1952
- currentStep += parseLength();
1953
- } else if (ch === "t" || ch === "v" || ch === "q") {
3108
+ const restStart = currentStep;
3109
+ const restSteps = parseLength();
3110
+ pushTok("rest", restStart, restSteps, tokStart);
3111
+ currentStep += restSteps;
3112
+ } else if (ch === "t" || ch === "v" || ch === "q" || ch === "p") {
1954
3113
  j++;
1955
3114
  let numStr = "";
1956
3115
  while (j < body.length && /\d/.test(body[j])) {
@@ -1958,8 +3117,9 @@ var parseMML = (mml, options = {}) => {
1958
3117
  j++;
1959
3118
  }
1960
3119
  if (ch === "t" && trackIndex === 0 && numStr) {
1961
- bpm = Number.parseInt(numStr, 10);
3120
+ bpm = clamp2(Number.parseInt(numStr, 10), 1, 255);
1962
3121
  }
3122
+ pushTok("ctrl", currentStep, 0, tokStart);
1963
3123
  } else if (ch === "[") {
1964
3124
  j++;
1965
3125
  const chordNotes = [];
@@ -1990,7 +3150,7 @@ var parseMML = (mml, options = {}) => {
1990
3150
  numStr += body[j];
1991
3151
  j++;
1992
3152
  }
1993
- octave = Number.parseInt(numStr, 10) || 4;
3153
+ octave = clamp2(Number.parseInt(numStr, 10) || 4, 0, 8);
1994
3154
  } else {
1995
3155
  j++;
1996
3156
  }
@@ -2005,6 +3165,7 @@ var parseMML = (mml, options = {}) => {
2005
3165
  durationSteps: Math.max(1, steps)
2006
3166
  });
2007
3167
  }
3168
+ pushTok("chord", currentStep, Math.max(1, steps), tokStart);
2008
3169
  currentStep += steps;
2009
3170
  octave = savedOctave;
2010
3171
  } else if (Object.hasOwn(PITCH_MAP2, ch)) {
@@ -2025,18 +3186,25 @@ var parseMML = (mml, options = {}) => {
2025
3186
  pitch: midiPitch,
2026
3187
  durationSteps: Math.max(1, steps)
2027
3188
  });
3189
+ pushTok("note", currentStep, Math.max(1, steps), tokStart);
2028
3190
  currentStep += steps;
2029
3191
  } else {
2030
3192
  j++;
2031
3193
  }
2032
3194
  }
2033
3195
  }
2034
- return { placements, bpm };
3196
+ return {
3197
+ placements,
3198
+ bpm,
3199
+ tokenTracks: collectTokens ? tokenTracks : void 0,
3200
+ lyrics,
3201
+ meta
3202
+ };
2035
3203
  };
2036
3204
 
2037
3205
  // src/sequencer.ts
2038
3206
  var STEPS_PER_BEAT2 = 48;
2039
- var PLAN_TIME = 0.1;
3207
+ var PLAN_TIME = 0.5;
2040
3208
  var TICK_INTERVAL_MS = 20;
2041
3209
  var createSequencer = (options) => {
2042
3210
  let timeline = [];
@@ -2046,11 +3214,14 @@ var createSequencer = (options) => {
2046
3214
  let animationId = null;
2047
3215
  let active = false;
2048
3216
  let fromStepValue = 0;
3217
+ let trackVolumeMap = /* @__PURE__ */ new Map();
2049
3218
  const secondsPerStep = () => 60 / options.getBpm() / STEPS_PER_BEAT2;
2050
3219
  const buildTimeline = (fromStep) => {
2051
3220
  timeline = [];
3221
+ trackVolumeMap = /* @__PURE__ */ new Map();
2052
3222
  const sps = secondsPerStep();
2053
3223
  for (const track of options.getTracks()) {
3224
+ trackVolumeMap.set(track.id, track.volume);
2054
3225
  for (const note of track.notes) {
2055
3226
  const relativeStart = note.startStep - fromStep;
2056
3227
  if (relativeStart < 0) continue;
@@ -2071,6 +3242,9 @@ var createSequencer = (options) => {
2071
3242
  const sps = secondsPerStep();
2072
3243
  const time = options.getAudioTime() - startTime;
2073
3244
  const soloId = options.getSoloTrackId();
3245
+ for (const track of options.getTracks()) {
3246
+ trackVolumeMap.set(track.id, track.volume);
3247
+ }
2074
3248
  while (nowIndex < timeline.length) {
2075
3249
  const ev = timeline[nowIndex];
2076
3250
  if (soloId && ev.trackId !== soloId) {
@@ -2081,11 +3255,12 @@ var createSequencer = (options) => {
2081
3255
  if (_when > PLAN_TIME) break;
2082
3256
  nowIndex++;
2083
3257
  const velocityVolume = ev.velocity / 127;
3258
+ const currentVolume = (trackVolumeMap.get(ev.trackId) ?? ev.volume * 100) / 100;
2084
3259
  options.onPlayNote({
2085
3260
  trackId: ev.trackId,
2086
3261
  pitch: ev.pitch,
2087
3262
  velocity: ev.velocity,
2088
- volume: ev.volume * velocityVolume,
3263
+ volume: currentVolume * velocityVolume,
2089
3264
  when: Math.max(0, _when),
2090
3265
  duration: ev.duration
2091
3266
  });
@@ -2136,13 +3311,14 @@ var createSequencer = (options) => {
2136
3311
  }
2137
3312
  active = false;
2138
3313
  };
3314
+ const START_DELAY = 0.1;
2139
3315
  const start = (fromStep) => {
2140
3316
  stop();
2141
3317
  fromStepValue = fromStep ?? options.getPlayStartStep();
2142
3318
  buildTimeline(fromStepValue);
2143
3319
  if (timeline.length === 0 && !options.getDrumPattern()?.length) return;
2144
3320
  active = true;
2145
- startTime = options.getAudioTime();
3321
+ startTime = options.getAudioTime() + START_DELAY;
2146
3322
  nowIndex = 0;
2147
3323
  intervalId = setInterval(scheduleTick, TICK_INTERVAL_MS);
2148
3324
  animationId = requestAnimationFrame(animate);
@@ -2150,7 +3326,8 @@ var createSequencer = (options) => {
2150
3326
  return {
2151
3327
  start,
2152
3328
  stop,
2153
- isActive: () => active
3329
+ isActive: () => active,
3330
+ getStartTime: () => startTime
2154
3331
  };
2155
3332
  };
2156
3333
 
@@ -2612,10 +3789,12 @@ var DAW_CSS = `
2612
3789
 
2613
3790
  /* \u2500\u2500\u2500 \u30ED\u30FC\u30C7\u30A3\u30F3\u30B0\u30AA\u30FC\u30D0\u30FC\u30EC\u30A4 \u2500\u2500\u2500 */
2614
3791
  .dtm-overlay {
2615
- position: fixed; inset: 0; z-index: 1000;
3792
+ position: absolute; inset: 0; z-index: 1000;
2616
3793
  background: rgba(0,0,0,.92);
2617
3794
  display: flex; align-items: center; justify-content: center;
2618
3795
  flex-direction: column; gap: 14px;
3796
+ pointer-events: auto;
3797
+ cursor: wait;
2619
3798
  }
2620
3799
  .dtm-overlay[hidden] { display: none; }
2621
3800
  .dtm-overlay::before {
@@ -2643,6 +3822,22 @@ var DAW_CSS = `
2643
3822
  animation: dtm-load 1.6s steps(8) infinite;
2644
3823
  }
2645
3824
  @keyframes dtm-load { 0%{width:0} 100%{width:100%} }
3825
+ /* \u9032\u6357\u304C\u78BA\u5B9A\u3057\u305F\u3089\u7121\u9650\u30EB\u30FC\u30D7\u6F14\u51FA\u3092\u6B62\u3081\u3001\u5B9F\u6E2C\u5024\u3067\u5857\u308A\u3064\u3076\u3059 */
3826
+ .dtm-spinner--determinate::after { display: none; }
3827
+ .dtm-spinner-fill {
3828
+ position: absolute;
3829
+ left: 0; top: 0; height: 100%;
3830
+ width: 0;
3831
+ background: var(--dtm-primary);
3832
+ transition: width .12s steps(8);
3833
+ }
3834
+ .dtm-loading-label {
3835
+ font-family: var(--dtm-font);
3836
+ font-size: 11px;
3837
+ color: var(--dtm-primary);
3838
+ letter-spacing: .15em;
3839
+ min-height: 1em;
3840
+ }
2646
3841
 
2647
3842
  @keyframes dtm-blink { 0%,100%{opacity:1} 50%{opacity:0} }
2648
3843
  .dtm-blink { animation: dtm-blink 1s steps(1) infinite; }
@@ -2655,6 +3850,112 @@ var DAW_CSS = `
2655
3850
  .dtm-daw { gap: 8px; padding: 10px; }
2656
3851
  .dtm-roll { height: 420px; }
2657
3852
  }
3853
+
3854
+ /* ====================================================
3855
+ MML PLAYER \u2014 \u518D\u751F\u5C02\u7528\u30D3\u30E5\u30FC\uFF08mountMmlPlayer\uFF09
3856
+ ==================================================== */
3857
+ .dtm-player {
3858
+ display: flex;
3859
+ flex-direction: column;
3860
+ gap: var(--dtm-gap);
3861
+ padding: var(--dtm-gap);
3862
+ background: var(--dtm-deep);
3863
+ border: 2px solid var(--dtm-border2);
3864
+ box-shadow: 4px 4px 0 var(--c-black);
3865
+ }
3866
+ .dtm-player-head {
3867
+ display: flex;
3868
+ align-items: center;
3869
+ gap: 8px;
3870
+ flex-wrap: wrap;
3871
+ }
3872
+ .dtm-player-play {
3873
+ flex: 0 0 auto;
3874
+ width: 28px;
3875
+ height: 28px;
3876
+ display: flex;
3877
+ align-items: center;
3878
+ justify-content: center;
3879
+ background: var(--dtm-primary);
3880
+ color: var(--dtm-pfg);
3881
+ border: 2px solid var(--c-black);
3882
+ box-shadow: 2px 2px 0 var(--c-black);
3883
+ cursor: pointer;
3884
+ padding: 0;
3885
+ }
3886
+ .dtm-player-play:active { transform: translate(2px, 2px); box-shadow: none; }
3887
+ .dtm-player-play--stop { background: var(--dtm-danger); }
3888
+ .dtm-player-play:disabled { opacity: 0.4; cursor: default; }
3889
+ .dtm-player-tempo,
3890
+ .dtm-player-time {
3891
+ font-family: 'k8x12', monospace;
3892
+ font-size: 12px;
3893
+ color: var(--dtm-muted);
3894
+ }
3895
+ .dtm-player-time { color: var(--dtm-text); min-width: 3em; }
3896
+ .dtm-player-dots {
3897
+ margin-left: auto;
3898
+ display: flex;
3899
+ align-items: center;
3900
+ gap: 6px;
3901
+ }
3902
+ .dtm-player-dot { width: 8px; height: 8px; display: inline-block; }
3903
+ .dtm-player-chip {
3904
+ font-family: 'k8x12', monospace;
3905
+ font-size: 9px;
3906
+ color: var(--dtm-text);
3907
+ background: var(--dtm-border2);
3908
+ padding: 2px 6px;
3909
+ white-space: nowrap;
3910
+ }
3911
+ .dtm-player-lane-row {
3912
+ display: flex;
3913
+ align-items: stretch;
3914
+ gap: 6px;
3915
+ }
3916
+ .dtm-player-lane-label {
3917
+ flex: 0 0 auto;
3918
+ width: 16px;
3919
+ display: flex;
3920
+ flex-direction: column;
3921
+ align-items: center;
3922
+ gap: 2px;
3923
+ padding-top: 4px;
3924
+ }
3925
+ .dtm-player-lane-no {
3926
+ font-family: 'k8x12', monospace;
3927
+ font-size: 9px;
3928
+ color: var(--dtm-muted);
3929
+ }
3930
+ .dtm-player-lane {
3931
+ position: relative; /* \u30C8\u30FC\u30AF\u30F3\u306E offsetParent \u3092\u30EC\u30FC\u30F3\u306B\u56FA\u5B9A\u3057\u3001\u4E2D\u592E\u5BC4\u305B\u8A08\u7B97\u3092\u6B63\u3059 */
3932
+ flex: 1 1 auto;
3933
+ overflow-x: auto;
3934
+ white-space: nowrap;
3935
+ background: var(--c-black);
3936
+ border: 2px solid var(--dtm-border2);
3937
+ padding: 6px;
3938
+ scrollbar-width: none;
3939
+ }
3940
+ .dtm-player-lane::-webkit-scrollbar { display: none; }
3941
+ .dtm-tk {
3942
+ font-family: 'k8x12', monospace;
3943
+ font-size: 12px;
3944
+ color: var(--dtm-text);
3945
+ }
3946
+ .dtm-tk--rest { color: var(--dtm-muted); }
3947
+ .dtm-tk--octave,
3948
+ .dtm-tk--shift,
3949
+ .dtm-tk--length,
3950
+ .dtm-tk--ctrl { color: var(--dtm-border2); }
3951
+ .dtm-tk--lyric { color: var(--dtm-text); letter-spacing: 1px; }
3952
+ .dtm-tk--break { color: var(--dtm-muted); opacity: 0.7; margin: 0 2px; }
3953
+ .dtm-tk--meta { color: var(--dtm-border2); margin-right: 4px; }
3954
+ .dtm-tk.is-active {
3955
+ background: var(--tk, var(--dtm-primary));
3956
+ color: var(--c-black);
3957
+ font-weight: bold;
3958
+ }
2658
3959
  `;
2659
3960
  var injectStyles = (doc = document) => {
2660
3961
  if (doc.getElementById(STYLE_ID)) return;
@@ -2663,6 +3964,45 @@ var injectStyles = (doc = document) => {
2663
3964
  style.textContent = DAW_CSS;
2664
3965
  doc.head.appendChild(style);
2665
3966
  };
3967
+ var showLoadingOverlay = (container) => {
3968
+ const origPos = container.style.position;
3969
+ const computed = window.getComputedStyle(container).position;
3970
+ if (computed === "static") {
3971
+ container.style.position = "relative";
3972
+ }
3973
+ const doc = container.ownerDocument ?? document;
3974
+ const overlay = doc.createElement("div");
3975
+ overlay.className = "dtm-overlay";
3976
+ const spinner = doc.createElement("div");
3977
+ spinner.className = "dtm-spinner";
3978
+ const fill = doc.createElement("i");
3979
+ fill.className = "dtm-spinner-fill";
3980
+ spinner.appendChild(fill);
3981
+ overlay.appendChild(spinner);
3982
+ const label = doc.createElement("div");
3983
+ label.className = "dtm-loading-label";
3984
+ overlay.appendChild(label);
3985
+ container.appendChild(overlay);
3986
+ const setProgress = (done, total) => {
3987
+ if (total > 0) {
3988
+ const pct = Math.max(0, Math.min(100, Math.round(done / total * 100)));
3989
+ spinner.classList.add("dtm-spinner--determinate");
3990
+ fill.style.width = `${pct}%`;
3991
+ label.textContent = `${done} / ${total} (${pct}%)`;
3992
+ } else {
3993
+ spinner.classList.remove("dtm-spinner--determinate");
3994
+ fill.style.width = "0";
3995
+ label.textContent = "";
3996
+ }
3997
+ };
3998
+ return {
3999
+ remove: () => {
4000
+ overlay.remove();
4001
+ container.style.position = origPos;
4002
+ },
4003
+ setProgress
4004
+ };
4005
+ };
2666
4006
 
2667
4007
  // src/daw.ts
2668
4008
  var BASE_STEP_WIDTH = 0.5;
@@ -2812,7 +4152,13 @@ var TRACKS_ADVANCED = [
2812
4152
  }
2813
4153
  ];
2814
4154
  var DEFAULT_TRACKS = TRACKS_SIMPLE;
2815
- var clamp = (v, min, max) => Math.min(Math.max(v, min), max);
4155
+ var LYRIC_MODELS = ["klatt", ...Object.keys(KOE_VOICEBANKS)];
4156
+ var LYRIC_MODEL_LABELS = {
4157
+ klatt: "\u8EFD\u91CF\u30ED\u30DC\u58F0",
4158
+ ...KOE_VOICEBANK_LABELS
4159
+ };
4160
+ var lyricModelLabel = (model) => LYRIC_MODEL_LABELS[model] ?? model;
4161
+ var clamp3 = (v, min, max) => Math.min(Math.max(v, min), max);
2816
4162
  var mountDAW = (target, options = {}) => {
2817
4163
  injectStyles();
2818
4164
  const getAudioTime = options.getAudioTime ?? (() => performance.now() / 1e3);
@@ -2843,6 +4189,7 @@ var mountDAW = (target, options = {}) => {
2843
4189
  let masterVolume = 50;
2844
4190
  let drumVolume = 80;
2845
4191
  let currentDrumPattern = refs.drumSelect.value;
4192
+ let currentInstrument = "";
2846
4193
  let activeTrackId = trackConfigs[0].id;
2847
4194
  let activeToolMode = "pen";
2848
4195
  let currentInsertLength = 48;
@@ -2852,6 +4199,7 @@ var mountDAW = (target, options = {}) => {
2852
4199
  let currentOffsetY = (104 - 1 - 60) * renderConfig.keyHeight - 215;
2853
4200
  let playStartStep = 0;
2854
4201
  let isSolo = false;
4202
+ let lyricTrackIndices = /* @__PURE__ */ new Set();
2855
4203
  let playbackState = "stopped";
2856
4204
  let pausedPlayStep = 0;
2857
4205
  let currentPlayStep = 0;
@@ -2878,9 +4226,34 @@ var mountDAW = (target, options = {}) => {
2878
4226
  volume: config.volume,
2879
4227
  savedChordInput: "",
2880
4228
  savedChordPattern: "block",
2881
- savedChordRoot: 0
4229
+ savedChordRoot: 0,
4230
+ lyrics: "",
4231
+ lyricModel: "",
4232
+ // 既定は「なし」(歌わない)
4233
+ vocalVolume: 300,
4234
+ vocalGate: 100,
4235
+ vocalPan: 64
2882
4236
  }));
2883
4237
  };
4238
+ const buildLyricsMap = () => {
4239
+ const map = /* @__PURE__ */ new Map();
4240
+ trackStates.forEach((t, i) => {
4241
+ const model = t.lyricModel.trim();
4242
+ const text = t.lyrics.trim();
4243
+ if (!model || !text) return;
4244
+ const syllables = normalizeLyrics(text);
4245
+ if (syllables.length === 0) return;
4246
+ map.set(i, {
4247
+ trackId: i,
4248
+ model: model.toLowerCase(),
4249
+ volume: t.vocalVolume,
4250
+ gate: t.vocalGate,
4251
+ pan: t.vocalPan,
4252
+ syllables
4253
+ });
4254
+ });
4255
+ return map;
4256
+ };
2884
4257
  const getActive = () => trackStates.find((t) => t.config.id === activeTrackId) ?? trackStates[0];
2885
4258
  const getMaxNoteStep = () => {
2886
4259
  let maxStep = renderConfig.stepsPerBar * 4;
@@ -2984,7 +4357,7 @@ var mountDAW = (target, options = {}) => {
2984
4357
  );
2985
4358
  const ratio = currentOffsetX / maxOffsetX;
2986
4359
  refs.hScrollThumb.style.width = `${thumbW}px`;
2987
- refs.hScrollThumb.style.left = `${clamp(ratio * (sbW - thumbW), 0, sbW - thumbW)}px`;
4360
+ refs.hScrollThumb.style.left = `${clamp3(ratio * (sbW - thumbW), 0, sbW - thumbW)}px`;
2988
4361
  }
2989
4362
  const totalHeight = renderConfig.keyCount * renderConfig.keyHeight;
2990
4363
  const sbH = refs.vScroll.clientHeight;
@@ -3043,9 +4416,9 @@ var mountDAW = (target, options = {}) => {
3043
4416
  if (maxOffsetX <= 0) return;
3044
4417
  const rect = refs.hScroll.getBoundingClientRect();
3045
4418
  const thumbW = Number.parseFloat(refs.hScrollThumb.style.width) || 40;
3046
- const x = clamp(clientX - rect.left - thumbW / 2, 0, rect.width - thumbW);
4419
+ const x = clamp3(clientX - rect.left - thumbW / 2, 0, rect.width - thumbW);
3047
4420
  const ratio = x / (rect.width - thumbW);
3048
- currentOffsetX = clamp(ratio * maxOffsetX, 0, maxOffsetX);
4421
+ currentOffsetX = clamp3(ratio * maxOffsetX, 0, maxOffsetX);
3049
4422
  setDrawOffset(currentOffsetX, currentOffsetY);
3050
4423
  redrawAll();
3051
4424
  };
@@ -3054,9 +4427,9 @@ var mountDAW = (target, options = {}) => {
3054
4427
  if (maxOffset <= 0) return;
3055
4428
  const rect = refs.vScroll.getBoundingClientRect();
3056
4429
  const thumbH = Number.parseFloat(refs.vScrollThumb.style.height) || 40;
3057
- const y = clamp(clientY - rect.top - thumbH / 2, 0, rect.height - thumbH);
4430
+ const y = clamp3(clientY - rect.top - thumbH / 2, 0, rect.height - thumbH);
3058
4431
  const ratio = y / (rect.height - thumbH);
3059
- currentOffsetY = clamp(ratio * maxOffset, 0, maxOffset);
4432
+ currentOffsetY = clamp3(ratio * maxOffset, 0, maxOffset);
3060
4433
  setDrawOffset(currentOffsetX, currentOffsetY);
3061
4434
  redrawAll();
3062
4435
  };
@@ -3338,7 +4711,7 @@ var mountDAW = (target, options = {}) => {
3338
4711
  "wheel",
3339
4712
  (event) => {
3340
4713
  event.preventDefault();
3341
- currentOffsetY = clamp(
4714
+ currentOffsetY = clamp3(
3342
4715
  currentOffsetY + event.deltaY,
3343
4716
  0,
3344
4717
  getMaxOffsetY()
@@ -3386,7 +4759,7 @@ var mountDAW = (target, options = {}) => {
3386
4759
  const centerKey = (currentOffsetY + canvas.height / 2) / renderConfig.keyHeight;
3387
4760
  renderConfig.keyHeight = BASE_KEY_HEIGHT * zoomY / 100;
3388
4761
  refs.zoomYLabel.textContent = `${zoomY}%`;
3389
- currentOffsetY = clamp(
4762
+ currentOffsetY = clamp3(
3390
4763
  centerKey * renderConfig.keyHeight - canvas.height / 2,
3391
4764
  0,
3392
4765
  getMaxOffsetY()
@@ -3410,8 +4783,11 @@ var mountDAW = (target, options = {}) => {
3410
4783
  getSoloTrackId: () => isSolo ? activeTrackId : null,
3411
4784
  getAudioTime,
3412
4785
  onPlayNote: (e) => {
3413
- const volume = e.volume * (masterVolume / 100);
3414
- options.onPlayNote?.({ ...e, volume });
4786
+ const idx = trackStates.findIndex((t) => t.config.id === e.trackId);
4787
+ if (idx >= 0 && lyricTrackIndices.has(idx) && options.singingVoices) {
4788
+ return;
4789
+ }
4790
+ options.onPlayNote?.({ ...e, volume: e.volume * (masterVolume / 100) });
3415
4791
  },
3416
4792
  onPlayDrum: (e) => {
3417
4793
  const velocity = e.velocity * (drumVolume / 100) * (masterVolume / 100);
@@ -3437,10 +4813,52 @@ var mountDAW = (target, options = {}) => {
3437
4813
  },
3438
4814
  stepsPerBar: renderConfig.stepsPerBar
3439
4815
  });
3440
- const play = () => {
4816
+ const play = async () => {
3441
4817
  options.onResumeAudio?.();
3442
4818
  if (playbackState === "playing") return;
3443
4819
  const fromStep = playbackState === "paused" ? pausedPlayStep : playStartStep;
4820
+ options.singingVoices?.reset();
4821
+ const lyricMap = buildLyricsMap();
4822
+ lyricTrackIndices = new Set(lyricMap.keys());
4823
+ const secondsPerStep = 60 / bpm / 48;
4824
+ const streamTracks = options.singingVoices ? [...lyricMap.values()].map((lt) => {
4825
+ const trackState = trackStates[lt.trackId];
4826
+ const sorted = [...trackState?.core.getNotes() ?? []].sort(
4827
+ (a, b) => a.startStep - b.startStep
4828
+ );
4829
+ const gate = (lt.gate ?? 100) / 100;
4830
+ const count = Math.min(sorted.length, lt.syllables.length);
4831
+ const notes = [];
4832
+ for (let i = 0; i < count; i++) {
4833
+ const n = sorted[i];
4834
+ if (n.startStep < fromStep) continue;
4835
+ notes.push({
4836
+ syllable: lt.syllables[i],
4837
+ pitch: n.pitch,
4838
+ startSec: (n.startStep - fromStep) * secondsPerStep,
4839
+ durationSec: n.durationSteps * secondsPerStep * gate
4840
+ });
4841
+ }
4842
+ return {
4843
+ model: lt.model,
4844
+ volume: vocalVolumeToGain(lt.volume ?? 300) * (masterVolume / 100),
4845
+ pan: panToStereo(lt.pan ?? 64),
4846
+ notes
4847
+ };
4848
+ }) : [];
4849
+ const voices = options.singingVoices;
4850
+ const streaming = !!voices && streamTracks.some((t) => t.notes.length > 0);
4851
+ if (streaming && voices) {
4852
+ const overlay = showLoadingOverlay(target);
4853
+ try {
4854
+ await voices.loadModels(streamTracks.map((t) => t.model));
4855
+ await voices.warm(streamTracks);
4856
+ } catch (err) {
4857
+ console.warn("[dtm] voice preload failed", err);
4858
+ } finally {
4859
+ overlay.remove();
4860
+ }
4861
+ }
3444
4862
  if (playbackState !== "paused") {
3445
4863
  const canvas = getGridCanvas();
3446
4864
  currentOffsetX = Math.max(
@@ -3451,17 +4869,22 @@ var mountDAW = (target, options = {}) => {
3451
4869
  }
3452
4870
  playbackState = "playing";
3453
4871
  sequencer.start(fromStep);
4872
+ if (streaming && voices) {
4873
+ voices.startStream(streamTracks, sequencer.getStartTime());
4874
+ }
3454
4875
  updateTransport();
3455
4876
  };
3456
4877
  const pause = () => {
3457
4878
  if (playbackState !== "playing") return;
3458
4879
  pausedPlayStep = currentPlayStep;
3459
4880
  sequencer.stop();
4881
+ options.singingVoices?.stopStream();
3460
4882
  playbackState = "paused";
3461
4883
  updateTransport();
3462
4884
  };
3463
4885
  const stop = () => {
3464
4886
  sequencer.stop();
4887
+ options.singingVoices?.stopStream();
3465
4888
  playbackState = "stopped";
3466
4889
  currentPlayStep = 0;
3467
4890
  updateTransport();
@@ -3511,6 +4934,104 @@ var mountDAW = (target, options = {}) => {
3511
4934
  active.core.setVolume(active.volume);
3512
4935
  volLabel.textContent = String(active.volume);
3513
4936
  });
4937
+ const lyricDiv = document.createElement("div");
4938
+ lyricDiv.className = "dtm-row";
4939
+ lyricDiv.style.flexDirection = "column";
4940
+ lyricDiv.style.alignItems = "stretch";
4941
+ lyricDiv.innerHTML = `
4942
+ <div class="dtm-row">
4943
+ <span class="dtm-label">\u266A \u6B4C\u8A5E</span>
4944
+ <select class="dtm-select" data-dtm="lyric-model" aria-label="\u6B4C\u5531\u30E2\u30C7\u30EB"></select>
4945
+ <span class="dtm-label dtm-grow" data-dtm="lyric-count" style="text-align:right"></span>
4946
+ </div>
4947
+ <div class="dtm-row" data-dtm="lyric-body" style="flex-direction:column;align-items:stretch">
4948
+ <div class="dtm-row">
4949
+ <span class="dtm-label">\u58F0\u91CF</span>
4950
+ <input type="range" class="dtm-range dtm-grow" data-dtm="lyric-vol" min="0" max="${MAX_VOCAL_VOLUME}" aria-label="\u6B4C\u5531\u306E\u58F0\u91CF\uFF08100=\u7B49\u500D\u3001100\u8D85\u3067\u30D6\u30FC\u30B9\u30C8\u3001\u65E2\u5B9A300\uFF09">
4951
+ <span class="dtm-label" data-dtm="lyric-vol-label"></span>
4952
+ </div>
4953
+ <div class="dtm-row">
4954
+ <span class="dtm-label">\u5B9A\u4F4D</span>
4955
+ <input type="range" class="dtm-range dtm-grow" data-dtm="lyric-pan" min="0" max="127" aria-label="\u6B4C\u5531\u306E\u30B9\u30C6\u30EC\u30AA\u5B9A\u4F4D\uFF08\u5DE6\u53F3\uFF09">
4956
+ <span class="dtm-label" data-dtm="lyric-pan-label"></span>
4957
+ </div>
4958
+ <textarea class="dtm-textarea" data-dtm="lyric-input" rows="2" placeholder="\u3072\u3089\u304C\u306A\u30FB\u30AB\u30BF\u30AB\u30CA\u3067\u6B4C\u8A5E\uFF08\u4F8B: \u3069\u308C\u307F\u3075\u3041\u305D\u3089\u3057\u3069\uFF09"></textarea>
4959
+ </div>`;
4960
+ refs.trackBody.appendChild(lyricDiv);
4961
+ const lyricModelSel = lyricDiv.querySelector(
4962
+ '[data-dtm="lyric-model"]'
4963
+ );
4964
+ const lyricBody = lyricDiv.querySelector(
4965
+ '[data-dtm="lyric-body"]'
4966
+ );
4967
+ const lyricInput = lyricDiv.querySelector(
4968
+ '[data-dtm="lyric-input"]'
4969
+ );
4970
+ const lyricCount = lyricDiv.querySelector(
4971
+ '[data-dtm="lyric-count"]'
4972
+ );
4973
+ const lyricVol = lyricDiv.querySelector(
4974
+ '[data-dtm="lyric-vol"]'
4975
+ );
4976
+ const lyricVolLabel = lyricDiv.querySelector(
4977
+ '[data-dtm="lyric-vol-label"]'
4978
+ );
4979
+ const lyricPan = lyricDiv.querySelector(
4980
+ '[data-dtm="lyric-pan"]'
4981
+ );
4982
+ const lyricPanLabel = lyricDiv.querySelector(
4983
+ '[data-dtm="lyric-pan-label"]'
4984
+ );
4985
+ const fmtPan = (pan) => pan === 64 ? "C" : pan < 64 ? `L${64 - pan}` : `R${pan - 64}`;
4986
+ const addOpt = (value, label) => {
4987
+ const o = document.createElement("option");
4988
+ o.value = value;
4989
+ o.textContent = label;
4990
+ lyricModelSel.appendChild(o);
4991
+ };
4992
+ addOpt("", "\u306A\u3057");
4993
+ for (const m of LYRIC_MODELS) addOpt(m, lyricModelLabel(m));
4994
+ if (active.lyricModel && !LYRIC_MODELS.includes(active.lyricModel)) {
4995
+ addOpt(active.lyricModel, lyricModelLabel(active.lyricModel));
4996
+ }
4997
+ lyricModelSel.value = active.lyricModel;
4998
+ lyricInput.value = active.lyrics;
4999
+ lyricVol.value = String(active.vocalVolume);
5000
+ lyricVolLabel.textContent = String(active.vocalVolume);
5001
+ lyricPan.value = String(active.vocalPan);
5002
+ lyricPanLabel.textContent = fmtPan(active.vocalPan);
5003
+ const updateLyricCount = () => {
5004
+ const n = normalizeLyrics(lyricInput.value).length;
5005
+ lyricCount.textContent = active.lyricModel && n > 0 ? `${n}\u97F3\u7BC0` : "";
5006
+ };
5007
+ const syncLyricVisibility = () => {
5008
+ lyricBody.style.display = active.lyricModel ? "" : "none";
5009
+ updateLyricCount();
5010
+ };
5011
+ syncLyricVisibility();
5012
+ lyricModelSel.addEventListener("change", () => {
5013
+ active.lyricModel = lyricModelSel.value;
5014
+ syncLyricVisibility();
5015
+ });
5016
+ lyricInput.addEventListener("input", () => {
5017
+ active.lyrics = lyricInput.value;
5018
+ updateLyricCount();
5019
+ });
5020
+ lyricVol.addEventListener("input", () => {
5021
+ active.vocalVolume = Number.parseInt(lyricVol.value, 10);
5022
+ lyricVolLabel.textContent = lyricVol.value;
5023
+ });
5024
+ lyricPan.addEventListener("input", () => {
5025
+ active.vocalPan = Number.parseInt(lyricPan.value, 10);
5026
+ lyricPanLabel.textContent = fmtPan(active.vocalPan);
5027
+ });
5028
+ lyricPanLabel.style.cursor = "pointer";
5029
+ lyricPanLabel.title = "\u30BF\u30C3\u30D7\u3067\u4E2D\u592E(C)\u3078";
5030
+ lyricPanLabel.addEventListener("click", () => {
5031
+ active.vocalPan = 64;
5032
+ lyricPan.value = "64";
5033
+ lyricPanLabel.textContent = fmtPan(64);
5034
+ });
3514
5035
  if (active.config.id === "chord" && showChord) {
3515
5036
  const div = document.createElement("div");
3516
5037
  div.className = "dtm-row";
@@ -3598,6 +5119,11 @@ var mountDAW = (target, options = {}) => {
3598
5119
  const barLimitBars = Number(refs.barLimitSelect.value);
3599
5120
  const limitSteps = barLimitBars > 0 ? barLimitBars * renderConfig.stepsPerBar : Infinity;
3600
5121
  const clipNotes = (notes) => limitSteps === Infinity ? notes : notes.filter((n) => n.startStep < limitSteps);
5122
+ const metaLine = formatMmlMeta({
5123
+ instrument: currentInstrument || void 0,
5124
+ drum: currentDrumPattern !== "none" ? currentDrumPattern : void 0,
5125
+ volume: masterVolume
5126
+ });
3601
5127
  if (refs.decomposeChordToggle.checked) {
3602
5128
  const ignoreHeavy = refs.ignoreChordHeavyToggle.checked;
3603
5129
  const targetStates = ignoreHeavy ? trackStates.filter((t) => !isChordHeavyTrack(t.core.getNotes())) : trackStates;
@@ -3607,12 +5133,14 @@ var mountDAW = (target, options = {}) => {
3607
5133
  );
3608
5134
  const monoTracks = decomposeToMonophonic(allNotes);
3609
5135
  const refCore = trackStates[0].core;
3610
- const full2 = monoTracks.map(
5136
+ const decomposedFull = monoTracks.map(
3611
5137
  (notes, i) => `@${i} ${refCore.getMMLFromNotes(notes, bpm, 100).trim()}`
3612
- ).join(";\n");
3613
- const minified2 = monoTracks.map(
5138
+ );
5139
+ const decomposedMini = monoTracks.map(
3614
5140
  (notes, i) => `@${i}${refCore.getMMLFromNotes(notes, bpm, 100).trim().replace(/\s+/g, "")}`
3615
- ).join(";");
5141
+ );
5142
+ const full2 = [metaLine, ...decomposedFull].filter((s) => s.length > 0).join(";\n");
5143
+ const minified2 = [metaLine, ...decomposedMini].filter((s) => s.length > 0).join(";");
3616
5144
  return {
3617
5145
  full: full2,
3618
5146
  minified: minified2,
@@ -3621,12 +5149,30 @@ var mountDAW = (target, options = {}) => {
3621
5149
  barLimit: barLimitBars
3622
5150
  };
3623
5151
  }
3624
- const full = trackStates.map(
5152
+ const trackLines = trackStates.map(
3625
5153
  (t, i) => `@${i} ${t.core.getMMLFromNotes(clipNotes(t.core.getNotes()), bpm, t.volume).trim()}`
3626
- ).join(";\n");
3627
- const minified = trackStates.map(
5154
+ );
5155
+ const trackLinesMini = trackStates.map(
3628
5156
  (t, i) => `@${i}${t.core.getMMLFromNotes(clipNotes(t.core.getNotes()), bpm, t.volume).trim().replace(/\s+/g, "")}`
3629
- ).join(";");
5157
+ );
5158
+ const lyricLines = trackStates.map((t, i) => ({
5159
+ i,
5160
+ text: t.lyrics.trim(),
5161
+ model: t.lyricModel.trim(),
5162
+ vol: t.vocalVolume,
5163
+ gate: t.vocalGate,
5164
+ pan: t.vocalPan
5165
+ })).filter((x) => x.model.length > 0 && x.text.length > 0).map((x) => {
5166
+ const params = [
5167
+ x.vol === 300 ? "" : `v${x.vol}`,
5168
+ x.gate === 100 ? "" : `q${x.gate}`,
5169
+ x.pan === 64 ? "" : `p${x.pan}`
5170
+ ].filter((s) => s.length > 0).join(" ");
5171
+ const head = params ? `${x.model} ${params}` : x.model;
5172
+ return `@@${x.i} ${head} ${x.text}`;
5173
+ });
5174
+ const full = [metaLine, ...trackLines, ...lyricLines].filter((s) => s.length > 0).join(";\n");
5175
+ const minified = [metaLine, ...trackLinesMini, ...lyricLines].filter((s) => s.length > 0).join(";");
3630
5176
  return {
3631
5177
  full,
3632
5178
  minified,
@@ -3647,6 +5193,34 @@ var mountDAW = (target, options = {}) => {
3647
5193
  refs.outputContainer.classList.remove("dtm-hidden");
3648
5194
  updateUndoRedo();
3649
5195
  };
5196
+ const getFirstDetectedPitch = () => {
5197
+ let minStep = Number.MAX_SAFE_INTEGER;
5198
+ let candidateNotes = [];
5199
+ for (const t of trackStates) {
5200
+ for (const note of t.core.getNotes()) {
5201
+ if (note.startStep < minStep) {
5202
+ minStep = note.startStep;
5203
+ candidateNotes = [note];
5204
+ } else if (note.startStep === minStep) {
5205
+ candidateNotes.push(note);
5206
+ }
5207
+ }
5208
+ }
5209
+ if (candidateNotes.length === 0) return null;
5210
+ const sum = candidateNotes.reduce((acc, note) => acc + note.pitch, 0);
5211
+ return Math.round(sum / candidateNotes.length);
5212
+ };
5213
+ const centerPitch = (pitch) => {
5214
+ const canvas = getGridCanvas();
5215
+ const yIndex = renderConfig.keyCount - 1 - (pitch - renderConfig.pitchRangeStart);
5216
+ const logicalY = yIndex * renderConfig.keyHeight;
5217
+ currentOffsetY = clamp3(
5218
+ logicalY - (canvas.height - renderConfig.keyHeight) / 2,
5219
+ 0,
5220
+ getMaxOffsetY()
5221
+ );
5222
+ setDrawOffset(currentOffsetX, currentOffsetY);
5223
+ };
3650
5224
  const clearAll = () => {
3651
5225
  for (const t of trackStates) {
3652
5226
  t.core.resetHistory();
@@ -3658,8 +5232,42 @@ var mountDAW = (target, options = {}) => {
3658
5232
  if (!mml) return;
3659
5233
  clearAll();
3660
5234
  for (const t of trackStates) t.core.setLoadMode(true);
3661
- const { placements, bpm: parsedBpm } = parseMML(mml, {
3662
- stepsPerBar: renderConfig.stepsPerBar
5235
+ const {
5236
+ placements,
5237
+ bpm: parsedBpm,
5238
+ lyrics,
5239
+ meta
5240
+ } = parseMML(mml, {
5241
+ stepsPerBar: renderConfig.stepsPerBar,
5242
+ collectLyrics: true,
5243
+ // このDAWのトラック数を超えるチャンネルはベースへ畳み込む(従来挙動)
5244
+ clampTrackCount: trackStates.length
5245
+ });
5246
+ currentInstrument = meta.instrument ?? "";
5247
+ if (meta.drum && drumPatterns[meta.drum]) {
5248
+ currentDrumPattern = meta.drum;
5249
+ refs.drumSelect.value = meta.drum;
5250
+ }
5251
+ if (meta.volume !== void 0) {
5252
+ masterVolume = meta.volume;
5253
+ refs.masterVolume.value = String(meta.volume);
5254
+ refs.masterVolumeLabel.textContent = `${meta.volume}%`;
5255
+ }
5256
+ for (const t of trackStates) {
5257
+ t.lyrics = "";
5258
+ t.lyricModel = "";
5259
+ t.vocalVolume = 300;
5260
+ t.vocalGate = 100;
5261
+ t.vocalPan = 64;
5262
+ }
5263
+ lyrics?.forEach((lt, idx) => {
5264
+ const t = trackStates[idx];
5265
+ if (!t) return;
5266
+ t.lyrics = lt.syllables.map((s) => s.kana).join("");
5267
+ t.lyricModel = lt.model;
5268
+ t.vocalVolume = lt.volume;
5269
+ t.vocalGate = lt.gate;
5270
+ t.vocalPan = lt.pan;
3663
5271
  });
3664
5272
  for (const p of placements) {
3665
5273
  const t = trackStates[p.trackIndex];
@@ -3675,8 +5283,14 @@ var mountDAW = (target, options = {}) => {
3675
5283
  }
3676
5284
  playStartStep = 0;
3677
5285
  currentOffsetX = 0;
3678
- setDrawOffset(currentOffsetX, currentOffsetY);
5286
+ const firstPitch = getFirstDetectedPitch();
5287
+ if (firstPitch !== null) {
5288
+ centerPitch(firstPitch);
5289
+ } else {
5290
+ setDrawOffset(currentOffsetX, currentOffsetY);
5291
+ }
3679
5292
  redrawAll();
5293
+ updateTrackPanel();
3680
5294
  updateUndoRedo();
3681
5295
  };
3682
5296
  const applyChord = () => {
@@ -3736,7 +5350,12 @@ var mountDAW = (target, options = {}) => {
3736
5350
  }
3737
5351
  playStartStep = 0;
3738
5352
  currentOffsetX = 0;
3739
- setDrawOffset(currentOffsetX, currentOffsetY);
5353
+ const firstPitch = getFirstDetectedPitch();
5354
+ if (firstPitch !== null) {
5355
+ centerPitch(firstPitch);
5356
+ } else {
5357
+ setDrawOffset(currentOffsetX, currentOffsetY);
5358
+ }
3740
5359
  redrawAll();
3741
5360
  updateUndoRedo();
3742
5361
  };
@@ -4008,6 +5627,9 @@ var mountDAW = (target, options = {}) => {
4008
5627
  pause,
4009
5628
  stop,
4010
5629
  getMML: generateMML,
5630
+ setInstrument: (name) => {
5631
+ currentInstrument = name;
5632
+ },
4011
5633
  loadMML,
4012
5634
  loadMIDI,
4013
5635
  exportMIDI: exportMIDI2,
@@ -4128,6 +5750,388 @@ var INSTRUMENT_PRESETS = {
4128
5750
  }
4129
5751
  };
4130
5752
 
5753
+ // src/mml-player.ts
5754
+ var STEPS_PER_BEAT3 = 48;
5755
+ var STEPS_PER_BAR = 192;
5756
+ var DEFAULT_TRACK_COLORS = ["#00e436", "#29adff", "#ff77a8", "#ffec27"];
5757
+ var activePlayer = null;
5758
+ var formatTime = (seconds) => {
5759
+ const m = Math.floor(seconds / 60);
5760
+ const s = Math.floor(seconds % 60);
5761
+ return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
5762
+ };
5763
+ var freqFromPitch = (pitch) => 440 * 2 ** ((pitch - 69) / 12);
5764
+ var mountMmlPlayer = (target, mml, options = {}) => {
5765
+ injectStyles(target.ownerDocument ?? document);
5766
+ const {
5767
+ placements,
5768
+ bpm: parsedBpm,
5769
+ tokenTracks,
5770
+ lyrics,
5771
+ meta
5772
+ } = parseMML(mml, {
5773
+ collectTokens: true,
5774
+ collectLyrics: true
5775
+ });
5776
+ const lyricTracks = lyrics ?? /* @__PURE__ */ new Map();
5777
+ const bpm = parsedBpm ?? options.defaultBpm ?? 120;
5778
+ const drumPatternDict = options.drumPatterns ?? DRUM_PATTERNS;
5779
+ const drumPattern = meta.drum ? drumPatternDict[meta.drum] ?? null : null;
5780
+ const trackVolume = meta.volume ?? options.volume ?? 100;
5781
+ const colors = options.trackColors ?? DEFAULT_TRACK_COLORS;
5782
+ const useSynth = options.synth ?? !options.onPlayNote;
5783
+ const secondsPerStep = 60 / bpm / STEPS_PER_BEAT3;
5784
+ const trackIndices = [...new Set(placements.map((p) => p.trackIndex))].sort(
5785
+ (a, b) => a - b
5786
+ );
5787
+ const seqTracks = trackIndices.map((index) => {
5788
+ let id = 0;
5789
+ const notes = placements.filter((p) => p.trackIndex === index).map((p) => ({
5790
+ id: id++,
5791
+ startStep: p.startStep,
5792
+ durationSteps: p.durationSteps,
5793
+ pitch: p.pitch,
5794
+ velocity: 100
5795
+ }));
5796
+ return { id: String(index), volume: trackVolume, notes };
5797
+ });
5798
+ const colorOf = (index) => colors[index % colors.length] ?? DEFAULT_TRACK_COLORS[0];
5799
+ let audioCtx = null;
5800
+ const ensureCtx = () => {
5801
+ if (!audioCtx) audioCtx = new AudioContext();
5802
+ return audioCtx;
5803
+ };
5804
+ const synthPlay = (e) => {
5805
+ const ctx = ensureCtx();
5806
+ const osc = ctx.createOscillator();
5807
+ const gain = ctx.createGain();
5808
+ osc.type = "square";
5809
+ osc.frequency.value = freqFromPitch(e.pitch);
5810
+ const t0 = ctx.currentTime + e.when;
5811
+ const peak = Math.max(1e-4, 0.06 * e.volume * 1.5);
5812
+ gain.gain.setValueAtTime(peak, t0);
5813
+ gain.gain.exponentialRampToValueAtTime(1e-3, t0 + e.duration);
5814
+ osc.connect(gain);
5815
+ if (typeof ctx.createStereoPanner === "function" && e.pan) {
5816
+ const panner = ctx.createStereoPanner();
5817
+ panner.pan.value = Math.max(-1, Math.min(1, e.pan));
5818
+ gain.connect(panner);
5819
+ panner.connect(ctx.destination);
5820
+ } else {
5821
+ gain.connect(ctx.destination);
5822
+ }
5823
+ osc.start(t0);
5824
+ osc.stop(t0 + e.duration + 0.02);
5825
+ };
5826
+ const drumSynth = (e) => {
5827
+ const ctx = ensureCtx();
5828
+ const t0 = ctx.currentTime + e.when;
5829
+ const vol = Math.max(1e-4, Math.min(1, e.velocity));
5830
+ const isKick = e.pitch === 35 || e.pitch === 36;
5831
+ const isSnareLike = e.pitch === 38 || e.pitch === 39 || e.pitch === 40;
5832
+ if (isKick) {
5833
+ const osc = ctx.createOscillator();
5834
+ const g2 = ctx.createGain();
5835
+ osc.frequency.setValueAtTime(150, t0);
5836
+ osc.frequency.exponentialRampToValueAtTime(50, t0 + 0.12);
5837
+ g2.gain.setValueAtTime(vol * 0.9, t0);
5838
+ g2.gain.exponentialRampToValueAtTime(1e-3, t0 + 0.18);
5839
+ osc.connect(g2).connect(ctx.destination);
5840
+ osc.start(t0);
5841
+ osc.stop(t0 + 0.2);
5842
+ osc.onended = () => osc.disconnect();
5843
+ return;
5844
+ }
5845
+ const dur = isSnareLike ? 0.18 : 0.05;
5846
+ const length = Math.max(1, Math.floor(ctx.sampleRate * dur));
5847
+ const buffer = ctx.createBuffer(1, length, ctx.sampleRate);
5848
+ const data = buffer.getChannelData(0);
5849
+ for (let i = 0; i < length; i++) data[i] = Math.random() * 2 - 1;
5850
+ const src = ctx.createBufferSource();
5851
+ src.buffer = buffer;
5852
+ const filter = ctx.createBiquadFilter();
5853
+ filter.type = isSnareLike ? "bandpass" : "highpass";
5854
+ filter.frequency.value = isSnareLike ? 2e3 : 8e3;
5855
+ const g = ctx.createGain();
5856
+ g.gain.setValueAtTime(vol * (isSnareLike ? 0.7 : 0.4), t0);
5857
+ g.gain.exponentialRampToValueAtTime(1e-3, t0 + dur);
5858
+ src.connect(filter).connect(g).connect(ctx.destination);
5859
+ src.start(t0);
5860
+ src.stop(t0 + dur);
5861
+ src.onended = () => {
5862
+ src.disconnect();
5863
+ filter.disconnect();
5864
+ g.disconnect();
5865
+ };
5866
+ };
5867
+ let voices = null;
5868
+ const ensureVoices = () => {
5869
+ if (options.singingVoices) return options.singingVoices;
5870
+ if (!voices) {
5871
+ const ctx = ensureCtx();
5872
+ voices = createSingingVoices(ctx, ctx.destination);
5873
+ }
5874
+ return voices;
5875
+ };
5876
+ const voicesAvailable = useSynth || !!options.singingVoices;
5877
+ const peekVoices = () => options.singingVoices ?? voices;
5878
+ const getAudioTime = () => {
5879
+ if (useSynth) return ensureCtx().currentTime;
5880
+ return options.getAudioTime?.() ?? performance.now() / 1e3;
5881
+ };
5882
+ const doc = target.ownerDocument ?? document;
5883
+ const root = doc.createElement("div");
5884
+ root.className = "dtm-daw dtm-player";
5885
+ const head = doc.createElement("div");
5886
+ head.className = "dtm-player-head";
5887
+ const playBtn = doc.createElement("button");
5888
+ playBtn.type = "button";
5889
+ playBtn.className = "dtm-player-play";
5890
+ playBtn.innerHTML = icon("play", 12);
5891
+ playBtn.disabled = trackIndices.length === 0;
5892
+ const tempoEl = doc.createElement("span");
5893
+ tempoEl.className = "dtm-player-tempo";
5894
+ tempoEl.textContent = `\u2669=${bpm}`;
5895
+ const timeEl = doc.createElement("span");
5896
+ timeEl.className = "dtm-player-time";
5897
+ timeEl.textContent = "00:00";
5898
+ const dots = doc.createElement("div");
5899
+ dots.className = "dtm-player-dots";
5900
+ for (const index of trackIndices) {
5901
+ const dot = doc.createElement("span");
5902
+ dot.className = "dtm-player-dot";
5903
+ dot.style.backgroundColor = colorOf(index);
5904
+ dots.appendChild(dot);
5905
+ }
5906
+ head.append(playBtn, tempoEl, timeEl);
5907
+ const addChip = (label) => {
5908
+ const chip = doc.createElement("span");
5909
+ chip.className = "dtm-player-chip";
5910
+ chip.textContent = label;
5911
+ head.appendChild(chip);
5912
+ };
5913
+ if (meta.instrument) addChip(`\u266A ${meta.instrument}`);
5914
+ if (meta.drum) addChip(`\u{1F941} ${meta.drum}${drumPattern ? "" : " (?)"}`);
5915
+ if (meta.volume !== void 0) addChip(`\u{1F50A} ${meta.volume}%`);
5916
+ head.appendChild(dots);
5917
+ root.appendChild(head);
5918
+ const laneViews = [];
5919
+ for (const index of trackIndices) {
5920
+ const lyricTrack = lyricTracks.get(index);
5921
+ const isLyricLane = !!lyricTrack && lyricTrack.syllables.length > 0;
5922
+ const row = doc.createElement("div");
5923
+ row.className = "dtm-player-lane-row";
5924
+ const label = doc.createElement("div");
5925
+ label.className = "dtm-player-lane-label";
5926
+ const swatch = doc.createElement("span");
5927
+ swatch.className = "dtm-player-dot";
5928
+ swatch.style.backgroundColor = colorOf(index);
5929
+ const no = doc.createElement("span");
5930
+ no.className = "dtm-player-lane-no";
5931
+ no.textContent = `@${index}`;
5932
+ label.append(swatch, no);
5933
+ const lane = doc.createElement("div");
5934
+ lane.className = "dtm-player-lane";
5935
+ lane.style.setProperty("--tk", colorOf(index));
5936
+ const laneTokens = [];
5937
+ if (isLyricLane) {
5938
+ const notes = placements.filter((p) => p.trackIndex === index).sort((a, b) => a.startStep - b.startStep);
5939
+ const gateScale = (lyricTrack.gate ?? 100) / 100;
5940
+ const breaks = new Set(lyricTrack.lineBreaks ?? []);
5941
+ if (lyricTrack.metaText) {
5942
+ const metaEl = doc.createElement("span");
5943
+ metaEl.className = "dtm-tk dtm-tk--meta";
5944
+ metaEl.textContent = lyricTrack.metaText;
5945
+ lane.appendChild(metaEl);
5946
+ }
5947
+ const count = Math.min(notes.length, lyricTrack.syllables.length);
5948
+ for (let i = 0; i < count; i++) {
5949
+ const note = notes[i];
5950
+ if (breaks.has(i)) {
5951
+ const br = doc.createElement("span");
5952
+ br.className = "dtm-tk dtm-tk--break";
5953
+ br.textContent = "\\n";
5954
+ lane.appendChild(br);
5955
+ }
5956
+ const span = doc.createElement("span");
5957
+ span.className = "dtm-tk dtm-tk--lyric";
5958
+ span.textContent = lyricTrack.syllables[i].kana;
5959
+ lane.appendChild(span);
5960
+ laneTokens.push({
5961
+ el: span,
5962
+ startStep: note.startStep,
5963
+ durationSteps: Math.max(
5964
+ 1,
5965
+ Math.round(note.durationSteps * gateScale)
5966
+ )
5967
+ });
5968
+ }
5969
+ } else {
5970
+ const tokens = tokenTracks?.get(index) ?? [];
5971
+ for (const tok of tokens) {
5972
+ const span = doc.createElement("span");
5973
+ span.className = `dtm-tk dtm-tk--${tok.type}`;
5974
+ span.textContent = tok.text;
5975
+ lane.appendChild(span);
5976
+ if (tok.durationSteps > 0) {
5977
+ laneTokens.push({
5978
+ el: span,
5979
+ startStep: tok.startStep,
5980
+ durationSteps: tok.durationSteps
5981
+ });
5982
+ }
5983
+ }
5984
+ }
5985
+ row.append(label, lane);
5986
+ root.appendChild(row);
5987
+ laneViews.push({ lane, tokens: laneTokens });
5988
+ }
5989
+ target.appendChild(root);
5990
+ const autoScroll = (lane, el) => {
5991
+ if (el.offsetWidth === 0 || lane.clientWidth === 0) return;
5992
+ const elementCenter = el.offsetLeft + el.offsetWidth / 2;
5993
+ const maxScroll = Math.max(0, lane.scrollWidth - lane.clientWidth);
5994
+ const next = elementCenter - lane.clientWidth / 2;
5995
+ lane.scrollLeft = Math.max(0, Math.min(next, maxScroll));
5996
+ };
5997
+ const renderPlayhead = (step) => {
5998
+ timeEl.textContent = formatTime(Math.max(0, step) * secondsPerStep);
5999
+ for (const view of laneViews) {
6000
+ let active = null;
6001
+ for (const t of view.tokens) {
6002
+ const on = step >= t.startStep && step < t.startStep + t.durationSteps;
6003
+ t.el.classList.toggle("is-active", on);
6004
+ if (on && !active) active = t;
6005
+ }
6006
+ if (active) autoScroll(view.lane, active.el);
6007
+ }
6008
+ };
6009
+ const resetPlayhead = () => {
6010
+ timeEl.textContent = "00:00";
6011
+ for (const view of laneViews) {
6012
+ for (const t of view.tokens) t.el.classList.remove("is-active");
6013
+ view.lane.scrollLeft = 0;
6014
+ }
6015
+ };
6016
+ const seq = createSequencer({
6017
+ getTracks: () => seqTracks,
6018
+ getBpm: () => bpm,
6019
+ getPlayStartStep: () => 0,
6020
+ getDrumPattern: () => drumPattern,
6021
+ getSoloTrackId: () => null,
6022
+ getAudioTime,
6023
+ onPlayNote: (e) => {
6024
+ if (lyricTracks.has(Number(e.trackId))) return;
6025
+ options.onPlayNote?.(e);
6026
+ if (useSynth) synthPlay(e);
6027
+ },
6028
+ onPlayDrum: (e) => {
6029
+ const velocity = e.velocity * (trackVolume / 100);
6030
+ options.onPlayDrum?.({ ...e, velocity });
6031
+ if (useSynth) drumSynth({ ...e, velocity });
6032
+ },
6033
+ onTick: (step) => {
6034
+ renderPlayhead(step);
6035
+ },
6036
+ onEnd: () => finish(),
6037
+ stepsPerBar: STEPS_PER_BAR
6038
+ });
6039
+ let playing = false;
6040
+ const setPlayingUI = (on) => {
6041
+ playing = on;
6042
+ playBtn.innerHTML = icon(on ? "stop" : "play", 12);
6043
+ playBtn.classList.toggle("dtm-player-play--stop", on);
6044
+ };
6045
+ const finish = () => {
6046
+ setPlayingUI(false);
6047
+ resetPlayhead();
6048
+ if (activePlayer === instance) activePlayer = null;
6049
+ };
6050
+ const buildStreamTracks = () => [...lyricTracks.entries()].map(([index, lt]) => {
6051
+ const seqTrack = seqTracks.find((t) => Number(t.id) === index);
6052
+ const sorted = [...seqTrack?.notes ?? []].sort(
6053
+ (a, b) => a.startStep - b.startStep
6054
+ );
6055
+ const gate = (lt.gate ?? 100) / 100;
6056
+ const count = Math.min(sorted.length, lt.syllables.length);
6057
+ const notes = [];
6058
+ for (let i = 0; i < count; i++) {
6059
+ const n = sorted[i];
6060
+ notes.push({
6061
+ syllable: lt.syllables[i],
6062
+ pitch: n.pitch,
6063
+ startSec: n.startStep * secondsPerStep,
6064
+ durationSec: n.durationSteps * secondsPerStep * gate
6065
+ });
6066
+ }
6067
+ return {
6068
+ model: lt.model,
6069
+ volume: vocalVolumeToGain(lt.volume ?? 300) * (trackVolume / 100),
6070
+ pan: panToStereo(lt.pan ?? 64),
6071
+ notes
6072
+ };
6073
+ });
6074
+ const startWhenReady = async () => {
6075
+ const streaming = voicesAvailable && lyricTracks.size > 0;
6076
+ const tracks = streaming ? buildStreamTracks() : [];
6077
+ if (streaming) {
6078
+ const v = ensureVoices();
6079
+ const overlay = showLoadingOverlay(root);
6080
+ try {
6081
+ await v.loadModels(tracks.map((t) => t.model));
6082
+ await v.warm(tracks);
6083
+ } catch (err) {
6084
+ console.warn("[dtm] voice preload failed", err);
6085
+ } finally {
6086
+ overlay.remove();
6087
+ }
6088
+ if (!playing || activePlayer !== instance) return;
6089
+ }
6090
+ seq.start(0);
6091
+ if (streaming) ensureVoices().startStream(tracks, seq.getStartTime());
6092
+ };
6093
+ const play = () => {
6094
+ if (playing || trackIndices.length === 0) return;
6095
+ if (activePlayer && activePlayer !== instance) activePlayer.stop();
6096
+ activePlayer = instance;
6097
+ setPlayingUI(true);
6098
+ void options.onResumeAudio?.();
6099
+ if (useSynth) {
6100
+ const ctx = ensureCtx();
6101
+ if (ctx.state === "suspended") void ctx.resume();
6102
+ }
6103
+ if (voicesAvailable && lyricTracks.size > 0) ensureVoices().reset();
6104
+ void startWhenReady();
6105
+ };
6106
+ const stop = () => {
6107
+ if (!playing) return;
6108
+ seq.stop();
6109
+ peekVoices()?.stopStream();
6110
+ finish();
6111
+ };
6112
+ playBtn.addEventListener("click", () => {
6113
+ if (playing) stop();
6114
+ else play();
6115
+ });
6116
+ const destroy = () => {
6117
+ seq.stop();
6118
+ peekVoices()?.stopStream();
6119
+ if (activePlayer === instance) activePlayer = null;
6120
+ if (audioCtx) {
6121
+ void audioCtx.close();
6122
+ audioCtx = null;
6123
+ }
6124
+ root.remove();
6125
+ };
6126
+ const instance = {
6127
+ play,
6128
+ stop,
6129
+ isPlaying: () => playing,
6130
+ destroy
6131
+ };
6132
+ return instance;
6133
+ };
6134
+
4131
6135
  // src/piano-roll.ts
4132
6136
  var createPianoRoll = (options, handlers) => {
4133
6137
  const {
@@ -4416,9 +6420,14 @@ export {
4416
6420
  DRUM_KEYS,
4417
6421
  DRUM_PATTERNS,
4418
6422
  INSTRUMENT_PRESETS,
6423
+ KOE_BASE_URL,
6424
+ KOE_VOICEBANKS,
6425
+ KOE_VOICEBANK_LABELS,
4419
6426
  LinkedList,
6427
+ MAX_VOCAL_VOLUME,
4420
6428
  MMLCore,
4421
6429
  PITCH_MAP,
6430
+ PREWARM_NOTES,
4422
6431
  TRACKS_ADVANCED,
4423
6432
  TRACKS_SIMPLE,
4424
6433
  analyzeMidiTracks,
@@ -4426,9 +6435,15 @@ export {
4426
6435
  applyMonophonic,
4427
6436
  buildChordPlacements,
4428
6437
  buildNameToKeyMapping,
6438
+ collectPitchTokens,
4429
6439
  createAudioContext,
6440
+ createKlattVoice,
6441
+ createKoeVoice,
6442
+ createLyricsConductor,
4430
6443
  createPianoRoll,
4431
6444
  createSequencer,
6445
+ createSingingVoices,
6446
+ createVoiceRegistry,
4432
6447
  decomposeToMonophonic,
4433
6448
  drawGrid,
4434
6449
  drawHeader,
@@ -4440,6 +6455,7 @@ export {
4440
6455
  extractMidiPlacements,
4441
6456
  extractMidiPlacementsByTrack,
4442
6457
  fetchSoundFontList,
6458
+ formatMmlMeta,
4443
6459
  generateRandomPattern,
4444
6460
  getDrawOffset,
4445
6461
  getGridCanvas,
@@ -4453,10 +6469,19 @@ export {
4453
6469
  init,
4454
6470
  injectStyles,
4455
6471
  isChordHeavyTrack,
6472
+ koeUrl,
4456
6473
  mountDAW,
6474
+ mountMmlPlayer,
6475
+ normalizeLyrics,
4457
6476
  onClick,
6477
+ panToStereo,
6478
+ parseLyrics,
4458
6479
  parseMML,
6480
+ parseMmlMeta,
4459
6481
  setDrawOffset,
4460
6482
  setupRecorder,
4461
- shiftNotes
6483
+ shiftNotes,
6484
+ stripLyrics,
6485
+ stripMmlMeta,
6486
+ vocalVolumeToGain
4462
6487
  };