@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/README.md +37 -37
- package/dist/index.d.mts +456 -3
- package/dist/index.d.ts +456 -3
- package/dist/index.js +2165 -119
- package/dist/index.mjs +2143 -118
- package/dist/voice-worker.js +382 -0
- package/package.json +5 -2
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 *
|
|
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
|
|
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
|
|
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
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
const
|
|
1736
|
-
|
|
1737
|
-
);
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
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 =
|
|
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
|
|
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)
|
|
1893
|
-
|
|
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 >=
|
|
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
|
-
|
|
1953
|
-
|
|
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 {
|
|
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.
|
|
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:
|
|
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:
|
|
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
|
|
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 = `${
|
|
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 =
|
|
4419
|
+
const x = clamp3(clientX - rect.left - thumbW / 2, 0, rect.width - thumbW);
|
|
3047
4420
|
const ratio = x / (rect.width - thumbW);
|
|
3048
|
-
currentOffsetX =
|
|
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 =
|
|
4430
|
+
const y = clamp3(clientY - rect.top - thumbH / 2, 0, rect.height - thumbH);
|
|
3058
4431
|
const ratio = y / (rect.height - thumbH);
|
|
3059
|
-
currentOffsetY =
|
|
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 =
|
|
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 =
|
|
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
|
|
3414
|
-
|
|
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
|
|
5136
|
+
const decomposedFull = monoTracks.map(
|
|
3611
5137
|
(notes, i) => `@${i} ${refCore.getMMLFromNotes(notes, bpm, 100).trim()}`
|
|
3612
|
-
)
|
|
3613
|
-
const
|
|
5138
|
+
);
|
|
5139
|
+
const decomposedMini = monoTracks.map(
|
|
3614
5140
|
(notes, i) => `@${i}${refCore.getMMLFromNotes(notes, bpm, 100).trim().replace(/\s+/g, "")}`
|
|
3615
|
-
)
|
|
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
|
|
5152
|
+
const trackLines = trackStates.map(
|
|
3625
5153
|
(t, i) => `@${i} ${t.core.getMMLFromNotes(clipNotes(t.core.getNotes()), bpm, t.volume).trim()}`
|
|
3626
|
-
)
|
|
3627
|
-
const
|
|
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
|
-
)
|
|
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 {
|
|
3662
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|