@marmooo/midy 0.4.9 → 0.5.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 +28 -1
- package/esm/midy-GM1.d.ts +63 -9
- package/esm/midy-GM1.d.ts.map +1 -1
- package/esm/midy-GM1.js +1094 -94
- package/esm/midy-GM2.d.ts +74 -24
- package/esm/midy-GM2.d.ts.map +1 -1
- package/esm/midy-GM2.js +1298 -234
- package/esm/midy-GMLite.d.ts +63 -8
- package/esm/midy-GMLite.d.ts.map +1 -1
- package/esm/midy-GMLite.js +1099 -92
- package/esm/midy.d.ts +49 -30
- package/esm/midy.d.ts.map +1 -1
- package/esm/midy.js +1310 -248
- package/esm/reverb.d.ts +58 -0
- package/esm/reverb.d.ts.map +1 -0
- package/esm/reverb.js +389 -0
- package/package.json +1 -1
- package/script/midy-GM1.d.ts +63 -9
- package/script/midy-GM1.d.ts.map +1 -1
- package/script/midy-GM1.js +1094 -94
- package/script/midy-GM2.d.ts +74 -24
- package/script/midy-GM2.d.ts.map +1 -1
- package/script/midy-GM2.js +1298 -234
- package/script/midy-GMLite.d.ts +63 -8
- package/script/midy-GMLite.d.ts.map +1 -1
- package/script/midy-GMLite.js +1099 -92
- package/script/midy.d.ts +49 -30
- package/script/midy.d.ts.map +1 -1
- package/script/midy.js +1310 -248
- package/script/reverb.d.ts +58 -0
- package/script/reverb.d.ts.map +1 -0
- package/script/reverb.js +405 -0
package/script/midy-GM1.js
CHANGED
|
@@ -4,6 +4,55 @@ exports.MidyGM1 = void 0;
|
|
|
4
4
|
const midi_file_1 = require("midi-file");
|
|
5
5
|
const soundfont_parser_1 = require("@marmooo/soundfont-parser");
|
|
6
6
|
const ogg_vorbis_1 = require("@wasm-audio-decoders/ogg-vorbis");
|
|
7
|
+
// Cache mode
|
|
8
|
+
// - "none" for full real-time control (dynamic CC, LFO, pitch)
|
|
9
|
+
// - "ads" for real-time playback with higher cache hit rate
|
|
10
|
+
// - "adsr" for real-time playback with accurate release envelope
|
|
11
|
+
// - "note" for efficient playback when note behavior is fixed
|
|
12
|
+
// - "audio" for fully pre-rendered playback (lowest CPU)
|
|
13
|
+
//
|
|
14
|
+
// "none"
|
|
15
|
+
// No caching. Envelope processing is done in real time on every note.
|
|
16
|
+
// Uses Web Audio API nodes directly, so LFO and pitch envelope are
|
|
17
|
+
// fully supported. Higher CPU usage.
|
|
18
|
+
// "ads"
|
|
19
|
+
// Pre-renders the ADS (Attack-Decay-Sustain) phase into an
|
|
20
|
+
// OfflineAudioContext and caches the result. The sustain tail is
|
|
21
|
+
// aligned to the loop boundary as a fixed buffer. Release is
|
|
22
|
+
// handled by fading volumeNode gain to 0 at note-off.
|
|
23
|
+
// LFO effects (modLfoToPitch, modLfoToFilterFc, modLfoToVolume,
|
|
24
|
+
// vibLfoToPitch) are applied in real time after playback starts.
|
|
25
|
+
// "adsr"
|
|
26
|
+
// Pre-renders the full ADSR envelope (Attack-Decay-Sustain-Release)
|
|
27
|
+
// into an OfflineAudioContext. The cache key includes the note
|
|
28
|
+
// duration in ticks (tempo-independent) and the volRelease parameter,
|
|
29
|
+
// so notes with the same duration and release shape share a buffer.
|
|
30
|
+
// LFO effects are applied in real time after playback starts,
|
|
31
|
+
// same as "ads" mode. Higher cache hit rate than "note" mode
|
|
32
|
+
// because LFO variations do not produce separate cache entries.
|
|
33
|
+
// "note"
|
|
34
|
+
// Renders the full noteOn-to-noteOff duration per note in an
|
|
35
|
+
// OfflineAudioContext. All events during the note (volume,
|
|
36
|
+
// expression, pitch bend, LFO, CC#1) are baked into the buffer,
|
|
37
|
+
// so no real-time processing is needed during playback. Greatly
|
|
38
|
+
// reduces CPU load for songs with many simultaneous notes.
|
|
39
|
+
// MIDI file playback only — does not respond to real-time CC changes.
|
|
40
|
+
// "audio"
|
|
41
|
+
// Renders the entire MIDI file into a single AudioBuffer offline.
|
|
42
|
+
// Call render() to complete rendering before calling start().
|
|
43
|
+
// Playback simply streams an AudioBufferSourceNode, so CPU usage
|
|
44
|
+
// is near zero. Seek and tempo changes are handled in real time.
|
|
45
|
+
// A "rendering" event is dispatched when rendering starts, and a
|
|
46
|
+
// "rendered" event is dispatched when rendering completes.
|
|
47
|
+
/** @type {"none"|"ads"|"adsr"|"note"|"audio"} */
|
|
48
|
+
const DEFAULT_CACHE_MODE = "ads";
|
|
49
|
+
const _f64Buf = new ArrayBuffer(8);
|
|
50
|
+
const _f64Array = new Float64Array(_f64Buf);
|
|
51
|
+
const _u64Array = new BigUint64Array(_f64Buf);
|
|
52
|
+
function f64ToBigInt(value) {
|
|
53
|
+
_f64Array[0] = value;
|
|
54
|
+
return _u64Array[0];
|
|
55
|
+
}
|
|
7
56
|
let decoderPromise = null;
|
|
8
57
|
let decoderQueue = Promise.resolve();
|
|
9
58
|
function initDecoder() {
|
|
@@ -51,6 +100,24 @@ class Note {
|
|
|
51
100
|
writable: true,
|
|
52
101
|
value: void 0
|
|
53
102
|
});
|
|
103
|
+
Object.defineProperty(this, "timelineIndex", {
|
|
104
|
+
enumerable: true,
|
|
105
|
+
configurable: true,
|
|
106
|
+
writable: true,
|
|
107
|
+
value: null
|
|
108
|
+
});
|
|
109
|
+
Object.defineProperty(this, "renderedBuffer", {
|
|
110
|
+
enumerable: true,
|
|
111
|
+
configurable: true,
|
|
112
|
+
writable: true,
|
|
113
|
+
value: null
|
|
114
|
+
});
|
|
115
|
+
Object.defineProperty(this, "fullCacheVoiceId", {
|
|
116
|
+
enumerable: true,
|
|
117
|
+
configurable: true,
|
|
118
|
+
writable: true,
|
|
119
|
+
value: null
|
|
120
|
+
});
|
|
54
121
|
Object.defineProperty(this, "filterEnvelopeNode", {
|
|
55
122
|
enumerable: true,
|
|
56
123
|
configurable: true,
|
|
@@ -96,7 +163,13 @@ class Note {
|
|
|
96
163
|
}
|
|
97
164
|
}
|
|
98
165
|
class Channel {
|
|
99
|
-
constructor(audioNodes, settings) {
|
|
166
|
+
constructor(channelNumber, audioNodes, settings) {
|
|
167
|
+
Object.defineProperty(this, "channelNumber", {
|
|
168
|
+
enumerable: true,
|
|
169
|
+
configurable: true,
|
|
170
|
+
writable: true,
|
|
171
|
+
value: 0
|
|
172
|
+
});
|
|
100
173
|
Object.defineProperty(this, "isDrum", {
|
|
101
174
|
enumerable: true,
|
|
102
175
|
configurable: true,
|
|
@@ -181,6 +254,7 @@ class Channel {
|
|
|
181
254
|
writable: true,
|
|
182
255
|
value: null
|
|
183
256
|
});
|
|
257
|
+
this.channelNumber = channelNumber;
|
|
184
258
|
Object.assign(this, audioNodes);
|
|
185
259
|
Object.assign(this, settings);
|
|
186
260
|
this.state = new ControllerState();
|
|
@@ -260,13 +334,73 @@ const pitchEnvelopeKeys = [
|
|
|
260
334
|
"playbackRate",
|
|
261
335
|
];
|
|
262
336
|
const pitchEnvelopeKeySet = new Set(pitchEnvelopeKeys);
|
|
337
|
+
class RenderedBuffer {
|
|
338
|
+
constructor(buffer, meta = {}) {
|
|
339
|
+
Object.defineProperty(this, "buffer", {
|
|
340
|
+
enumerable: true,
|
|
341
|
+
configurable: true,
|
|
342
|
+
writable: true,
|
|
343
|
+
value: void 0
|
|
344
|
+
});
|
|
345
|
+
Object.defineProperty(this, "isLoop", {
|
|
346
|
+
enumerable: true,
|
|
347
|
+
configurable: true,
|
|
348
|
+
writable: true,
|
|
349
|
+
value: void 0
|
|
350
|
+
});
|
|
351
|
+
Object.defineProperty(this, "isFull", {
|
|
352
|
+
enumerable: true,
|
|
353
|
+
configurable: true,
|
|
354
|
+
writable: true,
|
|
355
|
+
value: void 0
|
|
356
|
+
});
|
|
357
|
+
Object.defineProperty(this, "adsDuration", {
|
|
358
|
+
enumerable: true,
|
|
359
|
+
configurable: true,
|
|
360
|
+
writable: true,
|
|
361
|
+
value: void 0
|
|
362
|
+
});
|
|
363
|
+
Object.defineProperty(this, "loopStart", {
|
|
364
|
+
enumerable: true,
|
|
365
|
+
configurable: true,
|
|
366
|
+
writable: true,
|
|
367
|
+
value: void 0
|
|
368
|
+
});
|
|
369
|
+
Object.defineProperty(this, "loopDuration", {
|
|
370
|
+
enumerable: true,
|
|
371
|
+
configurable: true,
|
|
372
|
+
writable: true,
|
|
373
|
+
value: void 0
|
|
374
|
+
});
|
|
375
|
+
Object.defineProperty(this, "noteDuration", {
|
|
376
|
+
enumerable: true,
|
|
377
|
+
configurable: true,
|
|
378
|
+
writable: true,
|
|
379
|
+
value: void 0
|
|
380
|
+
});
|
|
381
|
+
Object.defineProperty(this, "releaseDuration", {
|
|
382
|
+
enumerable: true,
|
|
383
|
+
configurable: true,
|
|
384
|
+
writable: true,
|
|
385
|
+
value: void 0
|
|
386
|
+
});
|
|
387
|
+
this.buffer = buffer;
|
|
388
|
+
this.isLoop = meta.isLoop ?? false;
|
|
389
|
+
this.isFull = meta.isFull ?? false;
|
|
390
|
+
this.adsDuration = meta.adsDuration;
|
|
391
|
+
this.loopStart = meta.loopStart;
|
|
392
|
+
this.loopDuration = meta.loopDuration;
|
|
393
|
+
this.noteDuration = meta.noteDuration;
|
|
394
|
+
this.releaseDuration = meta.releaseDuration;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
263
397
|
function cbToRatio(cb) {
|
|
264
398
|
return Math.pow(10, cb / 200);
|
|
265
399
|
}
|
|
266
400
|
const decayCurve = 1 / (-Math.log(cbToRatio(-1000)));
|
|
267
401
|
const releaseCurve = 1 / (-Math.log(cbToRatio(-600)));
|
|
268
402
|
class MidyGM1 extends EventTarget {
|
|
269
|
-
constructor(audioContext) {
|
|
403
|
+
constructor(audioContext, options = {}) {
|
|
270
404
|
super();
|
|
271
405
|
// https://pmc.ncbi.nlm.nih.gov/articles/PMC4191557/
|
|
272
406
|
// https://pubmed.ncbi.nlm.nih.gov/12488797/
|
|
@@ -368,12 +502,6 @@ class MidyGM1 extends EventTarget {
|
|
|
368
502
|
writable: true,
|
|
369
503
|
value: "wasm-audio-decoders"
|
|
370
504
|
});
|
|
371
|
-
Object.defineProperty(this, "decoderQueue", {
|
|
372
|
-
enumerable: true,
|
|
373
|
-
configurable: true,
|
|
374
|
-
writable: true,
|
|
375
|
-
value: Promise.resolve()
|
|
376
|
-
});
|
|
377
505
|
Object.defineProperty(this, "isPlaying", {
|
|
378
506
|
enumerable: true,
|
|
379
507
|
configurable: true,
|
|
@@ -408,9 +536,7 @@ class MidyGM1 extends EventTarget {
|
|
|
408
536
|
enumerable: true,
|
|
409
537
|
configurable: true,
|
|
410
538
|
writable: true,
|
|
411
|
-
value: new Set([
|
|
412
|
-
"noteOff",
|
|
413
|
-
])
|
|
539
|
+
value: new Set(["noteOff"])
|
|
414
540
|
});
|
|
415
541
|
Object.defineProperty(this, "tempo", {
|
|
416
542
|
enumerable: true,
|
|
@@ -454,7 +580,53 @@ class MidyGM1 extends EventTarget {
|
|
|
454
580
|
writable: true,
|
|
455
581
|
value: new Array(128)
|
|
456
582
|
});
|
|
583
|
+
// "adsr" mode
|
|
584
|
+
Object.defineProperty(this, "adsrVoiceCache", {
|
|
585
|
+
enumerable: true,
|
|
586
|
+
configurable: true,
|
|
587
|
+
writable: true,
|
|
588
|
+
value: new Map()
|
|
589
|
+
});
|
|
590
|
+
// "note" mode
|
|
591
|
+
Object.defineProperty(this, "noteOnDurations", {
|
|
592
|
+
enumerable: true,
|
|
593
|
+
configurable: true,
|
|
594
|
+
writable: true,
|
|
595
|
+
value: new Map()
|
|
596
|
+
});
|
|
597
|
+
Object.defineProperty(this, "noteOnEvents", {
|
|
598
|
+
enumerable: true,
|
|
599
|
+
configurable: true,
|
|
600
|
+
writable: true,
|
|
601
|
+
value: new Map()
|
|
602
|
+
});
|
|
603
|
+
Object.defineProperty(this, "fullVoiceCache", {
|
|
604
|
+
enumerable: true,
|
|
605
|
+
configurable: true,
|
|
606
|
+
writable: true,
|
|
607
|
+
value: new Map()
|
|
608
|
+
});
|
|
609
|
+
// "audio" mode
|
|
610
|
+
Object.defineProperty(this, "renderedAudioBuffer", {
|
|
611
|
+
enumerable: true,
|
|
612
|
+
configurable: true,
|
|
613
|
+
writable: true,
|
|
614
|
+
value: null
|
|
615
|
+
});
|
|
616
|
+
Object.defineProperty(this, "isRendering", {
|
|
617
|
+
enumerable: true,
|
|
618
|
+
configurable: true,
|
|
619
|
+
writable: true,
|
|
620
|
+
value: false
|
|
621
|
+
});
|
|
622
|
+
Object.defineProperty(this, "audioModeBufferSource", {
|
|
623
|
+
enumerable: true,
|
|
624
|
+
configurable: true,
|
|
625
|
+
writable: true,
|
|
626
|
+
value: null
|
|
627
|
+
});
|
|
457
628
|
this.audioContext = audioContext;
|
|
629
|
+
this.cacheMode = options.cacheMode ?? DEFAULT_CACHE_MODE;
|
|
458
630
|
this.masterVolume = new GainNode(audioContext);
|
|
459
631
|
this.scheduler = new GainNode(audioContext, { gain: 0 });
|
|
460
632
|
this.schedulerBuffer = new AudioBuffer({
|
|
@@ -524,9 +696,157 @@ class MidyGM1 extends EventTarget {
|
|
|
524
696
|
this.instruments = midiData.instruments;
|
|
525
697
|
this.timeline = midiData.timeline;
|
|
526
698
|
this.totalTime = this.calcTotalTime();
|
|
699
|
+
if (this.cacheMode === "audio") {
|
|
700
|
+
await this.render();
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
buildNoteOnDurations() {
|
|
704
|
+
const { timeline, totalTime, noteOnDurations, noteOnEvents, numChannels } = this;
|
|
705
|
+
noteOnDurations.clear();
|
|
706
|
+
noteOnEvents.clear();
|
|
707
|
+
const inverseTempo = 1 / this.tempo;
|
|
708
|
+
const sustainPedal = new Uint8Array(numChannels);
|
|
709
|
+
const activeNotes = new Map();
|
|
710
|
+
const pendingOff = new Map();
|
|
711
|
+
const finalizeEntry = (entry, endTime, endTicks) => {
|
|
712
|
+
const duration = Math.max(0, endTime - entry.startTime);
|
|
713
|
+
const durationTicks = (endTicks == null || endTicks === Infinity)
|
|
714
|
+
? Infinity
|
|
715
|
+
: Math.max(0, endTicks - entry.startTicks);
|
|
716
|
+
noteOnDurations.set(entry.idx, duration);
|
|
717
|
+
noteOnEvents.set(entry.idx, {
|
|
718
|
+
duration,
|
|
719
|
+
durationTicks,
|
|
720
|
+
startTime: entry.startTime,
|
|
721
|
+
events: entry.events,
|
|
722
|
+
});
|
|
723
|
+
};
|
|
724
|
+
for (let i = 0; i < timeline.length; i++) {
|
|
725
|
+
const event = timeline[i];
|
|
726
|
+
const t = event.startTime * inverseTempo;
|
|
727
|
+
switch (event.type) {
|
|
728
|
+
case "noteOn": {
|
|
729
|
+
const key = event.noteNumber * numChannels + event.channel;
|
|
730
|
+
if (!activeNotes.has(key))
|
|
731
|
+
activeNotes.set(key, []);
|
|
732
|
+
activeNotes.get(key).push({
|
|
733
|
+
idx: i,
|
|
734
|
+
startTime: t,
|
|
735
|
+
startTicks: event.ticks,
|
|
736
|
+
events: [],
|
|
737
|
+
});
|
|
738
|
+
const pendingStack = pendingOff.get(key);
|
|
739
|
+
if (pendingStack && pendingStack.length > 0)
|
|
740
|
+
pendingStack.shift();
|
|
741
|
+
break;
|
|
742
|
+
}
|
|
743
|
+
case "noteOff": {
|
|
744
|
+
const ch = event.channel;
|
|
745
|
+
const key = event.noteNumber * numChannels + ch;
|
|
746
|
+
if (sustainPedal[ch]) {
|
|
747
|
+
if (!pendingOff.has(key))
|
|
748
|
+
pendingOff.set(key, []);
|
|
749
|
+
pendingOff.get(key).push({ t, ticks: event.ticks });
|
|
750
|
+
}
|
|
751
|
+
else {
|
|
752
|
+
const stack = activeNotes.get(key);
|
|
753
|
+
if (stack && stack.length > 0) {
|
|
754
|
+
finalizeEntry(stack.shift(), t, event.ticks);
|
|
755
|
+
if (stack.length === 0)
|
|
756
|
+
activeNotes.delete(key);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
break;
|
|
760
|
+
}
|
|
761
|
+
case "controller": {
|
|
762
|
+
const ch = event.channel;
|
|
763
|
+
for (const [key, entries] of activeNotes) {
|
|
764
|
+
if (key % numChannels !== ch)
|
|
765
|
+
continue;
|
|
766
|
+
for (const entry of entries)
|
|
767
|
+
entry.events.push(event);
|
|
768
|
+
}
|
|
769
|
+
switch (event.controllerType) {
|
|
770
|
+
case 64: { // Sustain Pedal
|
|
771
|
+
const on = event.value >= 64;
|
|
772
|
+
sustainPedal[ch] = on ? 1 : 0;
|
|
773
|
+
if (!on) {
|
|
774
|
+
for (const [key, offItems] of pendingOff) {
|
|
775
|
+
if (key % numChannels !== ch)
|
|
776
|
+
continue;
|
|
777
|
+
const activeStack = activeNotes.get(key);
|
|
778
|
+
for (const { t: offTime, ticks: offTicks } of offItems) {
|
|
779
|
+
if (activeStack && activeStack.length > 0) {
|
|
780
|
+
finalizeEntry(activeStack.shift(), offTime, offTicks);
|
|
781
|
+
if (activeStack.length === 0)
|
|
782
|
+
activeNotes.delete(key);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
pendingOff.delete(key);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
break;
|
|
789
|
+
}
|
|
790
|
+
case 121: // Reset All Controllers
|
|
791
|
+
sustainPedal[ch] = 0;
|
|
792
|
+
break;
|
|
793
|
+
case 120: // All Sound Off
|
|
794
|
+
case 123: { // All Notes Off
|
|
795
|
+
for (const [key, stack] of activeNotes) {
|
|
796
|
+
if (key % numChannels !== ch)
|
|
797
|
+
continue;
|
|
798
|
+
for (const entry of stack)
|
|
799
|
+
finalizeEntry(entry, t, event.ticks);
|
|
800
|
+
activeNotes.delete(key);
|
|
801
|
+
}
|
|
802
|
+
for (const key of pendingOff.keys()) {
|
|
803
|
+
if (key % numChannels === ch)
|
|
804
|
+
pendingOff.delete(key);
|
|
805
|
+
}
|
|
806
|
+
break;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
break;
|
|
810
|
+
}
|
|
811
|
+
case "sysEx":
|
|
812
|
+
if (event.data[0] === 126 && event.data[1] === 9 && event.data[2] === 3) {
|
|
813
|
+
// GM1 System On
|
|
814
|
+
if (event.data[3] === 1) {
|
|
815
|
+
sustainPedal.fill(0);
|
|
816
|
+
pendingOff.clear();
|
|
817
|
+
for (const [, stack] of activeNotes) {
|
|
818
|
+
for (const entry of stack)
|
|
819
|
+
finalizeEntry(entry, t, event.ticks);
|
|
820
|
+
}
|
|
821
|
+
activeNotes.clear();
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
else {
|
|
825
|
+
for (const [, entries] of activeNotes) {
|
|
826
|
+
for (const entry of entries)
|
|
827
|
+
entry.events.push(event);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
break;
|
|
831
|
+
case "pitchBend":
|
|
832
|
+
case "programChange": {
|
|
833
|
+
const ch = event.channel;
|
|
834
|
+
for (const [key, entries] of activeNotes) {
|
|
835
|
+
if (key % numChannels !== ch)
|
|
836
|
+
continue;
|
|
837
|
+
for (const entry of entries)
|
|
838
|
+
entry.events.push(event);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
for (const [, stack] of activeNotes) {
|
|
844
|
+
for (const entry of stack)
|
|
845
|
+
finalizeEntry(entry, totalTime, Infinity);
|
|
846
|
+
}
|
|
527
847
|
}
|
|
528
848
|
cacheVoiceIds() {
|
|
529
|
-
const { channels, timeline, voiceCounter } = this;
|
|
849
|
+
const { channels, timeline, voiceCounter, cacheMode } = this;
|
|
530
850
|
for (let i = 0; i < timeline.length; i++) {
|
|
531
851
|
const event = timeline[i];
|
|
532
852
|
switch (event.type) {
|
|
@@ -544,6 +864,9 @@ class MidyGM1 extends EventTarget {
|
|
|
544
864
|
voiceCounter.delete(audioBufferId);
|
|
545
865
|
}
|
|
546
866
|
this.GM1SystemOn();
|
|
867
|
+
if (cacheMode === "adsr" || cacheMode === "note" || cacheMode === "audio") {
|
|
868
|
+
this.buildNoteOnDurations();
|
|
869
|
+
}
|
|
547
870
|
}
|
|
548
871
|
getVoiceId(channel, noteNumber, velocity) {
|
|
549
872
|
const programNumber = channel.programNumber;
|
|
@@ -561,8 +884,11 @@ class MidyGM1 extends EventTarget {
|
|
|
561
884
|
return;
|
|
562
885
|
const soundFont = this.soundFonts[soundFontIndex];
|
|
563
886
|
const voice = soundFont.getVoice(bank, programNumber, noteNumber, velocity);
|
|
887
|
+
if (!voice)
|
|
888
|
+
return;
|
|
564
889
|
const { instrument, sampleID } = voice.generators;
|
|
565
|
-
return soundFontIndex * (2 **
|
|
890
|
+
return soundFontIndex * (2 ** 31) + instrument * (2 ** 24) +
|
|
891
|
+
(sampleID << 8);
|
|
566
892
|
}
|
|
567
893
|
createChannelAudioNodes(audioContext) {
|
|
568
894
|
const { gainLeft, gainRight } = this.panToGain(defaultControllerState.panMSB.defaultValue);
|
|
@@ -572,15 +898,11 @@ class MidyGM1 extends EventTarget {
|
|
|
572
898
|
gainL.connect(merger, 0, 0);
|
|
573
899
|
gainR.connect(merger, 0, 1);
|
|
574
900
|
merger.connect(this.masterVolume);
|
|
575
|
-
return {
|
|
576
|
-
gainL,
|
|
577
|
-
gainR,
|
|
578
|
-
merger,
|
|
579
|
-
};
|
|
901
|
+
return { gainL, gainR, merger };
|
|
580
902
|
}
|
|
581
903
|
createChannels(audioContext) {
|
|
582
904
|
const settings = this.constructor.channelSettings;
|
|
583
|
-
return Array.from({ length: this.numChannels }, () => new Channel(this.createChannelAudioNodes(audioContext), settings));
|
|
905
|
+
return Array.from({ length: this.numChannels }, (_, ch) => new Channel(ch, this.createChannelAudioNodes(audioContext), settings));
|
|
584
906
|
}
|
|
585
907
|
decodeOggVorbis(sample) {
|
|
586
908
|
const task = decoderQueue.then(async () => {
|
|
@@ -634,13 +956,25 @@ class MidyGM1 extends EventTarget {
|
|
|
634
956
|
return audioBuffer;
|
|
635
957
|
}
|
|
636
958
|
}
|
|
637
|
-
createBufferSource(voiceParams,
|
|
959
|
+
createBufferSource(voiceParams, renderedOrRaw) {
|
|
960
|
+
const isRendered = renderedOrRaw instanceof RenderedBuffer;
|
|
961
|
+
const audioBuffer = isRendered ? renderedOrRaw.buffer : renderedOrRaw;
|
|
638
962
|
const bufferSource = new AudioBufferSourceNode(this.audioContext);
|
|
639
963
|
bufferSource.buffer = audioBuffer;
|
|
640
|
-
|
|
964
|
+
const isLoop = isRendered
|
|
965
|
+
? renderedOrRaw.isLoop
|
|
966
|
+
: voiceParams.sampleModes % 2 !== 0;
|
|
967
|
+
bufferSource.loop = isLoop;
|
|
641
968
|
if (bufferSource.loop) {
|
|
642
|
-
|
|
643
|
-
|
|
969
|
+
if (isRendered && renderedOrRaw.adsDuration != null) {
|
|
970
|
+
bufferSource.loopStart = renderedOrRaw.loopStart;
|
|
971
|
+
bufferSource.loopEnd = renderedOrRaw.loopStart +
|
|
972
|
+
renderedOrRaw.loopDuration;
|
|
973
|
+
}
|
|
974
|
+
else {
|
|
975
|
+
bufferSource.loopStart = voiceParams.loopStart / voiceParams.sampleRate;
|
|
976
|
+
bufferSource.loopEnd = voiceParams.loopEnd / voiceParams.sampleRate;
|
|
977
|
+
}
|
|
644
978
|
}
|
|
645
979
|
return bufferSource;
|
|
646
980
|
}
|
|
@@ -657,13 +991,15 @@ class MidyGM1 extends EventTarget {
|
|
|
657
991
|
break;
|
|
658
992
|
const startTime = t + schedulingOffset;
|
|
659
993
|
switch (event.type) {
|
|
660
|
-
case "noteOn":
|
|
661
|
-
this.
|
|
994
|
+
case "noteOn": {
|
|
995
|
+
const note = this.createNote(event.channel, event.noteNumber, event.velocity, startTime);
|
|
996
|
+
note.timelineIndex = queueIndex;
|
|
997
|
+
this.setupNote(event.channel, note, startTime);
|
|
662
998
|
break;
|
|
663
|
-
|
|
999
|
+
}
|
|
1000
|
+
case "noteOff":
|
|
664
1001
|
this.noteOff(event.channel, event.noteNumber, event.velocity, startTime, false);
|
|
665
1002
|
break;
|
|
666
|
-
}
|
|
667
1003
|
case "controller":
|
|
668
1004
|
this.setControlChange(event.channel, event.controllerType, event.value, startTime);
|
|
669
1005
|
break;
|
|
@@ -694,6 +1030,7 @@ class MidyGM1 extends EventTarget {
|
|
|
694
1030
|
this.exclusiveClassNotes.fill(undefined);
|
|
695
1031
|
this.voiceCache.clear();
|
|
696
1032
|
this.realtimeVoiceCache.clear();
|
|
1033
|
+
this.adsrVoiceCache.clear();
|
|
697
1034
|
const channels = this.channels;
|
|
698
1035
|
for (let ch = 0; ch < channels.length; ch++) {
|
|
699
1036
|
channels[ch].scheduledNotes = [];
|
|
@@ -723,11 +1060,95 @@ class MidyGM1 extends EventTarget {
|
|
|
723
1060
|
}
|
|
724
1061
|
}
|
|
725
1062
|
}
|
|
1063
|
+
async playAudioBuffer() {
|
|
1064
|
+
const audioContext = this.audioContext;
|
|
1065
|
+
const paused = this.isPaused;
|
|
1066
|
+
this.isPlaying = true;
|
|
1067
|
+
this.isPaused = false;
|
|
1068
|
+
this.startTime = audioContext.currentTime;
|
|
1069
|
+
if (paused) {
|
|
1070
|
+
this.dispatchEvent(new Event("resumed"));
|
|
1071
|
+
}
|
|
1072
|
+
else {
|
|
1073
|
+
this.dispatchEvent(new Event("started"));
|
|
1074
|
+
}
|
|
1075
|
+
let exitReason;
|
|
1076
|
+
outer: while (true) {
|
|
1077
|
+
const buffer = this.renderedAudioBuffer;
|
|
1078
|
+
const bufferSource = new AudioBufferSourceNode(audioContext, { buffer });
|
|
1079
|
+
bufferSource.playbackRate.value = this.tempo;
|
|
1080
|
+
bufferSource.connect(this.masterVolume);
|
|
1081
|
+
const offset = Math.min(Math.max(this.resumeTime, 0), buffer.duration);
|
|
1082
|
+
bufferSource.start(audioContext.currentTime, offset);
|
|
1083
|
+
this.audioModeBufferSource = bufferSource;
|
|
1084
|
+
let naturalEnded = false;
|
|
1085
|
+
bufferSource.onended = () => {
|
|
1086
|
+
naturalEnded = true;
|
|
1087
|
+
};
|
|
1088
|
+
while (true) {
|
|
1089
|
+
const now = audioContext.currentTime;
|
|
1090
|
+
await this.scheduleTask(() => { }, now + this.noteCheckInterval);
|
|
1091
|
+
if (naturalEnded || this.currentTime() >= this.totalTime) {
|
|
1092
|
+
bufferSource.disconnect();
|
|
1093
|
+
this.audioModeBufferSource = null;
|
|
1094
|
+
if (this.loop) {
|
|
1095
|
+
this.resumeTime = 0;
|
|
1096
|
+
this.startTime = audioContext.currentTime;
|
|
1097
|
+
this.dispatchEvent(new Event("looped"));
|
|
1098
|
+
continue outer;
|
|
1099
|
+
}
|
|
1100
|
+
await audioContext.suspend();
|
|
1101
|
+
exitReason = "ended";
|
|
1102
|
+
break outer;
|
|
1103
|
+
}
|
|
1104
|
+
if (this.isPausing) {
|
|
1105
|
+
this.resumeTime = this.currentTime();
|
|
1106
|
+
bufferSource.stop();
|
|
1107
|
+
bufferSource.disconnect();
|
|
1108
|
+
this.audioModeBufferSource = null;
|
|
1109
|
+
await audioContext.suspend();
|
|
1110
|
+
this.isPausing = false;
|
|
1111
|
+
exitReason = "paused";
|
|
1112
|
+
break outer;
|
|
1113
|
+
}
|
|
1114
|
+
else if (this.isStopping) {
|
|
1115
|
+
bufferSource.stop();
|
|
1116
|
+
bufferSource.disconnect();
|
|
1117
|
+
this.audioModeBufferSource = null;
|
|
1118
|
+
await audioContext.suspend();
|
|
1119
|
+
this.isStopping = false;
|
|
1120
|
+
exitReason = "stopped";
|
|
1121
|
+
break outer;
|
|
1122
|
+
}
|
|
1123
|
+
else if (this.isSeeking) {
|
|
1124
|
+
bufferSource.stop();
|
|
1125
|
+
bufferSource.disconnect();
|
|
1126
|
+
this.audioModeBufferSource = null;
|
|
1127
|
+
this.startTime = audioContext.currentTime;
|
|
1128
|
+
this.isSeeking = false;
|
|
1129
|
+
this.dispatchEvent(new Event("seeked"));
|
|
1130
|
+
continue outer;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
this.isPlaying = false;
|
|
1135
|
+
if (exitReason === "paused") {
|
|
1136
|
+
this.isPaused = true;
|
|
1137
|
+
this.dispatchEvent(new Event("paused"));
|
|
1138
|
+
}
|
|
1139
|
+
else if (exitReason !== undefined) {
|
|
1140
|
+
this.isPaused = false;
|
|
1141
|
+
this.dispatchEvent(new Event(exitReason));
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
726
1144
|
async playNotes() {
|
|
727
1145
|
const audioContext = this.audioContext;
|
|
728
1146
|
if (audioContext.state === "suspended") {
|
|
729
1147
|
await audioContext.resume();
|
|
730
1148
|
}
|
|
1149
|
+
if (this.cacheMode === "audio" && this.renderedAudioBuffer) {
|
|
1150
|
+
return await this.playAudioBuffer();
|
|
1151
|
+
}
|
|
731
1152
|
const paused = this.isPaused;
|
|
732
1153
|
this.isPlaying = true;
|
|
733
1154
|
this.isPaused = false;
|
|
@@ -894,6 +1315,137 @@ class MidyGM1 extends EventTarget {
|
|
|
894
1315
|
this.notePromises = [];
|
|
895
1316
|
return stopPromise;
|
|
896
1317
|
}
|
|
1318
|
+
async render() {
|
|
1319
|
+
if (this.isRendering)
|
|
1320
|
+
return;
|
|
1321
|
+
if (this.timeline.length === 0)
|
|
1322
|
+
return;
|
|
1323
|
+
if (this.voiceCounter.size === 0)
|
|
1324
|
+
this.cacheVoiceIds();
|
|
1325
|
+
this.isRendering = true;
|
|
1326
|
+
this.renderedAudioBuffer = null;
|
|
1327
|
+
this.dispatchEvent(new Event("rendering"));
|
|
1328
|
+
const sampleRate = this.audioContext.sampleRate;
|
|
1329
|
+
const totalSamples = Math.ceil((this.totalTime + this.startDelay) * sampleRate);
|
|
1330
|
+
const renderProgramNumber = new Uint8Array(this.numChannels);
|
|
1331
|
+
const renderIsDrum = new Uint8Array(this.numChannels);
|
|
1332
|
+
renderIsDrum[9] = 1;
|
|
1333
|
+
const renderControllerStates = Array.from({ length: this.numChannels }, () => {
|
|
1334
|
+
const state = new Float32Array(256);
|
|
1335
|
+
for (const { type, defaultValue } of Object.values(defaultControllerState)) {
|
|
1336
|
+
state[type] = defaultValue;
|
|
1337
|
+
}
|
|
1338
|
+
return state;
|
|
1339
|
+
});
|
|
1340
|
+
const tasks = [];
|
|
1341
|
+
const timeline = this.timeline;
|
|
1342
|
+
const inverseTempo = 1 / this.tempo;
|
|
1343
|
+
for (let i = 0; i < timeline.length; i++) {
|
|
1344
|
+
const event = timeline[i];
|
|
1345
|
+
const ch = event.channel;
|
|
1346
|
+
switch (event.type) {
|
|
1347
|
+
case "noteOn": {
|
|
1348
|
+
const noteEvent = this.noteOnEvents.get(i);
|
|
1349
|
+
const noteDuration = noteEvent?.duration ??
|
|
1350
|
+
this.noteOnDurations.get(i) ??
|
|
1351
|
+
0;
|
|
1352
|
+
if (noteDuration <= 0)
|
|
1353
|
+
continue;
|
|
1354
|
+
const { noteNumber, velocity } = event;
|
|
1355
|
+
const isDrum = renderIsDrum[ch] === 1;
|
|
1356
|
+
const programNumber = renderProgramNumber[ch];
|
|
1357
|
+
const bankTable = this.soundFontTable[programNumber];
|
|
1358
|
+
if (!bankTable)
|
|
1359
|
+
continue;
|
|
1360
|
+
let bank = isDrum ? 128 : 0;
|
|
1361
|
+
if (bankTable[bank] === undefined) {
|
|
1362
|
+
if (isDrum)
|
|
1363
|
+
continue;
|
|
1364
|
+
bank = 0;
|
|
1365
|
+
}
|
|
1366
|
+
const soundFontIndex = bankTable[bank];
|
|
1367
|
+
if (soundFontIndex === undefined)
|
|
1368
|
+
continue;
|
|
1369
|
+
const soundFont = this.soundFonts[soundFontIndex];
|
|
1370
|
+
const fakeChannel = {
|
|
1371
|
+
channelNumber: ch,
|
|
1372
|
+
state: { array: renderControllerStates[ch].slice() },
|
|
1373
|
+
programNumber,
|
|
1374
|
+
isDrum,
|
|
1375
|
+
modulationDepthRange: 50,
|
|
1376
|
+
detune: 0,
|
|
1377
|
+
};
|
|
1378
|
+
const controllerState = this.getControllerState(fakeChannel, noteNumber, velocity);
|
|
1379
|
+
const voice = soundFont.getVoice(bank, programNumber, noteNumber, velocity);
|
|
1380
|
+
if (!voice)
|
|
1381
|
+
continue;
|
|
1382
|
+
const voiceParams = voice.getAllParams(controllerState);
|
|
1383
|
+
const t = event.startTime * inverseTempo + this.startDelay;
|
|
1384
|
+
const fakeNote = { voiceParams, channel: ch, noteNumber, velocity };
|
|
1385
|
+
const promise = (async () => {
|
|
1386
|
+
try {
|
|
1387
|
+
return await this.createFullRenderedBuffer(fakeChannel, fakeNote, voiceParams, noteDuration, noteEvent);
|
|
1388
|
+
}
|
|
1389
|
+
catch (err) {
|
|
1390
|
+
console.warn("render: note render failed", err);
|
|
1391
|
+
return null;
|
|
1392
|
+
}
|
|
1393
|
+
})();
|
|
1394
|
+
tasks.push({ t, promise, fakeChannel });
|
|
1395
|
+
break;
|
|
1396
|
+
}
|
|
1397
|
+
case "controller": {
|
|
1398
|
+
const { controllerType, value } = event;
|
|
1399
|
+
const stateIndex = 128 + controllerType;
|
|
1400
|
+
if (stateIndex < 256) {
|
|
1401
|
+
renderControllerStates[ch][stateIndex] = value / 127;
|
|
1402
|
+
}
|
|
1403
|
+
break;
|
|
1404
|
+
}
|
|
1405
|
+
case "pitchBend":
|
|
1406
|
+
renderControllerStates[ch][14] = (event.value + 8192) / 16383;
|
|
1407
|
+
break;
|
|
1408
|
+
case "programChange":
|
|
1409
|
+
renderProgramNumber[ch] = event.programNumber;
|
|
1410
|
+
break;
|
|
1411
|
+
case "sysEx": {
|
|
1412
|
+
const data = event.data;
|
|
1413
|
+
if (data[0] === 126 && data[1] === 9 && data[2] === 3) {
|
|
1414
|
+
if (data[3] === 1) { // GM1 System On
|
|
1415
|
+
renderProgramNumber.fill(0);
|
|
1416
|
+
renderIsDrum.fill(0);
|
|
1417
|
+
renderIsDrum[9] = 1;
|
|
1418
|
+
for (let c = 0; c < this.numChannels; c++) {
|
|
1419
|
+
for (const { type, defaultValue } of Object.values(defaultControllerState)) {
|
|
1420
|
+
renderControllerStates[c][type] = defaultValue;
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
break;
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
const offlineContext = new OfflineAudioContext(2, totalSamples, sampleRate);
|
|
1430
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
1431
|
+
const { t, promise } = tasks[i];
|
|
1432
|
+
const noteBuffer = await promise;
|
|
1433
|
+
if (!noteBuffer)
|
|
1434
|
+
continue;
|
|
1435
|
+
const audioBuffer = noteBuffer instanceof RenderedBuffer
|
|
1436
|
+
? noteBuffer.buffer
|
|
1437
|
+
: noteBuffer;
|
|
1438
|
+
const bufferSource = new AudioBufferSourceNode(offlineContext, {
|
|
1439
|
+
buffer: audioBuffer,
|
|
1440
|
+
});
|
|
1441
|
+
bufferSource.connect(offlineContext.destination);
|
|
1442
|
+
bufferSource.start(t);
|
|
1443
|
+
}
|
|
1444
|
+
this.renderedAudioBuffer = await offlineContext.startRendering();
|
|
1445
|
+
this.isRendering = false;
|
|
1446
|
+
this.dispatchEvent(new Event("rendered"));
|
|
1447
|
+
return this.renderedAudioBuffer;
|
|
1448
|
+
}
|
|
897
1449
|
async start() {
|
|
898
1450
|
if (this.isPlaying || this.isPaused)
|
|
899
1451
|
return;
|
|
@@ -930,11 +1482,22 @@ class MidyGM1 extends EventTarget {
|
|
|
930
1482
|
}
|
|
931
1483
|
}
|
|
932
1484
|
tempoChange(tempo) {
|
|
1485
|
+
const cacheMode = this.cacheMode;
|
|
933
1486
|
const timeScale = this.tempo / tempo;
|
|
934
1487
|
this.resumeTime = this.resumeTime * timeScale;
|
|
935
1488
|
this.tempo = tempo;
|
|
936
1489
|
this.totalTime = this.calcTotalTime();
|
|
937
1490
|
this.seekTo(this.currentTime() * timeScale);
|
|
1491
|
+
if (cacheMode === "adsr" || cacheMode === "note" || cacheMode === "audio") {
|
|
1492
|
+
this.buildNoteOnDurations();
|
|
1493
|
+
this.fullVoiceCache.clear();
|
|
1494
|
+
this.adsrVoiceCache.clear();
|
|
1495
|
+
}
|
|
1496
|
+
if (cacheMode === "audio") {
|
|
1497
|
+
if (this.audioModeBufferSource) {
|
|
1498
|
+
this.audioModeBufferSource.playbackRate.setValueAtTime(this.tempo, this.audioContext.currentTime);
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
938
1501
|
}
|
|
939
1502
|
calcTotalTime() {
|
|
940
1503
|
const totalTimeEventTypes = this.totalTimeEventTypes;
|
|
@@ -955,6 +1518,9 @@ class MidyGM1 extends EventTarget {
|
|
|
955
1518
|
if (!this.isPlaying)
|
|
956
1519
|
return this.resumeTime;
|
|
957
1520
|
const now = this.audioContext.currentTime;
|
|
1521
|
+
if (this.cacheMode === "audio") {
|
|
1522
|
+
return this.resumeTime + (now - this.startTime) * this.tempo;
|
|
1523
|
+
}
|
|
958
1524
|
return now + this.resumeTime - this.startTime;
|
|
959
1525
|
}
|
|
960
1526
|
async processScheduledNotes(channel, callback) {
|
|
@@ -1005,6 +1571,8 @@ class MidyGM1 extends EventTarget {
|
|
|
1005
1571
|
}
|
|
1006
1572
|
updateChannelDetune(channel, scheduleTime) {
|
|
1007
1573
|
this.processScheduledNotes(channel, (note) => {
|
|
1574
|
+
if (note.renderedBuffer?.isFull)
|
|
1575
|
+
return;
|
|
1008
1576
|
this.setDetune(channel, note, scheduleTime);
|
|
1009
1577
|
});
|
|
1010
1578
|
}
|
|
@@ -1012,6 +1580,8 @@ class MidyGM1 extends EventTarget {
|
|
|
1012
1580
|
return channel.detune + note.voiceParams.detune;
|
|
1013
1581
|
}
|
|
1014
1582
|
setVolumeEnvelope(note, scheduleTime) {
|
|
1583
|
+
if (!note.volumeEnvelopeNode)
|
|
1584
|
+
return;
|
|
1015
1585
|
const { voiceParams, startTime } = note;
|
|
1016
1586
|
const attackVolume = cbToRatio(-voiceParams.initialAttenuation);
|
|
1017
1587
|
const sustainVolume = attackVolume * (1 - voiceParams.volSustain);
|
|
@@ -1029,9 +1599,6 @@ class MidyGM1 extends EventTarget {
|
|
|
1029
1599
|
}
|
|
1030
1600
|
setDetune(channel, note, scheduleTime) {
|
|
1031
1601
|
const detune = this.calcNoteDetune(channel, note);
|
|
1032
|
-
note.bufferSource.detune
|
|
1033
|
-
.cancelScheduledValues(scheduleTime)
|
|
1034
|
-
.setValueAtTime(detune, scheduleTime);
|
|
1035
1602
|
const timeConstant = this.perceptualSmoothingTime / 5; // 99.3% (5 * tau)
|
|
1036
1603
|
note.bufferSource.detune
|
|
1037
1604
|
.cancelAndHoldAtTime(scheduleTime)
|
|
@@ -1063,6 +1630,8 @@ class MidyGM1 extends EventTarget {
|
|
|
1063
1630
|
return Math.max(minFrequency, Math.min(frequency, maxFrequency));
|
|
1064
1631
|
}
|
|
1065
1632
|
setFilterEnvelope(note, scheduleTime) {
|
|
1633
|
+
if (!note.filterEnvelopeNode)
|
|
1634
|
+
return;
|
|
1066
1635
|
const { voiceParams, startTime } = note;
|
|
1067
1636
|
const modEnvToFilterFc = voiceParams.modEnvToFilterFc;
|
|
1068
1637
|
const baseCent = voiceParams.initialFilterFc;
|
|
@@ -1103,40 +1672,352 @@ class MidyGM1 extends EventTarget {
|
|
|
1103
1672
|
this.setModLfoToVolume(note, scheduleTime);
|
|
1104
1673
|
note.modLfo.start(note.startTime + voiceParams.delayModLFO);
|
|
1105
1674
|
note.modLfo.connect(note.modLfoToFilterFc);
|
|
1106
|
-
|
|
1675
|
+
if (note.filterEnvelopeNode) {
|
|
1676
|
+
note.modLfoToFilterFc.connect(note.filterEnvelopeNode.frequency);
|
|
1677
|
+
}
|
|
1107
1678
|
note.modLfo.connect(note.modLfoToPitch);
|
|
1108
1679
|
note.modLfoToPitch.connect(note.bufferSource.detune);
|
|
1109
1680
|
note.modLfo.connect(note.modLfoToVolume);
|
|
1110
|
-
note.
|
|
1681
|
+
const volumeTarget = note.volumeEnvelopeNode ?? note.volumeNode;
|
|
1682
|
+
note.modLfoToVolume.connect(volumeTarget.gain);
|
|
1683
|
+
}
|
|
1684
|
+
async createAdsRenderedBuffer(note, voiceParams, audioBuffer, isDrum = false) {
|
|
1685
|
+
const isLoop = isDrum ? false : (voiceParams.sampleModes % 2 !== 0);
|
|
1686
|
+
const volAttack = voiceParams.volDelay + voiceParams.volAttack;
|
|
1687
|
+
const volHold = volAttack + voiceParams.volHold;
|
|
1688
|
+
const decayDuration = voiceParams.volDecay;
|
|
1689
|
+
const adsDuration = volHold + decayDuration * decayCurve * 5;
|
|
1690
|
+
const sampleLoopStart = voiceParams.loopStart / voiceParams.sampleRate;
|
|
1691
|
+
const sampleLoopDuration = isLoop
|
|
1692
|
+
? (voiceParams.loopEnd - voiceParams.loopStart) / voiceParams.sampleRate
|
|
1693
|
+
: 0;
|
|
1694
|
+
const playbackRate = voiceParams.playbackRate;
|
|
1695
|
+
const outputLoopStart = sampleLoopStart / playbackRate;
|
|
1696
|
+
const outputLoopDuration = sampleLoopDuration / playbackRate;
|
|
1697
|
+
const loopCount = isLoop && adsDuration > outputLoopStart
|
|
1698
|
+
? Math.ceil((adsDuration - outputLoopStart) / outputLoopDuration)
|
|
1699
|
+
: 0;
|
|
1700
|
+
const alignedLoopStart = outputLoopStart + loopCount * outputLoopDuration;
|
|
1701
|
+
const renderDuration = isLoop
|
|
1702
|
+
? alignedLoopStart + outputLoopDuration
|
|
1703
|
+
: audioBuffer.duration / playbackRate;
|
|
1704
|
+
const sampleRate = this.audioContext.sampleRate;
|
|
1705
|
+
const offlineContext = new OfflineAudioContext(audioBuffer.numberOfChannels, Math.ceil(renderDuration * sampleRate), sampleRate);
|
|
1706
|
+
const bufferSource = new AudioBufferSourceNode(offlineContext);
|
|
1707
|
+
bufferSource.buffer = audioBuffer;
|
|
1708
|
+
bufferSource.playbackRate.value = playbackRate;
|
|
1709
|
+
bufferSource.loop = isLoop;
|
|
1710
|
+
if (isLoop) {
|
|
1711
|
+
bufferSource.loopStart = sampleLoopStart;
|
|
1712
|
+
bufferSource.loopEnd = sampleLoopStart + sampleLoopDuration;
|
|
1713
|
+
}
|
|
1714
|
+
const initialFreq = this.clampCutoffFrequency(this.centToHz(voiceParams.initialFilterFc));
|
|
1715
|
+
const filterEnvelopeNode = new BiquadFilterNode(offlineContext, {
|
|
1716
|
+
type: "lowpass",
|
|
1717
|
+
Q: voiceParams.initialFilterQ / 10, // dB
|
|
1718
|
+
frequency: initialFreq,
|
|
1719
|
+
});
|
|
1720
|
+
const volumeEnvelopeNode = new GainNode(offlineContext);
|
|
1721
|
+
const offlineNote = {
|
|
1722
|
+
...note,
|
|
1723
|
+
startTime: 0,
|
|
1724
|
+
bufferSource,
|
|
1725
|
+
filterEnvelopeNode,
|
|
1726
|
+
volumeEnvelopeNode,
|
|
1727
|
+
};
|
|
1728
|
+
this.setVolumeEnvelope(offlineNote, 0);
|
|
1729
|
+
this.setFilterEnvelope(offlineNote, 0);
|
|
1730
|
+
bufferSource.connect(filterEnvelopeNode);
|
|
1731
|
+
filterEnvelopeNode.connect(volumeEnvelopeNode);
|
|
1732
|
+
volumeEnvelopeNode.connect(offlineContext.destination);
|
|
1733
|
+
if (voiceParams.sample.type === "compressed") {
|
|
1734
|
+
bufferSource.start(0, voiceParams.start / audioBuffer.sampleRate);
|
|
1735
|
+
}
|
|
1736
|
+
else {
|
|
1737
|
+
bufferSource.start(0);
|
|
1738
|
+
}
|
|
1739
|
+
const buffer = await offlineContext.startRendering();
|
|
1740
|
+
return new RenderedBuffer(buffer, {
|
|
1741
|
+
isLoop,
|
|
1742
|
+
adsDuration,
|
|
1743
|
+
loopStart: alignedLoopStart,
|
|
1744
|
+
loopDuration: outputLoopDuration,
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
async createAdsrRenderedBuffer(note, voiceParams, audioBuffer, noteDuration) {
|
|
1748
|
+
const isLoop = voiceParams.sampleModes % 2 !== 0;
|
|
1749
|
+
const volAttack = voiceParams.volDelay + voiceParams.volAttack;
|
|
1750
|
+
const volHold = volAttack + voiceParams.volHold;
|
|
1751
|
+
const decayDuration = voiceParams.volDecay;
|
|
1752
|
+
const adsDuration = volHold + decayDuration * decayCurve * 5;
|
|
1753
|
+
const releaseDuration = voiceParams.volRelease;
|
|
1754
|
+
const loopStartTime = voiceParams.loopStart / voiceParams.sampleRate;
|
|
1755
|
+
const loopDuration = isLoop
|
|
1756
|
+
? (voiceParams.loopEnd - voiceParams.loopStart) / voiceParams.sampleRate
|
|
1757
|
+
: 0;
|
|
1758
|
+
const noteLoopCount = isLoop && noteDuration > loopStartTime
|
|
1759
|
+
? Math.ceil((noteDuration - loopStartTime) / loopDuration)
|
|
1760
|
+
: 0;
|
|
1761
|
+
const alignedNoteEnd = isLoop
|
|
1762
|
+
? loopStartTime + noteLoopCount * loopDuration
|
|
1763
|
+
: noteDuration;
|
|
1764
|
+
const noteOffTime = alignedNoteEnd;
|
|
1765
|
+
const totalDuration = noteOffTime + releaseDuration;
|
|
1766
|
+
const sampleRate = this.audioContext.sampleRate;
|
|
1767
|
+
const offlineContext = new OfflineAudioContext(audioBuffer.numberOfChannels, Math.ceil(totalDuration * sampleRate), sampleRate);
|
|
1768
|
+
const bufferSource = new AudioBufferSourceNode(offlineContext);
|
|
1769
|
+
bufferSource.buffer = audioBuffer;
|
|
1770
|
+
bufferSource.playbackRate.value = voiceParams.playbackRate;
|
|
1771
|
+
bufferSource.loop = isLoop;
|
|
1772
|
+
if (isLoop) {
|
|
1773
|
+
bufferSource.loopStart = loopStartTime;
|
|
1774
|
+
bufferSource.loopEnd = loopStartTime + loopDuration;
|
|
1775
|
+
}
|
|
1776
|
+
const initialFreq = this.clampCutoffFrequency(this.centToHz(voiceParams.initialFilterFc));
|
|
1777
|
+
const filterEnvelopeNode = new BiquadFilterNode(offlineContext, {
|
|
1778
|
+
type: "lowpass",
|
|
1779
|
+
Q: voiceParams.initialFilterQ / 10, // dB
|
|
1780
|
+
frequency: initialFreq,
|
|
1781
|
+
});
|
|
1782
|
+
const volumeEnvelopeNode = new GainNode(offlineContext);
|
|
1783
|
+
const offlineNote = {
|
|
1784
|
+
...note,
|
|
1785
|
+
startTime: 0,
|
|
1786
|
+
bufferSource,
|
|
1787
|
+
filterEnvelopeNode,
|
|
1788
|
+
volumeEnvelopeNode,
|
|
1789
|
+
};
|
|
1790
|
+
this.setVolumeEnvelope(offlineNote, 0);
|
|
1791
|
+
this.setFilterEnvelope(offlineNote, 0);
|
|
1792
|
+
const attackVolume = cbToRatio(-voiceParams.initialAttenuation);
|
|
1793
|
+
const sustainVolume = attackVolume * (1 - voiceParams.volSustain);
|
|
1794
|
+
const volDelayTime = voiceParams.volDelay;
|
|
1795
|
+
const volAttackTime = volDelayTime + voiceParams.volAttack;
|
|
1796
|
+
const volHoldTime = volAttackTime + voiceParams.volHold;
|
|
1797
|
+
let gainAtNoteOff;
|
|
1798
|
+
if (noteOffTime <= volDelayTime) {
|
|
1799
|
+
gainAtNoteOff = 0;
|
|
1800
|
+
}
|
|
1801
|
+
else if (noteOffTime <= volAttackTime) {
|
|
1802
|
+
gainAtNoteOff = 1e-6 + (attackVolume - 1e-6) *
|
|
1803
|
+
(noteOffTime - volDelayTime) / voiceParams.volAttack;
|
|
1804
|
+
}
|
|
1805
|
+
else if (noteOffTime <= volHoldTime) {
|
|
1806
|
+
gainAtNoteOff = attackVolume;
|
|
1807
|
+
}
|
|
1808
|
+
else {
|
|
1809
|
+
const decayElapsed = noteOffTime - volHoldTime;
|
|
1810
|
+
gainAtNoteOff = sustainVolume +
|
|
1811
|
+
(attackVolume - sustainVolume) *
|
|
1812
|
+
Math.exp(-decayElapsed / (decayCurve * voiceParams.volDecay));
|
|
1813
|
+
}
|
|
1814
|
+
volumeEnvelopeNode.gain
|
|
1815
|
+
.cancelScheduledValues(noteOffTime)
|
|
1816
|
+
.setValueAtTime(gainAtNoteOff, noteOffTime)
|
|
1817
|
+
.setTargetAtTime(0, noteOffTime, releaseDuration * releaseCurve);
|
|
1818
|
+
filterEnvelopeNode.frequency
|
|
1819
|
+
.cancelScheduledValues(noteOffTime)
|
|
1820
|
+
.setValueAtTime(initialFreq, noteOffTime)
|
|
1821
|
+
.setTargetAtTime(initialFreq, noteOffTime, voiceParams.modRelease * releaseCurve);
|
|
1822
|
+
bufferSource.connect(filterEnvelopeNode);
|
|
1823
|
+
filterEnvelopeNode.connect(volumeEnvelopeNode);
|
|
1824
|
+
volumeEnvelopeNode.connect(offlineContext.destination);
|
|
1825
|
+
if (isLoop) {
|
|
1826
|
+
bufferSource.start(0, voiceParams.start / audioBuffer.sampleRate);
|
|
1827
|
+
}
|
|
1828
|
+
else {
|
|
1829
|
+
bufferSource.start(0);
|
|
1830
|
+
}
|
|
1831
|
+
const buffer = await offlineContext.startRendering();
|
|
1832
|
+
return new RenderedBuffer(buffer, {
|
|
1833
|
+
isLoop: false,
|
|
1834
|
+
isFull: false,
|
|
1835
|
+
adsDuration,
|
|
1836
|
+
noteDuration: noteOffTime,
|
|
1837
|
+
releaseDuration,
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
async createFullRenderedBuffer(channel, note, voiceParams, noteDuration, noteEvent = {}) {
|
|
1841
|
+
const { startTime: noteStartTime = 0, events: noteEvents = [] } = noteEvent;
|
|
1842
|
+
const ch = channel.channelNumber;
|
|
1843
|
+
const releaseEndDuration = voiceParams.volRelease * releaseCurve * 5;
|
|
1844
|
+
const totalDuration = noteDuration + releaseEndDuration;
|
|
1845
|
+
const sampleRate = this.audioContext.sampleRate;
|
|
1846
|
+
const offlineContext = new OfflineAudioContext(2, Math.ceil(totalDuration * sampleRate), sampleRate);
|
|
1847
|
+
const offlinePlayer = new this.constructor(offlineContext, {
|
|
1848
|
+
cacheMode: "none",
|
|
1849
|
+
});
|
|
1850
|
+
offlineContext.suspend = () => Promise.resolve();
|
|
1851
|
+
offlineContext.resume = () => Promise.resolve();
|
|
1852
|
+
offlinePlayer.soundFonts = this.soundFonts;
|
|
1853
|
+
offlinePlayer.soundFontTable = this.soundFontTable;
|
|
1854
|
+
const dstChannel = offlinePlayer.channels[ch];
|
|
1855
|
+
dstChannel.state.array.set(channel.state.array);
|
|
1856
|
+
dstChannel.isDrum = channel.isDrum;
|
|
1857
|
+
dstChannel.programNumber = channel.programNumber;
|
|
1858
|
+
dstChannel.modulationDepthRange = channel.modulationDepthRange;
|
|
1859
|
+
dstChannel.detune = this.calcChannelDetune(dstChannel);
|
|
1860
|
+
await offlinePlayer.noteOn(ch, note.noteNumber, note.velocity, 0);
|
|
1861
|
+
for (const event of noteEvents) {
|
|
1862
|
+
const t = event.startTime / this.tempo - noteStartTime;
|
|
1863
|
+
if (t < 0 || t > noteDuration)
|
|
1864
|
+
continue;
|
|
1865
|
+
switch (event.type) {
|
|
1866
|
+
case "controller":
|
|
1867
|
+
offlinePlayer.setControlChange(ch, event.controllerType, event.value, t);
|
|
1868
|
+
break;
|
|
1869
|
+
case "pitchBend":
|
|
1870
|
+
offlinePlayer.setPitchBend(ch, event.value + 8192, t);
|
|
1871
|
+
break;
|
|
1872
|
+
case "sysEx":
|
|
1873
|
+
offlinePlayer.handleSysEx(event.data, t);
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
offlinePlayer.noteOff(ch, note.noteNumber, 0, noteDuration, true);
|
|
1877
|
+
const buffer = await offlineContext.startRendering();
|
|
1878
|
+
return new RenderedBuffer(buffer, {
|
|
1879
|
+
isLoop: false,
|
|
1880
|
+
isFull: true,
|
|
1881
|
+
noteDuration: noteDuration,
|
|
1882
|
+
releaseDuration: releaseEndDuration,
|
|
1883
|
+
});
|
|
1111
1884
|
}
|
|
1112
|
-
async getAudioBuffer(channel,
|
|
1885
|
+
async getAudioBuffer(channel, note, realtime) {
|
|
1886
|
+
const cacheMode = this.cacheMode;
|
|
1887
|
+
const { noteNumber, velocity } = note;
|
|
1113
1888
|
const audioBufferId = this.getVoiceId(channel, noteNumber, velocity);
|
|
1889
|
+
if (!realtime) {
|
|
1890
|
+
if (cacheMode === "note") {
|
|
1891
|
+
return await this.getFullCachedBuffer(channel, note, audioBufferId);
|
|
1892
|
+
}
|
|
1893
|
+
else if (cacheMode === "adsr") {
|
|
1894
|
+
return await this.getAdsrCachedBuffer(note, audioBufferId);
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
if (cacheMode === "none") {
|
|
1898
|
+
return await this.createAudioBuffer(note.voiceParams);
|
|
1899
|
+
}
|
|
1900
|
+
// fallback to ADS cache:
|
|
1901
|
+
// - "ads" (realtime or not)
|
|
1902
|
+
// - "adsr" + realtime
|
|
1903
|
+
// - "note" + realtime
|
|
1904
|
+
return await this.getAdsCachedBuffer(channel, note, audioBufferId, realtime);
|
|
1905
|
+
}
|
|
1906
|
+
async getAdsCachedBuffer(channel, note, audioBufferId, realtime) {
|
|
1907
|
+
const cacheKey = audioBufferId + (note.noteNumber << 1) + 1;
|
|
1908
|
+
const voiceParams = note.voiceParams;
|
|
1114
1909
|
if (realtime) {
|
|
1115
|
-
const
|
|
1116
|
-
if (
|
|
1117
|
-
return
|
|
1118
|
-
const
|
|
1119
|
-
this.
|
|
1120
|
-
|
|
1910
|
+
const cached = this.realtimeVoiceCache.get(cacheKey);
|
|
1911
|
+
if (cached)
|
|
1912
|
+
return cached;
|
|
1913
|
+
const rawBuffer = await this.createAudioBuffer(voiceParams);
|
|
1914
|
+
const rendered = await this.createAdsRenderedBuffer(note, voiceParams, rawBuffer, channel.isDrum);
|
|
1915
|
+
this.realtimeVoiceCache.set(cacheKey, rendered);
|
|
1916
|
+
return rendered;
|
|
1121
1917
|
}
|
|
1122
1918
|
else {
|
|
1123
|
-
const cache = this.voiceCache.get(
|
|
1919
|
+
const cache = this.voiceCache.get(cacheKey);
|
|
1124
1920
|
if (cache) {
|
|
1125
1921
|
cache.counter += 1;
|
|
1126
1922
|
if (cache.maxCount <= cache.counter) {
|
|
1127
|
-
this.voiceCache.delete(
|
|
1923
|
+
this.voiceCache.delete(cacheKey);
|
|
1128
1924
|
}
|
|
1129
1925
|
return cache.audioBuffer;
|
|
1130
1926
|
}
|
|
1131
1927
|
else {
|
|
1132
|
-
const maxCount = this.voiceCounter.get(
|
|
1133
|
-
const
|
|
1134
|
-
const
|
|
1135
|
-
|
|
1136
|
-
|
|
1928
|
+
const maxCount = this.voiceCounter.get(cacheKey) ?? 0;
|
|
1929
|
+
const rawBuffer = await this.createAudioBuffer(voiceParams);
|
|
1930
|
+
const rendered = await this.createAdsRenderedBuffer(note, voiceParams, rawBuffer, channel.isDrum);
|
|
1931
|
+
const cache = { audioBuffer: rendered, maxCount, counter: 1 };
|
|
1932
|
+
this.voiceCache.set(cacheKey, cache);
|
|
1933
|
+
return rendered;
|
|
1137
1934
|
}
|
|
1138
1935
|
}
|
|
1139
1936
|
}
|
|
1937
|
+
async getAdsrCachedBuffer(note, audioBufferId) {
|
|
1938
|
+
const voiceParams = note.voiceParams;
|
|
1939
|
+
const timelineIndex = note.timelineIndex;
|
|
1940
|
+
const noteEvent = this.noteOnEvents.get(timelineIndex);
|
|
1941
|
+
const noteDurationTicks = noteEvent?.durationTicks ?? 0;
|
|
1942
|
+
const safeTicks = noteDurationTicks === Infinity
|
|
1943
|
+
? 0xffffffffn
|
|
1944
|
+
: BigInt(noteDurationTicks);
|
|
1945
|
+
const volReleaseBits = f64ToBigInt(voiceParams.volRelease);
|
|
1946
|
+
const playbackRateBits = f64ToBigInt(voiceParams.playbackRate);
|
|
1947
|
+
const cacheKey = (BigInt(audioBufferId) << 160n) |
|
|
1948
|
+
(playbackRateBits << 96n) |
|
|
1949
|
+
(safeTicks << 64n) |
|
|
1950
|
+
volReleaseBits;
|
|
1951
|
+
let durationMap = this.adsrVoiceCache.get(audioBufferId);
|
|
1952
|
+
if (!durationMap) {
|
|
1953
|
+
durationMap = new Map();
|
|
1954
|
+
this.adsrVoiceCache.set(audioBufferId, durationMap);
|
|
1955
|
+
}
|
|
1956
|
+
const cached = durationMap.get(cacheKey);
|
|
1957
|
+
if (cached instanceof RenderedBuffer) {
|
|
1958
|
+
return cached;
|
|
1959
|
+
}
|
|
1960
|
+
if (cached instanceof Promise) {
|
|
1961
|
+
const buf = await cached;
|
|
1962
|
+
if (buf == null)
|
|
1963
|
+
return await this.createAudioBuffer(voiceParams);
|
|
1964
|
+
return buf;
|
|
1965
|
+
}
|
|
1966
|
+
const noteDuration = noteEvent?.duration ?? 0;
|
|
1967
|
+
const renderPromise = (async () => {
|
|
1968
|
+
try {
|
|
1969
|
+
const rawBuffer = await this.createAudioBuffer(voiceParams);
|
|
1970
|
+
const rendered = await this.createAdsrRenderedBuffer(note, voiceParams, rawBuffer, noteDuration);
|
|
1971
|
+
durationMap.set(cacheKey, rendered);
|
|
1972
|
+
return rendered;
|
|
1973
|
+
}
|
|
1974
|
+
catch (err) {
|
|
1975
|
+
durationMap.delete(cacheKey);
|
|
1976
|
+
throw err;
|
|
1977
|
+
}
|
|
1978
|
+
})();
|
|
1979
|
+
durationMap.set(cacheKey, renderPromise);
|
|
1980
|
+
return await renderPromise;
|
|
1981
|
+
}
|
|
1982
|
+
async getFullCachedBuffer(channel, note, audioBufferId) {
|
|
1983
|
+
const voiceParams = note.voiceParams;
|
|
1984
|
+
const timelineIndex = note.timelineIndex;
|
|
1985
|
+
const noteEvent = this.noteOnEvents.get(timelineIndex);
|
|
1986
|
+
const noteDuration = noteEvent?.duration ?? 0;
|
|
1987
|
+
const cacheKey = timelineIndex;
|
|
1988
|
+
let durationMap = this.fullVoiceCache.get(audioBufferId);
|
|
1989
|
+
if (!durationMap) {
|
|
1990
|
+
durationMap = new Map();
|
|
1991
|
+
this.fullVoiceCache.set(audioBufferId, durationMap);
|
|
1992
|
+
}
|
|
1993
|
+
const cached = durationMap.get(cacheKey);
|
|
1994
|
+
if (cached instanceof RenderedBuffer) {
|
|
1995
|
+
note.fullCacheVoiceId = audioBufferId;
|
|
1996
|
+
return cached;
|
|
1997
|
+
}
|
|
1998
|
+
if (cached instanceof Promise) {
|
|
1999
|
+
const buf = await cached;
|
|
2000
|
+
if (buf == null)
|
|
2001
|
+
return await this.createAudioBuffer(voiceParams);
|
|
2002
|
+
note.fullCacheVoiceId = audioBufferId;
|
|
2003
|
+
return buf;
|
|
2004
|
+
}
|
|
2005
|
+
const renderPromise = (async () => {
|
|
2006
|
+
try {
|
|
2007
|
+
const rendered = await this.createFullRenderedBuffer(channel, note, voiceParams, noteDuration, noteEvent);
|
|
2008
|
+
durationMap.set(cacheKey, rendered);
|
|
2009
|
+
return rendered;
|
|
2010
|
+
}
|
|
2011
|
+
catch (err) {
|
|
2012
|
+
durationMap.delete(cacheKey);
|
|
2013
|
+
throw err;
|
|
2014
|
+
}
|
|
2015
|
+
})();
|
|
2016
|
+
durationMap.set(cacheKey, renderPromise);
|
|
2017
|
+
const rendered = await renderPromise;
|
|
2018
|
+
note.fullCacheVoiceId = audioBufferId;
|
|
2019
|
+
return rendered;
|
|
2020
|
+
}
|
|
1140
2021
|
async setNoteAudioNode(channel, note, realtime) {
|
|
1141
2022
|
const audioContext = this.audioContext;
|
|
1142
2023
|
const now = audioContext.currentTime;
|
|
@@ -1145,25 +2026,46 @@ class MidyGM1 extends EventTarget {
|
|
|
1145
2026
|
const controllerState = this.getControllerState(channel, noteNumber, velocity);
|
|
1146
2027
|
const voiceParams = note.voice.getAllParams(controllerState);
|
|
1147
2028
|
note.voiceParams = voiceParams;
|
|
1148
|
-
const audioBuffer = await this.getAudioBuffer(channel,
|
|
2029
|
+
const audioBuffer = await this.getAudioBuffer(channel, note, realtime);
|
|
2030
|
+
const isRendered = audioBuffer instanceof RenderedBuffer;
|
|
2031
|
+
note.renderedBuffer = isRendered ? audioBuffer : null;
|
|
1149
2032
|
note.bufferSource = this.createBufferSource(voiceParams, audioBuffer);
|
|
1150
|
-
note.
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
this.
|
|
2033
|
+
note.volumeNode = new GainNode(audioContext);
|
|
2034
|
+
const cacheMode = this.cacheMode;
|
|
2035
|
+
const isFullCached = isRendered && audioBuffer.isFull === true;
|
|
2036
|
+
if (cacheMode === "none") {
|
|
2037
|
+
note.volumeEnvelopeNode = new GainNode(audioContext);
|
|
2038
|
+
note.filterEnvelopeNode = new BiquadFilterNode(audioContext, {
|
|
2039
|
+
type: "lowpass",
|
|
2040
|
+
Q: voiceParams.initialFilterQ / 10, // dB
|
|
2041
|
+
});
|
|
2042
|
+
this.setVolumeEnvelope(note, now);
|
|
2043
|
+
this.setFilterEnvelope(note, now);
|
|
2044
|
+
this.setPitchEnvelope(note, now);
|
|
2045
|
+
this.setDetune(channel, note, now);
|
|
2046
|
+
if (0 < state.modulationDepthMSB) {
|
|
2047
|
+
this.startModulation(channel, note, now);
|
|
2048
|
+
}
|
|
2049
|
+
note.bufferSource.connect(note.filterEnvelopeNode);
|
|
2050
|
+
note.filterEnvelopeNode.connect(note.volumeEnvelopeNode);
|
|
2051
|
+
note.volumeEnvelopeNode.connect(note.volumeNode);
|
|
2052
|
+
}
|
|
2053
|
+
else if (isFullCached) { // "note" mode
|
|
2054
|
+
note.volumeEnvelopeNode = null;
|
|
2055
|
+
note.filterEnvelopeNode = null;
|
|
2056
|
+
note.bufferSource.connect(note.volumeNode);
|
|
2057
|
+
}
|
|
2058
|
+
else { // "ads" / "asdr" mode
|
|
2059
|
+
note.volumeEnvelopeNode = null;
|
|
2060
|
+
note.filterEnvelopeNode = null;
|
|
2061
|
+
this.setDetune(channel, note, now);
|
|
2062
|
+
if (0 < state.modulationDepthMSB) {
|
|
2063
|
+
this.startModulation(channel, note, now);
|
|
2064
|
+
}
|
|
2065
|
+
note.bufferSource.connect(note.volumeNode);
|
|
1161
2066
|
}
|
|
1162
|
-
note.bufferSource.connect(note.filterEnvelopeNode);
|
|
1163
|
-
note.filterEnvelopeNode.connect(note.volumeEnvelopeNode);
|
|
1164
2067
|
if (voiceParams.sample.type === "compressed") {
|
|
1165
|
-
|
|
1166
|
-
note.bufferSource.start(startTime, offset);
|
|
2068
|
+
note.bufferSource.start(startTime);
|
|
1167
2069
|
}
|
|
1168
2070
|
else {
|
|
1169
2071
|
note.bufferSource.start(startTime);
|
|
@@ -1186,23 +2088,30 @@ class MidyGM1 extends EventTarget {
|
|
|
1186
2088
|
}
|
|
1187
2089
|
setNoteRouting(channelNumber, note, startTime) {
|
|
1188
2090
|
const channel = this.channels[channelNumber];
|
|
1189
|
-
const
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
2091
|
+
const { volumeNode } = note;
|
|
2092
|
+
if (note.renderedBuffer?.isFull) {
|
|
2093
|
+
volumeNode.connect(this.masterVolume);
|
|
2094
|
+
}
|
|
2095
|
+
else {
|
|
2096
|
+
volumeNode.connect(channel.gainL);
|
|
2097
|
+
volumeNode.connect(channel.gainR);
|
|
1194
2098
|
}
|
|
1195
2099
|
this.handleExclusiveClass(note, channelNumber, startTime);
|
|
1196
2100
|
}
|
|
1197
2101
|
async noteOn(channelNumber, noteNumber, velocity, startTime) {
|
|
1198
|
-
const
|
|
1199
|
-
|
|
1200
|
-
|
|
2102
|
+
const note = this.createNote(channelNumber, noteNumber, velocity, startTime);
|
|
2103
|
+
return await this.setupNote(channelNumber, note, startTime);
|
|
2104
|
+
}
|
|
2105
|
+
createNote(channelNumber, noteNumber, velocity, startTime) {
|
|
2106
|
+
if (!(0 <= startTime))
|
|
1201
2107
|
startTime = this.audioContext.currentTime;
|
|
1202
2108
|
const note = new Note(noteNumber, velocity, startTime);
|
|
1203
|
-
|
|
1204
|
-
note
|
|
1205
|
-
|
|
2109
|
+
note.channel = channelNumber;
|
|
2110
|
+
return note;
|
|
2111
|
+
}
|
|
2112
|
+
async setupNote(channelNumber, note, startTime) {
|
|
2113
|
+
const realtime = startTime === undefined;
|
|
2114
|
+
const channel = this.channels[channelNumber];
|
|
1206
2115
|
const programNumber = channel.programNumber;
|
|
1207
2116
|
const bankTable = this.soundFontTable[programNumber];
|
|
1208
2117
|
if (!bankTable)
|
|
@@ -1217,42 +2126,124 @@ class MidyGM1 extends EventTarget {
|
|
|
1217
2126
|
if (soundFontIndex === undefined)
|
|
1218
2127
|
return;
|
|
1219
2128
|
const soundFont = this.soundFonts[soundFontIndex];
|
|
1220
|
-
note.voice = soundFont.getVoice(bank, programNumber, noteNumber, velocity);
|
|
2129
|
+
note.voice = soundFont.getVoice(bank, programNumber, note.noteNumber, note.velocity);
|
|
1221
2130
|
if (!note.voice)
|
|
1222
2131
|
return;
|
|
2132
|
+
note.index = channel.scheduledNotes.length;
|
|
2133
|
+
channel.scheduledNotes.push(note);
|
|
1223
2134
|
await this.setNoteAudioNode(channel, note, realtime);
|
|
1224
2135
|
this.setNoteRouting(channelNumber, note, startTime);
|
|
1225
2136
|
note.resolveReady();
|
|
2137
|
+
if (0.5 <= channel.state.sustainPedal) {
|
|
2138
|
+
channel.sustainNotes.push(note);
|
|
2139
|
+
}
|
|
2140
|
+
return note;
|
|
1226
2141
|
}
|
|
1227
2142
|
disconnectNote(note) {
|
|
1228
2143
|
note.bufferSource.disconnect();
|
|
1229
|
-
note.filterEnvelopeNode
|
|
1230
|
-
note.volumeEnvelopeNode
|
|
2144
|
+
note.filterEnvelopeNode?.disconnect();
|
|
2145
|
+
note.volumeEnvelopeNode?.disconnect();
|
|
2146
|
+
note.volumeNode.disconnect();
|
|
1231
2147
|
if (note.modLfoToPitch) {
|
|
1232
2148
|
note.modLfoToVolume.disconnect();
|
|
1233
2149
|
note.modLfoToPitch.disconnect();
|
|
1234
2150
|
note.modLfo.stop();
|
|
1235
2151
|
}
|
|
1236
2152
|
}
|
|
2153
|
+
releaseFullCache(note) {
|
|
2154
|
+
if (note.timelineIndex == null || note.fullCacheVoiceId == null)
|
|
2155
|
+
return;
|
|
2156
|
+
const durationMap = this.fullVoiceCache.get(note.fullCacheVoiceId);
|
|
2157
|
+
if (!durationMap)
|
|
2158
|
+
return;
|
|
2159
|
+
const entry = durationMap.get(note.timelineIndex);
|
|
2160
|
+
if (entry instanceof RenderedBuffer) {
|
|
2161
|
+
durationMap.delete(note.timelineIndex);
|
|
2162
|
+
if (durationMap.size === 0) {
|
|
2163
|
+
this.fullVoiceCache.delete(note.fullCacheVoiceId);
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
1237
2167
|
releaseNote(channel, note, endTime) {
|
|
1238
2168
|
endTime ??= this.audioContext.currentTime;
|
|
2169
|
+
if (note.renderedBuffer?.isFull) {
|
|
2170
|
+
const rb = note.renderedBuffer;
|
|
2171
|
+
const naturalEndTime = note.startTime + rb.buffer.duration;
|
|
2172
|
+
const noteOffTime = note.startTime + (rb.noteDuration ?? 0);
|
|
2173
|
+
const isEarlyCut = endTime < noteOffTime;
|
|
2174
|
+
if (isEarlyCut) {
|
|
2175
|
+
const volDuration = note.voiceParams.volRelease;
|
|
2176
|
+
const volRelease = endTime + volDuration;
|
|
2177
|
+
note.volumeNode.gain
|
|
2178
|
+
.cancelScheduledValues(endTime)
|
|
2179
|
+
.setTargetAtTime(0, endTime, volDuration * releaseCurve);
|
|
2180
|
+
note.bufferSource.stop(volRelease);
|
|
2181
|
+
}
|
|
2182
|
+
else {
|
|
2183
|
+
const now = this.audioContext.currentTime;
|
|
2184
|
+
if (naturalEndTime <= now) {
|
|
2185
|
+
this.disconnectNote(note);
|
|
2186
|
+
channel.scheduledNotes[note.index] = undefined;
|
|
2187
|
+
this.releaseFullCache(note);
|
|
2188
|
+
return Promise.resolve();
|
|
2189
|
+
}
|
|
2190
|
+
note.bufferSource.stop(naturalEndTime);
|
|
2191
|
+
}
|
|
2192
|
+
return new Promise((resolve) => {
|
|
2193
|
+
note.bufferSource.onended = () => {
|
|
2194
|
+
this.disconnectNote(note);
|
|
2195
|
+
channel.scheduledNotes[note.index] = undefined;
|
|
2196
|
+
this.releaseFullCache(note);
|
|
2197
|
+
resolve();
|
|
2198
|
+
};
|
|
2199
|
+
});
|
|
2200
|
+
}
|
|
1239
2201
|
const volDuration = note.voiceParams.volRelease;
|
|
1240
2202
|
const volRelease = endTime + volDuration;
|
|
1241
|
-
note.
|
|
1242
|
-
.
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
.
|
|
1246
|
-
|
|
2203
|
+
if (note.volumeEnvelopeNode) { // "none" mode
|
|
2204
|
+
note.filterEnvelopeNode.frequency
|
|
2205
|
+
.cancelScheduledValues(endTime)
|
|
2206
|
+
.setTargetAtTime(note.adjustedBaseFreq, endTime, note.voiceParams.modRelease * releaseCurve);
|
|
2207
|
+
note.volumeEnvelopeNode.gain
|
|
2208
|
+
.cancelScheduledValues(endTime)
|
|
2209
|
+
.setTargetAtTime(0, endTime, volDuration * releaseCurve);
|
|
2210
|
+
}
|
|
2211
|
+
else { // "ads" / "adsr" mode
|
|
2212
|
+
const isAdsr = note.renderedBuffer?.releaseDuration != null &&
|
|
2213
|
+
!note.renderedBuffer.isFull;
|
|
2214
|
+
if (isAdsr) {
|
|
2215
|
+
const rb = note.renderedBuffer;
|
|
2216
|
+
const naturalEndTime = note.startTime + rb.buffer.duration;
|
|
2217
|
+
const noteOffTime = note.startTime + (rb.noteDuration ?? 0);
|
|
2218
|
+
const isEarlyCut = endTime < noteOffTime;
|
|
2219
|
+
if (isEarlyCut) {
|
|
2220
|
+
note.volumeNode.gain
|
|
2221
|
+
.cancelScheduledValues(endTime)
|
|
2222
|
+
.setTargetAtTime(0, endTime, volDuration * releaseCurve);
|
|
2223
|
+
note.bufferSource.stop(volRelease);
|
|
2224
|
+
}
|
|
2225
|
+
else {
|
|
2226
|
+
note.bufferSource.stop(naturalEndTime);
|
|
2227
|
+
}
|
|
2228
|
+
return new Promise((resolve) => {
|
|
2229
|
+
note.bufferSource.onended = () => {
|
|
2230
|
+
this.disconnectNote(note);
|
|
2231
|
+
channel.scheduledNotes[note.index] = undefined;
|
|
2232
|
+
resolve();
|
|
2233
|
+
};
|
|
2234
|
+
});
|
|
2235
|
+
}
|
|
2236
|
+
note.volumeNode.gain
|
|
2237
|
+
.cancelScheduledValues(endTime)
|
|
2238
|
+
.setTargetAtTime(0, endTime, volDuration * releaseCurve);
|
|
2239
|
+
}
|
|
2240
|
+
note.bufferSource.stop(volRelease);
|
|
1247
2241
|
return new Promise((resolve) => {
|
|
1248
|
-
|
|
1249
|
-
const bufferSource = note.bufferSource;
|
|
1250
|
-
bufferSource.loop = false;
|
|
1251
|
-
bufferSource.stop(volRelease);
|
|
2242
|
+
note.bufferSource.onended = () => {
|
|
1252
2243
|
this.disconnectNote(note);
|
|
1253
2244
|
channel.scheduledNotes[note.index] = undefined;
|
|
1254
2245
|
resolve();
|
|
1255
|
-
}
|
|
2246
|
+
};
|
|
1256
2247
|
});
|
|
1257
2248
|
}
|
|
1258
2249
|
noteOff(channelNumber, noteNumber, _velocity, endTime, force) {
|
|
@@ -1427,7 +2418,7 @@ class MidyGM1 extends EventTarget {
|
|
|
1427
2418
|
},
|
|
1428
2419
|
chorusEffectsSend: (_channel, _note, _scheduleTime) => { },
|
|
1429
2420
|
reverbEffectsSend: (_channel, _note, _scheduleTime) => { },
|
|
1430
|
-
delayModLFO: (
|
|
2421
|
+
delayModLFO: (channel, note, scheduleTime) => {
|
|
1431
2422
|
if (0 < channel.state.modulationDepth) {
|
|
1432
2423
|
this.setDelayModLFO(note, scheduleTime);
|
|
1433
2424
|
}
|
|
@@ -1453,6 +2444,8 @@ class MidyGM1 extends EventTarget {
|
|
|
1453
2444
|
}
|
|
1454
2445
|
applyVoiceParams(channel, controllerType, scheduleTime) {
|
|
1455
2446
|
this.processScheduledNotes(channel, (note) => {
|
|
2447
|
+
if (note.renderedBuffer?.isFull)
|
|
2448
|
+
return;
|
|
1456
2449
|
const controllerState = this.getControllerState(channel, note.noteNumber, note.velocity);
|
|
1457
2450
|
const voiceParams = note.voice.getParams(controllerType, controllerState);
|
|
1458
2451
|
let applyVolumeEnvelope = false;
|
|
@@ -1516,6 +2509,8 @@ class MidyGM1 extends EventTarget {
|
|
|
1516
2509
|
const depth = channel.state.modulationDepthMSB *
|
|
1517
2510
|
channel.modulationDepthRange;
|
|
1518
2511
|
this.processScheduledNotes(channel, (note) => {
|
|
2512
|
+
if (note.renderedBuffer?.isFull)
|
|
2513
|
+
return;
|
|
1519
2514
|
if (note.modLfoToPitch) {
|
|
1520
2515
|
note.modLfoToPitch.gain.setValueAtTime(depth, scheduleTime);
|
|
1521
2516
|
}
|
|
@@ -1578,11 +2573,15 @@ class MidyGM1 extends EventTarget {
|
|
|
1578
2573
|
const channel = this.channels[channelNumber];
|
|
1579
2574
|
if (!(0 <= scheduleTime))
|
|
1580
2575
|
scheduleTime = this.audioContext.currentTime;
|
|
1581
|
-
|
|
2576
|
+
const state = channel.state;
|
|
2577
|
+
const prevValue = state.sustainPedal;
|
|
2578
|
+
state.sustainPedal = value / 127;
|
|
1582
2579
|
if (64 <= value) {
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
2580
|
+
if (prevValue < 0.5) {
|
|
2581
|
+
this.processScheduledNotes(channel, (note) => {
|
|
2582
|
+
channel.sustainNotes.push(note);
|
|
2583
|
+
});
|
|
2584
|
+
}
|
|
1586
2585
|
}
|
|
1587
2586
|
else {
|
|
1588
2587
|
this.releaseSustainPedal(channelNumber, value, scheduleTime);
|
|
@@ -1800,9 +2799,10 @@ class MidyGM1 extends EventTarget {
|
|
|
1800
2799
|
setMasterVolume(value, scheduleTime) {
|
|
1801
2800
|
if (!(0 <= scheduleTime))
|
|
1802
2801
|
scheduleTime = this.audioContext.currentTime;
|
|
2802
|
+
const timeConstant = this.perceptualSmoothingTime / 5; // 99.3% (5 * tau)
|
|
1803
2803
|
this.masterVolume.gain
|
|
1804
|
-
.
|
|
1805
|
-
.
|
|
2804
|
+
.cancelAndHoldAtTime(scheduleTime)
|
|
2805
|
+
.setTargetAtTime(value * value, scheduleTime, timeConstant);
|
|
1806
2806
|
}
|
|
1807
2807
|
handleSysEx(data, scheduleTime) {
|
|
1808
2808
|
switch (data[0]) {
|