@marmooo/midy 0.4.8 → 0.5.0

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.
@@ -4,6 +4,55 @@ exports.MidyGM2 = 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,
@@ -125,6 +192,166 @@ class Note {
125
192
  });
126
193
  }
127
194
  }
195
+ class Channel {
196
+ constructor(audioNodes, settings) {
197
+ Object.defineProperty(this, "isDrum", {
198
+ enumerable: true,
199
+ configurable: true,
200
+ writable: true,
201
+ value: false
202
+ });
203
+ Object.defineProperty(this, "programNumber", {
204
+ enumerable: true,
205
+ configurable: true,
206
+ writable: true,
207
+ value: 0
208
+ });
209
+ Object.defineProperty(this, "scheduleIndex", {
210
+ enumerable: true,
211
+ configurable: true,
212
+ writable: true,
213
+ value: 0
214
+ });
215
+ Object.defineProperty(this, "detune", {
216
+ enumerable: true,
217
+ configurable: true,
218
+ writable: true,
219
+ value: 0
220
+ });
221
+ Object.defineProperty(this, "bankMSB", {
222
+ enumerable: true,
223
+ configurable: true,
224
+ writable: true,
225
+ value: 121
226
+ });
227
+ Object.defineProperty(this, "bankLSB", {
228
+ enumerable: true,
229
+ configurable: true,
230
+ writable: true,
231
+ value: 0
232
+ });
233
+ Object.defineProperty(this, "dataMSB", {
234
+ enumerable: true,
235
+ configurable: true,
236
+ writable: true,
237
+ value: 0
238
+ });
239
+ Object.defineProperty(this, "dataLSB", {
240
+ enumerable: true,
241
+ configurable: true,
242
+ writable: true,
243
+ value: 0
244
+ });
245
+ Object.defineProperty(this, "rpnMSB", {
246
+ enumerable: true,
247
+ configurable: true,
248
+ writable: true,
249
+ value: 127
250
+ });
251
+ Object.defineProperty(this, "rpnLSB", {
252
+ enumerable: true,
253
+ configurable: true,
254
+ writable: true,
255
+ value: 127
256
+ });
257
+ Object.defineProperty(this, "mono", {
258
+ enumerable: true,
259
+ configurable: true,
260
+ writable: true,
261
+ value: false
262
+ }); // CC#124, CC#125
263
+ Object.defineProperty(this, "modulationDepthRange", {
264
+ enumerable: true,
265
+ configurable: true,
266
+ writable: true,
267
+ value: 50
268
+ }); // cent
269
+ Object.defineProperty(this, "fineTuning", {
270
+ enumerable: true,
271
+ configurable: true,
272
+ writable: true,
273
+ value: 0
274
+ }); // cent
275
+ Object.defineProperty(this, "coarseTuning", {
276
+ enumerable: true,
277
+ configurable: true,
278
+ writable: true,
279
+ value: 0
280
+ }); // cent
281
+ Object.defineProperty(this, "scheduledNotes", {
282
+ enumerable: true,
283
+ configurable: true,
284
+ writable: true,
285
+ value: []
286
+ });
287
+ Object.defineProperty(this, "sustainNotes", {
288
+ enumerable: true,
289
+ configurable: true,
290
+ writable: true,
291
+ value: []
292
+ });
293
+ Object.defineProperty(this, "sostenutoNotes", {
294
+ enumerable: true,
295
+ configurable: true,
296
+ writable: true,
297
+ value: []
298
+ });
299
+ Object.defineProperty(this, "controlTable", {
300
+ enumerable: true,
301
+ configurable: true,
302
+ writable: true,
303
+ value: new Int8Array(defaultControlValues)
304
+ });
305
+ Object.defineProperty(this, "scaleOctaveTuningTable", {
306
+ enumerable: true,
307
+ configurable: true,
308
+ writable: true,
309
+ value: new Int8Array(12)
310
+ }); // [-64, 63] cent
311
+ Object.defineProperty(this, "channelPressureTable", {
312
+ enumerable: true,
313
+ configurable: true,
314
+ writable: true,
315
+ value: new Int8Array(defaultPressureValues)
316
+ });
317
+ Object.defineProperty(this, "keyBasedTable", {
318
+ enumerable: true,
319
+ configurable: true,
320
+ writable: true,
321
+ value: new Int8Array(128 * 128).fill(-1)
322
+ });
323
+ Object.defineProperty(this, "keyBasedGainLs", {
324
+ enumerable: true,
325
+ configurable: true,
326
+ writable: true,
327
+ value: new Array(128)
328
+ });
329
+ Object.defineProperty(this, "keyBasedGainRs", {
330
+ enumerable: true,
331
+ configurable: true,
332
+ writable: true,
333
+ value: new Array(128)
334
+ });
335
+ Object.defineProperty(this, "currentBufferSource", {
336
+ enumerable: true,
337
+ configurable: true,
338
+ writable: true,
339
+ value: null
340
+ });
341
+ Object.assign(this, audioNodes);
342
+ Object.assign(this, settings);
343
+ this.state = new ControllerState();
344
+ }
345
+ resetSettings(settings) {
346
+ Object.assign(this, settings);
347
+ }
348
+ resetTable() {
349
+ this.controlTable.set(defaultControlValues);
350
+ this.scaleOctaveTuningTable.fill(0); // [-100, 100] cent
351
+ this.channelPressureTable.set(defaultPressureValues);
352
+ this.keyBasedTable.fill(-1);
353
+ }
354
+ }
128
355
  const drumExclusiveClassesByKit = new Array(57);
129
356
  const drumExclusiveClassCount = 10;
130
357
  const standardSet = new Uint8Array(128);
@@ -258,13 +485,73 @@ const defaultControlValues = new Int8Array([
258
485
  ...[-1, -1, -1, -1, -1, -1],
259
486
  ...defaultPressureValues,
260
487
  ]);
488
+ class RenderedBuffer {
489
+ constructor(buffer, meta = {}) {
490
+ Object.defineProperty(this, "buffer", {
491
+ enumerable: true,
492
+ configurable: true,
493
+ writable: true,
494
+ value: void 0
495
+ });
496
+ Object.defineProperty(this, "isLoop", {
497
+ enumerable: true,
498
+ configurable: true,
499
+ writable: true,
500
+ value: void 0
501
+ });
502
+ Object.defineProperty(this, "isFull", {
503
+ enumerable: true,
504
+ configurable: true,
505
+ writable: true,
506
+ value: void 0
507
+ });
508
+ Object.defineProperty(this, "adsDuration", {
509
+ enumerable: true,
510
+ configurable: true,
511
+ writable: true,
512
+ value: void 0
513
+ });
514
+ Object.defineProperty(this, "loopStart", {
515
+ enumerable: true,
516
+ configurable: true,
517
+ writable: true,
518
+ value: void 0
519
+ });
520
+ Object.defineProperty(this, "loopDuration", {
521
+ enumerable: true,
522
+ configurable: true,
523
+ writable: true,
524
+ value: void 0
525
+ });
526
+ Object.defineProperty(this, "noteDuration", {
527
+ enumerable: true,
528
+ configurable: true,
529
+ writable: true,
530
+ value: void 0
531
+ });
532
+ Object.defineProperty(this, "releaseDuration", {
533
+ enumerable: true,
534
+ configurable: true,
535
+ writable: true,
536
+ value: void 0
537
+ });
538
+ this.buffer = buffer;
539
+ this.isLoop = meta.isLoop ?? false;
540
+ this.isFull = meta.isFull ?? false;
541
+ this.adsDuration = meta.adsDuration;
542
+ this.loopStart = meta.loopStart;
543
+ this.loopDuration = meta.loopDuration;
544
+ this.noteDuration = meta.noteDuration;
545
+ this.releaseDuration = meta.releaseDuration;
546
+ }
547
+ }
261
548
  function cbToRatio(cb) {
262
549
  return Math.pow(10, cb / 200);
263
550
  }
264
551
  const decayCurve = 1 / (-Math.log(cbToRatio(-1000)));
265
552
  const releaseCurve = 1 / (-Math.log(cbToRatio(-600)));
266
553
  class MidyGM2 extends EventTarget {
267
- constructor(audioContext) {
554
+ constructor(audioContext, options = {}) {
268
555
  super();
269
556
  // https://pmc.ncbi.nlm.nih.gov/articles/PMC4191557/
270
557
  // https://pubmed.ncbi.nlm.nih.gov/12488797/
@@ -446,9 +733,7 @@ class MidyGM2 extends EventTarget {
446
733
  enumerable: true,
447
734
  configurable: true,
448
735
  writable: true,
449
- value: new Set([
450
- "noteOff",
451
- ])
736
+ value: new Set(["noteOff"])
452
737
  });
453
738
  Object.defineProperty(this, "tempo", {
454
739
  enumerable: true,
@@ -498,7 +783,53 @@ class MidyGM2 extends EventTarget {
498
783
  writable: true,
499
784
  value: new Array(this.numChannels * drumExclusiveClassCount)
500
785
  });
786
+ // "adsr" mode
787
+ Object.defineProperty(this, "adsrVoiceCache", {
788
+ enumerable: true,
789
+ configurable: true,
790
+ writable: true,
791
+ value: new Map()
792
+ });
793
+ // "note" mode
794
+ Object.defineProperty(this, "noteOnDurations", {
795
+ enumerable: true,
796
+ configurable: true,
797
+ writable: true,
798
+ value: new Map()
799
+ });
800
+ Object.defineProperty(this, "noteOnEvents", {
801
+ enumerable: true,
802
+ configurable: true,
803
+ writable: true,
804
+ value: new Map()
805
+ });
806
+ Object.defineProperty(this, "fullVoiceCache", {
807
+ enumerable: true,
808
+ configurable: true,
809
+ writable: true,
810
+ value: new Map()
811
+ });
812
+ // "audio" mode
813
+ Object.defineProperty(this, "renderedAudioBuffer", {
814
+ enumerable: true,
815
+ configurable: true,
816
+ writable: true,
817
+ value: null
818
+ });
819
+ Object.defineProperty(this, "isRendering", {
820
+ enumerable: true,
821
+ configurable: true,
822
+ writable: true,
823
+ value: false
824
+ });
825
+ Object.defineProperty(this, "audioModeBufferSource", {
826
+ enumerable: true,
827
+ configurable: true,
828
+ writable: true,
829
+ value: null
830
+ });
501
831
  this.audioContext = audioContext;
832
+ this.cacheMode = options.cacheMode ?? DEFAULT_CACHE_MODE;
502
833
  this.masterVolume = new GainNode(audioContext);
503
834
  this.scheduler = new GainNode(audioContext, { gain: 0 });
504
835
  this.schedulerBuffer = new AudioBuffer({
@@ -574,9 +905,177 @@ class MidyGM2 extends EventTarget {
574
905
  this.instruments = midiData.instruments;
575
906
  this.timeline = midiData.timeline;
576
907
  this.totalTime = this.calcTotalTime();
908
+ if (this.cacheMode === "audio") {
909
+ await this.render();
910
+ }
911
+ }
912
+ buildNoteOnDurations() {
913
+ const { timeline, totalTime, noteOnDurations, noteOnEvents, numChannels } = this;
914
+ noteOnDurations.clear();
915
+ noteOnEvents.clear();
916
+ const inverseTempo = 1 / this.tempo;
917
+ const sustainPedal = new Uint8Array(numChannels);
918
+ const sostenutoPedal = new Uint8Array(numChannels);
919
+ const sostenutoKeys = new Array(numChannels).fill(null).map(() => new Set());
920
+ const activeNotes = new Map();
921
+ const pendingOff = new Map();
922
+ const finalizeEntry = (entry, endTime, endTicks) => {
923
+ const duration = Math.max(0, endTime - entry.startTime);
924
+ const durationTicks = (endTicks == null || endTicks === Infinity)
925
+ ? Infinity
926
+ : Math.max(0, endTicks - entry.startTicks);
927
+ noteOnDurations.set(entry.idx, duration);
928
+ noteOnEvents.set(entry.idx, {
929
+ duration,
930
+ durationTicks,
931
+ startTime: entry.startTime,
932
+ events: entry.events,
933
+ });
934
+ };
935
+ for (let i = 0; i < timeline.length; i++) {
936
+ const event = timeline[i];
937
+ const t = event.startTime * inverseTempo;
938
+ switch (event.type) {
939
+ case "noteOn": {
940
+ const key = event.noteNumber * numChannels + event.channel;
941
+ if (!activeNotes.has(key))
942
+ activeNotes.set(key, []);
943
+ activeNotes.get(key).push({
944
+ idx: i,
945
+ startTime: t,
946
+ startTicks: event.ticks,
947
+ events: [],
948
+ });
949
+ const pendingStack = pendingOff.get(key);
950
+ if (pendingStack && pendingStack.length > 0)
951
+ pendingStack.shift();
952
+ break;
953
+ }
954
+ case "noteOff": {
955
+ const ch = event.channel;
956
+ const key = event.noteNumber * numChannels + ch;
957
+ const isSostenuto = sostenutoKeys[ch].has(key);
958
+ if (sustainPedal[ch] || isSostenuto) {
959
+ if (!pendingOff.has(key))
960
+ pendingOff.set(key, []);
961
+ pendingOff.get(key).push({ t, ticks: event.ticks });
962
+ }
963
+ else {
964
+ const stack = activeNotes.get(key);
965
+ if (stack && stack.length > 0) {
966
+ finalizeEntry(stack.shift(), t, event.ticks);
967
+ if (stack.length === 0)
968
+ activeNotes.delete(key);
969
+ }
970
+ }
971
+ break;
972
+ }
973
+ case "controller": {
974
+ const ch = event.channel;
975
+ for (const [key, entries] of activeNotes) {
976
+ if (key % numChannels !== ch)
977
+ continue;
978
+ for (const entry of entries)
979
+ entry.events.push(event);
980
+ }
981
+ switch (event.controllerType) {
982
+ case 64: { // Sustain Pedal
983
+ const on = event.value >= 64;
984
+ sustainPedal[ch] = on ? 1 : 0;
985
+ if (!on) {
986
+ for (const [key, offItems] of pendingOff) {
987
+ if (key % numChannels !== ch)
988
+ continue;
989
+ const activeStack = activeNotes.get(key);
990
+ for (const { t: offTime, ticks: offTicks } of offItems) {
991
+ if (activeStack && activeStack.length > 0) {
992
+ finalizeEntry(activeStack.shift(), offTime, offTicks);
993
+ if (activeStack.length === 0)
994
+ activeNotes.delete(key);
995
+ }
996
+ }
997
+ pendingOff.delete(key);
998
+ }
999
+ }
1000
+ break;
1001
+ }
1002
+ case 66: { // Sostenuto Pedal
1003
+ const on = event.value >= 64;
1004
+ if (on && !sostenutoPedal[ch]) {
1005
+ for (const [key] of activeNotes) {
1006
+ if (key % numChannels === ch)
1007
+ sostenutoKeys[ch].add(key);
1008
+ }
1009
+ }
1010
+ else if (!on) {
1011
+ sostenutoKeys[ch].clear();
1012
+ }
1013
+ sostenutoPedal[ch] = on ? 1 : 0;
1014
+ break;
1015
+ }
1016
+ case 121: // Reset All Controllers
1017
+ sustainPedal[ch] = 0;
1018
+ sostenutoPedal[ch] = 0;
1019
+ sostenutoKeys[ch].clear();
1020
+ break;
1021
+ case 120: // All Sound Off
1022
+ case 123: { // All Notes Off
1023
+ for (const [key, stack] of activeNotes) {
1024
+ if (key % numChannels !== ch)
1025
+ continue;
1026
+ for (const entry of stack)
1027
+ finalizeEntry(entry, t, event.ticks);
1028
+ activeNotes.delete(key);
1029
+ }
1030
+ for (const key of pendingOff.keys()) {
1031
+ if (key % numChannels === ch)
1032
+ pendingOff.delete(key);
1033
+ }
1034
+ break;
1035
+ }
1036
+ }
1037
+ break;
1038
+ }
1039
+ case "sysEx":
1040
+ if (event.data[0] === 126 && event.data[1] === 9 && event.data[2] === 3) {
1041
+ // GM1 System On / GM2 System On
1042
+ if (event.data[3] === 1 || event.data[3] === 3) {
1043
+ sustainPedal.fill(0);
1044
+ pendingOff.clear();
1045
+ for (const [, stack] of activeNotes) {
1046
+ for (const entry of stack)
1047
+ finalizeEntry(entry, t, event.ticks);
1048
+ }
1049
+ activeNotes.clear();
1050
+ }
1051
+ }
1052
+ else {
1053
+ for (const [, entries] of activeNotes) {
1054
+ for (const entry of entries)
1055
+ entry.events.push(event);
1056
+ }
1057
+ }
1058
+ break;
1059
+ case "pitchBend":
1060
+ case "programChange":
1061
+ case "channelAftertouch": {
1062
+ const ch = event.channel;
1063
+ for (const [key, entries] of activeNotes) {
1064
+ if (key % numChannels !== ch)
1065
+ continue;
1066
+ for (const entry of entries)
1067
+ entry.events.push(event);
1068
+ }
1069
+ }
1070
+ }
1071
+ }
1072
+ for (const [, stack] of activeNotes) {
1073
+ for (const entry of stack)
1074
+ finalizeEntry(entry, totalTime, Infinity);
1075
+ }
577
1076
  }
578
1077
  cacheVoiceIds() {
579
- const { channels, timeline, voiceCounter } = this;
1078
+ const { channels, timeline, voiceCounter, cacheMode } = this;
580
1079
  for (let i = 0; i < timeline.length; i++) {
581
1080
  const event = timeline[i];
582
1081
  switch (event.type) {
@@ -602,6 +1101,9 @@ class MidyGM2 extends EventTarget {
602
1101
  voiceCounter.delete(audioBufferId);
603
1102
  }
604
1103
  this.GM2SystemOn();
1104
+ if (cacheMode === "adsr" || cacheMode === "note" || cacheMode === "audio") {
1105
+ this.buildNoteOnDurations();
1106
+ }
605
1107
  }
606
1108
  getVoiceId(channel, noteNumber, velocity) {
607
1109
  const programNumber = channel.programNumber;
@@ -620,7 +1122,8 @@ class MidyGM2 extends EventTarget {
620
1122
  const soundFont = this.soundFonts[soundFontIndex];
621
1123
  const voice = soundFont.getVoice(bank, programNumber, noteNumber, velocity);
622
1124
  const { instrument, sampleID } = voice.generators;
623
- return soundFontIndex * (2 ** 32) + (instrument << 16) + sampleID;
1125
+ return soundFontIndex * (2 ** 31) + instrument * (2 ** 24) +
1126
+ (sampleID << 8);
624
1127
  }
625
1128
  createChannelAudioNodes(audioContext) {
626
1129
  const { gainLeft, gainRight } = this.panToGain(defaultControllerState.panMSB.defaultValue);
@@ -630,38 +1133,11 @@ class MidyGM2 extends EventTarget {
630
1133
  gainL.connect(merger, 0, 0);
631
1134
  gainR.connect(merger, 0, 1);
632
1135
  merger.connect(this.masterVolume);
633
- return {
634
- gainL,
635
- gainR,
636
- merger,
637
- };
638
- }
639
- resetChannelTable(channel) {
640
- channel.controlTable.set(defaultControlValues);
641
- channel.scaleOctaveTuningTable.fill(0); // [-100, 100] cent
642
- channel.channelPressureTable.set(defaultPressureValues);
643
- channel.keyBasedTable.fill(-1);
1136
+ return { gainL, gainR, merger };
644
1137
  }
645
1138
  createChannels(audioContext) {
646
- const channels = Array.from({ length: this.numChannels }, () => {
647
- return {
648
- currentBufferSource: null,
649
- isDrum: false,
650
- state: new ControllerState(),
651
- ...this.constructor.channelSettings,
652
- ...this.createChannelAudioNodes(audioContext),
653
- scheduledNotes: [],
654
- sustainNotes: [],
655
- sostenutoNotes: [],
656
- controlTable: new Int8Array(defaultControlValues),
657
- scaleOctaveTuningTable: new Int8Array(12), // [-64, 63] cent
658
- channelPressureTable: new Int8Array(defaultPressureValues),
659
- keyBasedTable: new Int8Array(128 * 128).fill(-1),
660
- keyBasedGainLs: new Array(128),
661
- keyBasedGainRs: new Array(128),
662
- };
663
- });
664
- return channels;
1139
+ const settings = this.constructor.channelSettings;
1140
+ return Array.from({ length: this.numChannels }, () => new Channel(this.createChannelAudioNodes(audioContext), settings));
665
1141
  }
666
1142
  decodeOggVorbis(sample) {
667
1143
  const task = decoderQueue.then(async () => {
@@ -720,15 +1196,26 @@ class MidyGM2 extends EventTarget {
720
1196
  return ((programNumber === 48 && noteNumber === 88) ||
721
1197
  (programNumber === 56 && 47 <= noteNumber && noteNumber <= 84));
722
1198
  }
723
- createBufferSource(channel, noteNumber, voiceParams, audioBuffer) {
1199
+ createBufferSource(channel, noteNumber, voiceParams, renderedOrRaw) {
1200
+ const isRendered = renderedOrRaw instanceof RenderedBuffer;
1201
+ const audioBuffer = isRendered ? renderedOrRaw.buffer : renderedOrRaw;
724
1202
  const bufferSource = new AudioBufferSourceNode(this.audioContext);
725
1203
  bufferSource.buffer = audioBuffer;
726
- bufferSource.loop = channel.isDrum
1204
+ const isDrumLoop = channel.isDrum
727
1205
  ? this.isLoopDrum(channel, noteNumber)
728
- : (voiceParams.sampleModes % 2 !== 0);
1206
+ : voiceParams.sampleModes % 2 !== 0;
1207
+ const isLoop = isRendered ? renderedOrRaw.isLoop : isDrumLoop;
1208
+ bufferSource.loop = isLoop;
729
1209
  if (bufferSource.loop) {
730
- bufferSource.loopStart = voiceParams.loopStart / voiceParams.sampleRate;
731
- bufferSource.loopEnd = voiceParams.loopEnd / voiceParams.sampleRate;
1210
+ if (isRendered && renderedOrRaw.adsDuration != null) {
1211
+ bufferSource.loopStart = renderedOrRaw.loopStart;
1212
+ bufferSource.loopEnd = renderedOrRaw.loopStart +
1213
+ renderedOrRaw.loopDuration;
1214
+ }
1215
+ else {
1216
+ bufferSource.loopStart = voiceParams.loopStart / voiceParams.sampleRate;
1217
+ bufferSource.loopEnd = voiceParams.loopEnd / voiceParams.sampleRate;
1218
+ }
732
1219
  }
733
1220
  return bufferSource;
734
1221
  }
@@ -745,27 +1232,29 @@ class MidyGM2 extends EventTarget {
745
1232
  break;
746
1233
  const startTime = t + schedulingOffset;
747
1234
  switch (event.type) {
748
- case "noteOn":
749
- this.noteOn(event.channel, event.noteNumber, event.velocity, startTime);
1235
+ case "noteOn": {
1236
+ const note = this.createNote(event.channel, event.noteNumber, event.velocity, startTime);
1237
+ note.timelineIndex = queueIndex;
1238
+ this.setupNote(event.channel, note, startTime);
750
1239
  break;
751
- case "noteOff": {
1240
+ }
1241
+ case "noteOff":
752
1242
  this.noteOff(event.channel, event.noteNumber, event.velocity, startTime, false);
753
1243
  break;
754
- }
755
1244
  case "controller":
756
1245
  this.setControlChange(event.channel, event.controllerType, event.value, startTime);
757
1246
  break;
758
1247
  case "programChange":
759
1248
  this.setProgramChange(event.channel, event.programNumber, startTime);
760
1249
  break;
761
- case "channelAftertouch":
762
- this.setChannelPressure(event.channel, event.amount, startTime);
763
- break;
764
1250
  case "pitchBend":
765
1251
  this.setPitchBend(event.channel, event.value + 8192, startTime);
766
1252
  break;
767
1253
  case "sysEx":
768
1254
  this.handleSysEx(event.data, startTime);
1255
+ break;
1256
+ case "channelAftertouch":
1257
+ this.setChannelPressure(event.channel, event.amount, startTime);
769
1258
  }
770
1259
  queueIndex++;
771
1260
  }
@@ -786,6 +1275,7 @@ class MidyGM2 extends EventTarget {
786
1275
  this.drumExclusiveClassNotes.fill(undefined);
787
1276
  this.voiceCache.clear();
788
1277
  this.realtimeVoiceCache.clear();
1278
+ this.adsrVoiceCache.clear();
789
1279
  const channels = this.channels;
790
1280
  for (let ch = 0; ch < channels.length; ch++) {
791
1281
  channels[ch].scheduledNotes = [];
@@ -812,14 +1302,101 @@ class MidyGM2 extends EventTarget {
812
1302
  break;
813
1303
  case "sysEx":
814
1304
  this.handleSysEx(event.data, now - resumeTime + event.startTime * inverseTempo);
1305
+ break;
1306
+ case "channelAftertouch":
1307
+ this.setChannelPressure(event.channel, event.amount, now - resumeTime + event.startTime * inverseTempo);
815
1308
  }
816
1309
  }
817
1310
  }
1311
+ async playAudioBuffer() {
1312
+ const audioContext = this.audioContext;
1313
+ const paused = this.isPaused;
1314
+ this.isPlaying = true;
1315
+ this.isPaused = false;
1316
+ this.startTime = audioContext.currentTime;
1317
+ if (paused) {
1318
+ this.dispatchEvent(new Event("resumed"));
1319
+ }
1320
+ else {
1321
+ this.dispatchEvent(new Event("started"));
1322
+ }
1323
+ let exitReason;
1324
+ outer: while (true) {
1325
+ const buffer = this.renderedAudioBuffer;
1326
+ const bufferSource = new AudioBufferSourceNode(audioContext, { buffer });
1327
+ bufferSource.playbackRate.value = this.tempo;
1328
+ bufferSource.connect(this.masterVolume);
1329
+ const offset = Math.min(Math.max(this.resumeTime, 0), buffer.duration);
1330
+ bufferSource.start(audioContext.currentTime, offset);
1331
+ this.audioModeBufferSource = bufferSource;
1332
+ let naturalEnded = false;
1333
+ bufferSource.onended = () => {
1334
+ naturalEnded = true;
1335
+ };
1336
+ while (true) {
1337
+ const now = audioContext.currentTime;
1338
+ await this.scheduleTask(() => { }, now + this.noteCheckInterval);
1339
+ if (naturalEnded || this.currentTime() >= this.totalTime) {
1340
+ bufferSource.disconnect();
1341
+ this.audioModeBufferSource = null;
1342
+ if (this.loop) {
1343
+ this.resumeTime = 0;
1344
+ this.startTime = audioContext.currentTime;
1345
+ this.dispatchEvent(new Event("looped"));
1346
+ continue outer;
1347
+ }
1348
+ await audioContext.suspend();
1349
+ exitReason = "ended";
1350
+ break outer;
1351
+ }
1352
+ if (this.isPausing) {
1353
+ this.resumeTime = this.currentTime();
1354
+ bufferSource.stop();
1355
+ bufferSource.disconnect();
1356
+ this.audioModeBufferSource = null;
1357
+ await audioContext.suspend();
1358
+ this.isPausing = false;
1359
+ exitReason = "paused";
1360
+ break outer;
1361
+ }
1362
+ else if (this.isStopping) {
1363
+ bufferSource.stop();
1364
+ bufferSource.disconnect();
1365
+ this.audioModeBufferSource = null;
1366
+ await audioContext.suspend();
1367
+ this.isStopping = false;
1368
+ exitReason = "stopped";
1369
+ break outer;
1370
+ }
1371
+ else if (this.isSeeking) {
1372
+ bufferSource.stop();
1373
+ bufferSource.disconnect();
1374
+ this.audioModeBufferSource = null;
1375
+ this.startTime = audioContext.currentTime;
1376
+ this.isSeeking = false;
1377
+ this.dispatchEvent(new Event("seeked"));
1378
+ continue outer;
1379
+ }
1380
+ }
1381
+ }
1382
+ this.isPlaying = false;
1383
+ if (exitReason === "paused") {
1384
+ this.isPaused = true;
1385
+ this.dispatchEvent(new Event("paused"));
1386
+ }
1387
+ else if (exitReason !== undefined) {
1388
+ this.isPaused = false;
1389
+ this.dispatchEvent(new Event(exitReason));
1390
+ }
1391
+ }
818
1392
  async playNotes() {
819
1393
  const audioContext = this.audioContext;
820
1394
  if (audioContext.state === "suspended") {
821
1395
  await audioContext.resume();
822
1396
  }
1397
+ if (this.cacheMode === "audio" && this.renderedAudioBuffer) {
1398
+ return await this.playAudioBuffer();
1399
+ }
823
1400
  const paused = this.isPaused;
824
1401
  this.isPlaying = true;
825
1402
  this.isPaused = false;
@@ -952,12 +1529,12 @@ class MidyGM2 extends EventTarget {
952
1529
  if (data[0] === 126 && data[1] === 9 && data[2] === 3) {
953
1530
  switch (data[3]) {
954
1531
  case 1:
955
- this.GM1SystemOn(scheduleTime);
1532
+ this.GM1SystemOn();
956
1533
  break;
957
1534
  case 2: // GM System Off
958
1535
  break;
959
1536
  case 3:
960
- this.GM2SystemOn(scheduleTime);
1537
+ this.GM2SystemOn();
961
1538
  break;
962
1539
  default:
963
1540
  console.warn(`Unsupported Exclusive Message: ${data}`);
@@ -1024,6 +1601,186 @@ class MidyGM2 extends EventTarget {
1024
1601
  this.notePromises = [];
1025
1602
  return stopPromise;
1026
1603
  }
1604
+ async render() {
1605
+ if (this.isRendering)
1606
+ return;
1607
+ if (this.timeline.length === 0)
1608
+ return;
1609
+ if (this.voiceCounter.size === 0)
1610
+ this.cacheVoiceIds();
1611
+ this.isRendering = true;
1612
+ this.renderedAudioBuffer = null;
1613
+ this.dispatchEvent(new Event("rendering"));
1614
+ const sampleRate = this.audioContext.sampleRate;
1615
+ const totalSamples = Math.ceil((this.totalTime + this.startDelay) * sampleRate);
1616
+ const renderBankMSB = new Uint8Array(this.numChannels);
1617
+ const renderBankLSB = new Uint8Array(this.numChannels);
1618
+ const renderProgramNumber = new Uint8Array(this.numChannels);
1619
+ const renderIsDrum = new Uint8Array(this.numChannels);
1620
+ renderBankMSB.fill(121);
1621
+ renderIsDrum[9] = 1;
1622
+ const renderControllerStates = Array.from({ length: this.numChannels }, () => {
1623
+ const state = new Float32Array(256);
1624
+ for (const { type, defaultValue } of Object.values(defaultControllerState)) {
1625
+ state[type] = defaultValue;
1626
+ }
1627
+ return state;
1628
+ });
1629
+ const tasks = [];
1630
+ const timeline = this.timeline;
1631
+ const inverseTempo = 1 / this.tempo;
1632
+ for (let i = 0; i < timeline.length; i++) {
1633
+ const event = timeline[i];
1634
+ const ch = event.channel;
1635
+ switch (event.type) {
1636
+ case "noteOn": {
1637
+ const noteEvent = this.noteOnEvents.get(i);
1638
+ const noteDuration = noteEvent?.duration ??
1639
+ this.noteOnDurations.get(i) ??
1640
+ 0;
1641
+ if (noteDuration <= 0)
1642
+ continue;
1643
+ const { noteNumber, velocity } = event;
1644
+ const isDrum = renderIsDrum[ch] === 1;
1645
+ const programNumber = renderProgramNumber[ch];
1646
+ const bankTable = this.soundFontTable[programNumber];
1647
+ if (!bankTable)
1648
+ continue;
1649
+ let bank = isDrum ? 128 : renderBankLSB[ch];
1650
+ if (bankTable[bank] === undefined) {
1651
+ if (isDrum)
1652
+ continue;
1653
+ bank = 0;
1654
+ }
1655
+ const soundFontIndex = bankTable[bank];
1656
+ if (soundFontIndex === undefined)
1657
+ continue;
1658
+ const soundFont = this.soundFonts[soundFontIndex];
1659
+ const fakeChannel = {
1660
+ state: { array: renderControllerStates[ch].slice() },
1661
+ programNumber,
1662
+ isDrum,
1663
+ modulationDepthRange: 50,
1664
+ detune: 0,
1665
+ };
1666
+ const controllerState = this.getControllerState(fakeChannel, noteNumber, velocity);
1667
+ const voice = soundFont.getVoice(bank, programNumber, noteNumber, velocity);
1668
+ if (!voice)
1669
+ continue;
1670
+ const voiceParams = voice.getAllParams(controllerState);
1671
+ const t = event.startTime * inverseTempo + this.startDelay;
1672
+ const fakeNote = { voiceParams, channel: ch, noteNumber, velocity };
1673
+ const promise = (async () => {
1674
+ try {
1675
+ return await this.createFullRenderedBuffer(fakeChannel, fakeNote, voiceParams, noteDuration, noteEvent);
1676
+ }
1677
+ catch (err) {
1678
+ console.warn("render: note render failed", err);
1679
+ return null;
1680
+ }
1681
+ })();
1682
+ tasks.push({ t, promise, fakeChannel });
1683
+ break;
1684
+ }
1685
+ case "controller": {
1686
+ const { controllerType, value } = event;
1687
+ switch (controllerType) {
1688
+ case 0: // bankMSB
1689
+ renderBankMSB[ch] = value;
1690
+ if (this.mode === "GM2") {
1691
+ if (value === 120) {
1692
+ renderIsDrum[ch] = 1;
1693
+ }
1694
+ else if (value === 121) {
1695
+ renderIsDrum[ch] = 0;
1696
+ }
1697
+ }
1698
+ break;
1699
+ case 32: // bankLSB
1700
+ renderBankLSB[ch] = value;
1701
+ break;
1702
+ default: {
1703
+ const stateIndex = 128 + controllerType;
1704
+ if (stateIndex < 256) {
1705
+ renderControllerStates[ch][stateIndex] = value / 127;
1706
+ }
1707
+ break;
1708
+ }
1709
+ }
1710
+ break;
1711
+ }
1712
+ case "pitchBend":
1713
+ renderControllerStates[ch][14] = (event.value + 8192) / 16383;
1714
+ break;
1715
+ case "programChange":
1716
+ renderProgramNumber[ch] = event.programNumber;
1717
+ if (this.mode === "GM2") {
1718
+ if (renderBankMSB[ch] === 120) {
1719
+ renderIsDrum[ch] = 1;
1720
+ }
1721
+ else if (renderBankMSB[ch] === 121) {
1722
+ renderIsDrum[ch] = 0;
1723
+ }
1724
+ }
1725
+ break;
1726
+ case "sysEx": {
1727
+ const data = event.data;
1728
+ if (data[0] === 126 && data[1] === 9 && data[2] === 3) {
1729
+ if (data[3] === 1) { // GM1 System On
1730
+ renderBankMSB.fill(0);
1731
+ renderBankLSB.fill(0);
1732
+ renderProgramNumber.fill(0);
1733
+ renderIsDrum.fill(0);
1734
+ renderIsDrum[9] = 1;
1735
+ renderBankMSB[9] = 1;
1736
+ for (let c = 0; c < this.numChannels; c++) {
1737
+ for (const { type, defaultValue } of Object.values(defaultControllerState)) {
1738
+ renderControllerStates[c][type] = defaultValue;
1739
+ }
1740
+ }
1741
+ renderNoteAftertouch.fill(0);
1742
+ }
1743
+ else if (data[3] === 3) { // GM2 System On
1744
+ renderBankMSB.fill(121);
1745
+ renderBankLSB.fill(0);
1746
+ renderProgramNumber.fill(0);
1747
+ renderIsDrum.fill(0);
1748
+ renderIsDrum[9] = 1;
1749
+ renderBankMSB[9] = 120;
1750
+ for (let c = 0; c < this.numChannels; c++) {
1751
+ for (const { type, defaultValue } of Object.values(defaultControllerState)) {
1752
+ renderControllerStates[c][type] = defaultValue;
1753
+ }
1754
+ }
1755
+ renderNoteAftertouch.fill(0);
1756
+ }
1757
+ }
1758
+ break;
1759
+ }
1760
+ case "channelAftertouch":
1761
+ renderControllerStates[ch][13] = event.amount / 127;
1762
+ }
1763
+ }
1764
+ const offlineContext = new OfflineAudioContext(2, totalSamples, sampleRate);
1765
+ for (let i = 0; i < tasks.length; i++) {
1766
+ const { t, promise } = tasks[i];
1767
+ const noteBuffer = await promise;
1768
+ if (!noteBuffer)
1769
+ continue;
1770
+ const audioBuffer = noteBuffer instanceof RenderedBuffer
1771
+ ? noteBuffer.buffer
1772
+ : noteBuffer;
1773
+ const bufferSource = new AudioBufferSourceNode(offlineContext, {
1774
+ buffer: audioBuffer,
1775
+ });
1776
+ bufferSource.connect(offlineContext.destination);
1777
+ bufferSource.start(t);
1778
+ }
1779
+ this.renderedAudioBuffer = await offlineContext.startRendering();
1780
+ this.isRendering = false;
1781
+ this.dispatchEvent(new Event("rendered"));
1782
+ return this.renderedAudioBuffer;
1783
+ }
1027
1784
  async start() {
1028
1785
  if (this.isPlaying || this.isPaused)
1029
1786
  return;
@@ -1060,11 +1817,22 @@ class MidyGM2 extends EventTarget {
1060
1817
  }
1061
1818
  }
1062
1819
  tempoChange(tempo) {
1820
+ const cacheMode = this.cacheMode;
1063
1821
  const timeScale = this.tempo / tempo;
1064
1822
  this.resumeTime = this.resumeTime * timeScale;
1065
1823
  this.tempo = tempo;
1066
1824
  this.totalTime = this.calcTotalTime();
1067
1825
  this.seekTo(this.currentTime() * timeScale);
1826
+ if (cacheMode === "adsr" || cacheMode === "note" || cacheMode === "audio") {
1827
+ this.buildNoteOnDurations();
1828
+ this.fullVoiceCache.clear();
1829
+ this.adsrVoiceCache.clear();
1830
+ }
1831
+ if (cacheMode === "audio") {
1832
+ if (this.audioModeBufferSource) {
1833
+ this.audioModeBufferSource.playbackRate.setValueAtTime(this.tempo, this.audioContext.currentTime);
1834
+ }
1835
+ }
1068
1836
  }
1069
1837
  calcTotalTime() {
1070
1838
  const totalTimeEventTypes = this.totalTimeEventTypes;
@@ -1085,6 +1853,9 @@ class MidyGM2 extends EventTarget {
1085
1853
  if (!this.isPlaying)
1086
1854
  return this.resumeTime;
1087
1855
  const now = this.audioContext.currentTime;
1856
+ if (this.cacheMode === "audio") {
1857
+ return this.resumeTime + (now - this.startTime) * this.tempo;
1858
+ }
1088
1859
  return now + this.resumeTime - this.startTime;
1089
1860
  }
1090
1861
  async processScheduledNotes(channel, callback) {
@@ -1282,6 +2053,8 @@ class MidyGM2 extends EventTarget {
1282
2053
  }
1283
2054
  updateChannelDetune(channel, scheduleTime) {
1284
2055
  this.processScheduledNotes(channel, (note) => {
2056
+ if (note.renderedBuffer?.isFull)
2057
+ return;
1285
2058
  if (this.isPortamento(channel, note)) {
1286
2059
  this.setPortamentoDetune(channel, note, scheduleTime);
1287
2060
  }
@@ -1370,6 +2143,8 @@ class MidyGM2 extends EventTarget {
1370
2143
  .exponentialRampToValueAtTime(sustainVolume, portamentoTime);
1371
2144
  }
1372
2145
  setVolumeEnvelope(channel, note, scheduleTime) {
2146
+ if (!note.volumeEnvelopeNode)
2147
+ return;
1373
2148
  const { voiceParams, startTime } = note;
1374
2149
  const attackVolume = cbToRatio(-voiceParams.initialAttenuation) *
1375
2150
  (1 + this.getAmplitudeControl(channel));
@@ -1398,9 +2173,6 @@ class MidyGM2 extends EventTarget {
1398
2173
  }
1399
2174
  setDetune(channel, note, scheduleTime) {
1400
2175
  const detune = this.calcNoteDetune(channel, note);
1401
- note.bufferSource.detune
1402
- .cancelScheduledValues(scheduleTime)
1403
- .setValueAtTime(detune, scheduleTime);
1404
2176
  const timeConstant = this.perceptualSmoothingTime / 5; // 99.3% (5 * tau)
1405
2177
  note.bufferSource.detune
1406
2178
  .cancelAndHoldAtTime(scheduleTime)
@@ -1460,6 +2232,8 @@ class MidyGM2 extends EventTarget {
1460
2232
  .exponentialRampToValueAtTime(adjustedSustainFreq, portamentoTime);
1461
2233
  }
1462
2234
  setFilterEnvelope(channel, note, scheduleTime) {
2235
+ if (!note.filterEnvelopeNode)
2236
+ return;
1463
2237
  const { voiceParams, startTime } = note;
1464
2238
  const modEnvToFilterFc = voiceParams.modEnvToFilterFc;
1465
2239
  const baseCent = voiceParams.initialFilterFc +
@@ -1499,14 +2273,17 @@ class MidyGM2 extends EventTarget {
1499
2273
  note.modLfoToPitch = new GainNode(audioContext);
1500
2274
  this.setModLfoToPitch(channel, note, scheduleTime);
1501
2275
  note.modLfoToVolume = new GainNode(audioContext);
1502
- this.setModLfoToVolume(note, scheduleTime);
2276
+ this.setModLfoToVolume(channel, note, scheduleTime);
1503
2277
  note.modLfo.start(note.startTime + voiceParams.delayModLFO);
1504
2278
  note.modLfo.connect(note.modLfoToFilterFc);
1505
- note.modLfoToFilterFc.connect(note.filterEnvelopeNode.frequency);
2279
+ if (note.filterEnvelopeNode) {
2280
+ note.modLfoToFilterFc.connect(note.filterEnvelopeNode.frequency);
2281
+ }
1506
2282
  note.modLfo.connect(note.modLfoToPitch);
1507
2283
  note.modLfoToPitch.connect(note.bufferSource.detune);
1508
2284
  note.modLfo.connect(note.modLfoToVolume);
1509
- note.modLfoToVolume.connect(note.volumeEnvelopeNode.gain);
2285
+ const volumeTarget = note.volumeEnvelopeNode ?? note.volumeNode;
2286
+ note.modLfoToVolume.connect(volumeTarget.gain);
1510
2287
  }
1511
2288
  startVibrato(channel, note, scheduleTime) {
1512
2289
  const { voiceParams } = note;
@@ -1522,34 +2299,342 @@ class MidyGM2 extends EventTarget {
1522
2299
  note.vibLfo.connect(note.vibLfoToPitch);
1523
2300
  note.vibLfoToPitch.connect(note.bufferSource.detune);
1524
2301
  }
1525
- async getAudioBuffer(channel, noteNumber, velocity, voiceParams, realtime) {
2302
+ async createAdsRenderedBuffer(channel, note, voiceParams, audioBuffer, isDrum = false) {
2303
+ const isLoop = isDrum ? false : (voiceParams.sampleModes % 2 !== 0);
2304
+ const volAttack = voiceParams.volDelay + voiceParams.volAttack;
2305
+ const volHold = volAttack + voiceParams.volHold;
2306
+ const decayDuration = voiceParams.volDecay;
2307
+ const adsDuration = volHold + decayDuration * decayCurve * 5;
2308
+ const loopStartTime = voiceParams.loopStart / voiceParams.sampleRate;
2309
+ const loopDuration = isLoop
2310
+ ? (voiceParams.loopEnd - voiceParams.loopStart) / voiceParams.sampleRate
2311
+ : 0;
2312
+ const loopCount = isLoop && adsDuration > loopStartTime
2313
+ ? Math.ceil((adsDuration - loopStartTime) / loopDuration)
2314
+ : 0;
2315
+ const alignedLoopStart = loopStartTime + loopCount * loopDuration;
2316
+ const renderDuration = isLoop
2317
+ ? alignedLoopStart + loopDuration
2318
+ : audioBuffer.duration;
2319
+ const offlineContext = new OfflineAudioContext(audioBuffer.numberOfChannels, Math.ceil(renderDuration * this.audioContext.sampleRate), this.audioContext.sampleRate);
2320
+ const bufferSource = new AudioBufferSourceNode(offlineContext);
2321
+ bufferSource.buffer = audioBuffer;
2322
+ bufferSource.playbackRate.value = voiceParams.playbackRate;
2323
+ bufferSource.loop = isLoop;
2324
+ if (isLoop) {
2325
+ bufferSource.loopStart = loopStartTime;
2326
+ bufferSource.loopEnd = loopStartTime + loopDuration;
2327
+ }
2328
+ const initialFreq = this.clampCutoffFrequency(this.centToHz(voiceParams.initialFilterFc));
2329
+ const filterEnvelopeNode = new BiquadFilterNode(offlineContext, {
2330
+ type: "lowpass",
2331
+ Q: voiceParams.initialFilterQ / 10, // dB
2332
+ frequency: initialFreq,
2333
+ });
2334
+ const volumeEnvelopeNode = new GainNode(offlineContext);
2335
+ const offlineNote = {
2336
+ ...note,
2337
+ startTime: 0,
2338
+ bufferSource,
2339
+ filterEnvelopeNode,
2340
+ volumeEnvelopeNode,
2341
+ };
2342
+ this.setVolumeEnvelope(channel, offlineNote, 0);
2343
+ this.setFilterEnvelope(channel, offlineNote, 0);
2344
+ bufferSource.connect(filterEnvelopeNode);
2345
+ filterEnvelopeNode.connect(volumeEnvelopeNode);
2346
+ volumeEnvelopeNode.connect(offlineContext.destination);
2347
+ if (voiceParams.sample.type === "compressed") {
2348
+ bufferSource.start(0, voiceParams.start / audioBuffer.sampleRate);
2349
+ }
2350
+ else {
2351
+ bufferSource.start(0);
2352
+ }
2353
+ const buffer = await offlineContext.startRendering();
2354
+ return new RenderedBuffer(buffer, {
2355
+ isLoop,
2356
+ adsDuration,
2357
+ loopStart: alignedLoopStart,
2358
+ loopDuration,
2359
+ });
2360
+ }
2361
+ async createAdsrRenderedBuffer(channel, note, voiceParams, audioBuffer, noteDuration) {
2362
+ const isLoop = voiceParams.sampleModes % 2 !== 0;
2363
+ const volAttack = voiceParams.volDelay + voiceParams.volAttack;
2364
+ const volHold = volAttack + voiceParams.volHold;
2365
+ const decayDuration = voiceParams.volDecay;
2366
+ const adsDuration = volHold + decayDuration * decayCurve * 5;
2367
+ const releaseDuration = voiceParams.volRelease;
2368
+ const loopStartTime = voiceParams.loopStart / voiceParams.sampleRate;
2369
+ const loopDuration = isLoop
2370
+ ? (voiceParams.loopEnd - voiceParams.loopStart) / voiceParams.sampleRate
2371
+ : 0;
2372
+ const noteLoopCount = isLoop && noteDuration > loopStartTime
2373
+ ? Math.ceil((noteDuration - loopStartTime) / loopDuration)
2374
+ : 0;
2375
+ const alignedNoteEnd = isLoop
2376
+ ? loopStartTime + noteLoopCount * loopDuration
2377
+ : noteDuration;
2378
+ const noteOffTime = alignedNoteEnd;
2379
+ const totalDuration = noteOffTime + releaseDuration;
2380
+ const sampleRate = this.audioContext.sampleRate;
2381
+ const offlineContext = new OfflineAudioContext(audioBuffer.numberOfChannels, Math.ceil(totalDuration * sampleRate), sampleRate);
2382
+ const bufferSource = new AudioBufferSourceNode(offlineContext);
2383
+ bufferSource.buffer = audioBuffer;
2384
+ bufferSource.playbackRate.value = voiceParams.playbackRate;
2385
+ bufferSource.loop = isLoop;
2386
+ if (isLoop) {
2387
+ bufferSource.loopStart = loopStartTime;
2388
+ bufferSource.loopEnd = loopStartTime + loopDuration;
2389
+ }
2390
+ const initialFreq = this.clampCutoffFrequency(this.centToHz(voiceParams.initialFilterFc));
2391
+ const filterEnvelopeNode = new BiquadFilterNode(offlineContext, {
2392
+ type: "lowpass",
2393
+ Q: voiceParams.initialFilterQ / 10, // dB
2394
+ frequency: initialFreq,
2395
+ });
2396
+ const volumeEnvelopeNode = new GainNode(offlineContext);
2397
+ const offlineNote = {
2398
+ ...note,
2399
+ startTime: 0,
2400
+ bufferSource,
2401
+ filterEnvelopeNode,
2402
+ volumeEnvelopeNode,
2403
+ };
2404
+ this.setVolumeEnvelope(channel, offlineNote, 0);
2405
+ this.setFilterEnvelope(channel, offlineNote, 0);
2406
+ const attackVolume = cbToRatio(-voiceParams.initialAttenuation);
2407
+ const sustainVolume = attackVolume * (1 - voiceParams.volSustain);
2408
+ const volDelayTime = voiceParams.volDelay;
2409
+ const volAttackTime = volDelayTime + voiceParams.volAttack;
2410
+ const volHoldTime = volAttackTime + voiceParams.volHold;
2411
+ let gainAtNoteOff;
2412
+ if (noteOffTime <= volDelayTime) {
2413
+ gainAtNoteOff = 0;
2414
+ }
2415
+ else if (noteOffTime <= volAttackTime) {
2416
+ gainAtNoteOff = 1e-6 + (attackVolume - 1e-6) *
2417
+ (noteOffTime - volDelayTime) / voiceParams.volAttack;
2418
+ }
2419
+ else if (noteOffTime <= volHoldTime) {
2420
+ gainAtNoteOff = attackVolume;
2421
+ }
2422
+ else {
2423
+ const decayElapsed = noteOffTime - volHoldTime;
2424
+ gainAtNoteOff = sustainVolume +
2425
+ (attackVolume - sustainVolume) *
2426
+ Math.exp(-decayElapsed / (decayCurve * voiceParams.volDecay));
2427
+ }
2428
+ volumeEnvelopeNode.gain
2429
+ .cancelScheduledValues(noteOffTime)
2430
+ .setValueAtTime(gainAtNoteOff, noteOffTime)
2431
+ .setTargetAtTime(0, noteOffTime, releaseDuration * releaseCurve);
2432
+ filterEnvelopeNode.frequency
2433
+ .cancelScheduledValues(noteOffTime)
2434
+ .setValueAtTime(initialFreq, noteOffTime)
2435
+ .setTargetAtTime(initialFreq, noteOffTime, voiceParams.modRelease * releaseCurve);
2436
+ bufferSource.connect(filterEnvelopeNode);
2437
+ filterEnvelopeNode.connect(volumeEnvelopeNode);
2438
+ volumeEnvelopeNode.connect(offlineContext.destination);
2439
+ if (isLoop) {
2440
+ bufferSource.start(0, voiceParams.start / audioBuffer.sampleRate);
2441
+ }
2442
+ else {
2443
+ bufferSource.start(0);
2444
+ }
2445
+ const buffer = await offlineContext.startRendering();
2446
+ return new RenderedBuffer(buffer, {
2447
+ isLoop: false,
2448
+ isFull: false,
2449
+ adsDuration,
2450
+ noteDuration: noteOffTime,
2451
+ releaseDuration,
2452
+ });
2453
+ }
2454
+ async createFullRenderedBuffer(channel, note, voiceParams, noteDuration, noteEvent = {}) {
2455
+ const { startTime: noteStartTime = 0, events: noteEvents = [] } = noteEvent;
2456
+ const ch = note.channel ?? 0;
2457
+ const releaseEndDuration = voiceParams.volRelease * releaseCurve * 5;
2458
+ const totalDuration = noteDuration + releaseEndDuration;
2459
+ const sampleRate = this.audioContext.sampleRate;
2460
+ const offlineContext = new OfflineAudioContext(2, Math.ceil(totalDuration * sampleRate), sampleRate);
2461
+ const offlinePlayer = new this.constructor(offlineContext, {
2462
+ cacheMode: "none",
2463
+ });
2464
+ offlineContext.suspend = () => Promise.resolve();
2465
+ offlineContext.resume = () => Promise.resolve();
2466
+ offlinePlayer.soundFonts = this.soundFonts;
2467
+ offlinePlayer.soundFontTable = this.soundFontTable;
2468
+ const dstChannel = offlinePlayer.channels[ch];
2469
+ dstChannel.state.array.set(channel.state.array);
2470
+ dstChannel.isDrum = channel.isDrum;
2471
+ dstChannel.programNumber = channel.programNumber;
2472
+ dstChannel.modulationDepthRange = channel.modulationDepthRange;
2473
+ dstChannel.detune = this.calcChannelDetune(dstChannel);
2474
+ await offlinePlayer.noteOn(ch, note.noteNumber, note.velocity, 0);
2475
+ for (const event of noteEvents) {
2476
+ const t = event.startTime / this.tempo - noteStartTime;
2477
+ if (t < 0 || t > noteDuration)
2478
+ continue;
2479
+ switch (event.type) {
2480
+ case "controller":
2481
+ offlinePlayer.setControlChange(ch, event.controllerType, event.value, t);
2482
+ break;
2483
+ case "pitchBend":
2484
+ offlinePlayer.setPitchBend(ch, event.value + 8192, t);
2485
+ break;
2486
+ case "sysEx":
2487
+ offlinePlayer.handleSysEx(event.data, t);
2488
+ break;
2489
+ case "channelAftertouch":
2490
+ offlinePlayer.setChannelPressure(ch, event.amount, t);
2491
+ }
2492
+ }
2493
+ offlinePlayer.noteOff(ch, note.noteNumber, 0, noteDuration, true);
2494
+ const buffer = await offlineContext.startRendering();
2495
+ return new RenderedBuffer(buffer, {
2496
+ isLoop: false,
2497
+ isFull: true,
2498
+ noteDuration: noteDuration,
2499
+ releaseDuration: releaseEndDuration,
2500
+ });
2501
+ }
2502
+ async getAudioBuffer(channel, note, realtime) {
2503
+ const cacheMode = this.cacheMode;
2504
+ const { noteNumber, velocity } = note;
1526
2505
  const audioBufferId = this.getVoiceId(channel, noteNumber, velocity);
2506
+ if (!realtime) {
2507
+ if (cacheMode === "note") {
2508
+ return await this.getFullCachedBuffer(note, audioBufferId);
2509
+ }
2510
+ else if (cacheMode === "adsr") {
2511
+ return await this.getAdsrCachedBuffer(channel, note, audioBufferId);
2512
+ }
2513
+ }
2514
+ if (cacheMode === "none") {
2515
+ return await this.createAudioBuffer(note.voiceParams);
2516
+ }
2517
+ // fallback to ADS cache:
2518
+ // - "ads" (realtime or not)
2519
+ // - "adsr" + realtime
2520
+ // - "note" + realtime
2521
+ return await this.getAdsCachedBuffer(channel, note, audioBufferId, realtime);
2522
+ }
2523
+ async getAdsCachedBuffer(channel, note, audioBufferId, realtime) {
2524
+ const cacheKey = audioBufferId + (note.noteNumber << 1) + 1;
2525
+ const voiceParams = note.voiceParams;
1527
2526
  if (realtime) {
1528
- const cachedAudioBuffer = this.realtimeVoiceCache.get(audioBufferId);
1529
- if (cachedAudioBuffer)
1530
- return cachedAudioBuffer;
1531
- const audioBuffer = await this.createAudioBuffer(voiceParams);
1532
- this.realtimeVoiceCache.set(audioBufferId, audioBuffer);
1533
- return audioBuffer;
2527
+ const cached = this.realtimeVoiceCache.get(cacheKey);
2528
+ if (cached)
2529
+ return cached;
2530
+ const rawBuffer = await this.createAudioBuffer(voiceParams);
2531
+ const rendered = await this.createAdsRenderedBuffer(channel, note, voiceParams, rawBuffer, channel.isDrum);
2532
+ this.realtimeVoiceCache.set(cacheKey, rendered);
2533
+ return rendered;
1534
2534
  }
1535
2535
  else {
1536
- const cache = this.voiceCache.get(audioBufferId);
2536
+ const cache = this.voiceCache.get(cacheKey);
1537
2537
  if (cache) {
1538
2538
  cache.counter += 1;
1539
2539
  if (cache.maxCount <= cache.counter) {
1540
- this.voiceCache.delete(audioBufferId);
2540
+ this.voiceCache.delete(cacheKey);
1541
2541
  }
1542
2542
  return cache.audioBuffer;
1543
2543
  }
1544
2544
  else {
1545
- const maxCount = this.voiceCounter.get(audioBufferId) ?? 0;
1546
- const audioBuffer = await this.createAudioBuffer(voiceParams);
1547
- const cache = { audioBuffer, maxCount, counter: 1 };
1548
- this.voiceCache.set(audioBufferId, cache);
1549
- return audioBuffer;
2545
+ const maxCount = this.voiceCounter.get(cacheKey) ?? 0;
2546
+ const rawBuffer = await this.createAudioBuffer(voiceParams);
2547
+ const rendered = await this.createAdsRenderedBuffer(channel, note, voiceParams, rawBuffer, channel.isDrum);
2548
+ const cache = { audioBuffer: rendered, maxCount, counter: 1 };
2549
+ this.voiceCache.set(cacheKey, cache);
2550
+ return rendered;
1550
2551
  }
1551
2552
  }
1552
2553
  }
2554
+ async getAdsrCachedBuffer(channel, note, audioBufferId) {
2555
+ const voiceParams = note.voiceParams;
2556
+ const timelineIndex = note.timelineIndex;
2557
+ const noteEvent = this.noteOnEvents.get(timelineIndex);
2558
+ const noteDurationTicks = noteEvent?.durationTicks ?? 0;
2559
+ const safeTicks = noteDurationTicks === Infinity
2560
+ ? 0xffffffffn
2561
+ : BigInt(noteDurationTicks);
2562
+ const volReleaseBits = f64ToBigInt(voiceParams.volRelease);
2563
+ const playbackRateBits = f64ToBigInt(voiceParams.playbackRate);
2564
+ const cacheKey = (BigInt(audioBufferId) << 160n) |
2565
+ (playbackRateBits << 96n) |
2566
+ (safeTicks << 64n) |
2567
+ volReleaseBits;
2568
+ let durationMap = this.adsrVoiceCache.get(audioBufferId);
2569
+ if (!durationMap) {
2570
+ durationMap = new Map();
2571
+ this.adsrVoiceCache.set(audioBufferId, durationMap);
2572
+ }
2573
+ const cached = durationMap.get(cacheKey);
2574
+ if (cached instanceof RenderedBuffer) {
2575
+ return cached;
2576
+ }
2577
+ if (cached instanceof Promise) {
2578
+ const buf = await cached;
2579
+ if (buf == null)
2580
+ return await this.createAudioBuffer(voiceParams);
2581
+ return buf;
2582
+ }
2583
+ const noteDuration = noteEvent?.duration ?? 0;
2584
+ const renderPromise = (async () => {
2585
+ try {
2586
+ const rawBuffer = await this.createAudioBuffer(voiceParams);
2587
+ const rendered = await this.createAdsrRenderedBuffer(channel, note, voiceParams, rawBuffer, noteDuration);
2588
+ durationMap.set(cacheKey, rendered);
2589
+ return rendered;
2590
+ }
2591
+ catch (err) {
2592
+ durationMap.delete(cacheKey);
2593
+ throw err;
2594
+ }
2595
+ })();
2596
+ durationMap.set(cacheKey, renderPromise);
2597
+ return await renderPromise;
2598
+ }
2599
+ async getFullCachedBuffer(note, audioBufferId) {
2600
+ const voiceParams = note.voiceParams;
2601
+ const timelineIndex = note.timelineIndex;
2602
+ const noteEvent = this.noteOnEvents.get(timelineIndex);
2603
+ const noteDuration = noteEvent?.duration ?? 0;
2604
+ const cacheKey = timelineIndex;
2605
+ let durationMap = this.fullVoiceCache.get(audioBufferId);
2606
+ if (!durationMap) {
2607
+ durationMap = new Map();
2608
+ this.fullVoiceCache.set(audioBufferId, durationMap);
2609
+ }
2610
+ const cached = durationMap.get(cacheKey);
2611
+ if (cached instanceof RenderedBuffer) {
2612
+ note.fullCacheVoiceId = audioBufferId;
2613
+ return cached;
2614
+ }
2615
+ if (cached instanceof Promise) {
2616
+ const buf = await cached;
2617
+ if (buf == null)
2618
+ return await this.createAudioBuffer(voiceParams);
2619
+ note.fullCacheVoiceId = audioBufferId;
2620
+ return buf;
2621
+ }
2622
+ const renderPromise = (async () => {
2623
+ try {
2624
+ const rendered = await this.createFullRenderedBuffer(this.channels[note.channel], note, voiceParams, noteDuration, noteEvent);
2625
+ durationMap.set(cacheKey, rendered);
2626
+ return rendered;
2627
+ }
2628
+ catch (err) {
2629
+ durationMap.delete(cacheKey);
2630
+ throw err;
2631
+ }
2632
+ })();
2633
+ durationMap.set(cacheKey, renderPromise);
2634
+ const rendered = await renderPromise;
2635
+ note.fullCacheVoiceId = audioBufferId;
2636
+ return rendered;
2637
+ }
1553
2638
  async setNoteAudioNode(channel, note, realtime) {
1554
2639
  const audioContext = this.audioContext;
1555
2640
  const now = audioContext.currentTime;
@@ -1558,46 +2643,72 @@ class MidyGM2 extends EventTarget {
1558
2643
  const controllerState = this.getControllerState(channel, noteNumber, velocity);
1559
2644
  const voiceParams = note.voice.getAllParams(controllerState);
1560
2645
  note.voiceParams = voiceParams;
1561
- const audioBuffer = await this.getAudioBuffer(channel, noteNumber, velocity, voiceParams, realtime);
2646
+ const audioBuffer = await this.getAudioBuffer(channel, note, realtime);
2647
+ const isRendered = audioBuffer instanceof RenderedBuffer;
2648
+ note.renderedBuffer = isRendered ? audioBuffer : null;
1562
2649
  note.bufferSource = this.createBufferSource(channel, noteNumber, voiceParams, audioBuffer);
1563
- note.volumeEnvelopeNode = new GainNode(audioContext);
1564
- note.filterEnvelopeNode = new BiquadFilterNode(audioContext, {
1565
- type: "lowpass",
1566
- Q: voiceParams.initialFilterQ / 10, // dB
1567
- });
1568
- const prevNote = channel.scheduledNotes.at(-1);
1569
- if (prevNote && prevNote.noteNumber !== noteNumber) {
1570
- note.portamentoNoteNumber = prevNote.noteNumber;
1571
- }
1572
- if (!channel.isDrum && this.isPortamento(channel, note)) {
1573
- this.setPortamentoVolumeEnvelope(channel, note, now);
1574
- this.setPortamentoFilterEnvelope(channel, note, now);
1575
- this.setPortamentoPitchEnvelope(channel, note, now);
1576
- this.setPortamentoDetune(channel, note, now);
1577
- }
1578
- else {
1579
- this.setVolumeEnvelope(channel, note, now);
1580
- this.setFilterEnvelope(channel, note, now);
1581
- this.setPitchEnvelope(note, now);
2650
+ note.volumeNode = new GainNode(audioContext);
2651
+ note.volumeNode.gain.setValueAtTime(1, now);
2652
+ const cacheMode = this.cacheMode;
2653
+ const isFullCached = isRendered && audioBuffer.isFull === true;
2654
+ if (cacheMode === "none") {
2655
+ note.volumeEnvelopeNode = new GainNode(audioContext);
2656
+ note.filterEnvelopeNode = new BiquadFilterNode(audioContext, {
2657
+ type: "lowpass",
2658
+ Q: voiceParams.initialFilterQ / 10, // dB
2659
+ });
2660
+ const prevNote = channel.scheduledNotes.at(-1);
2661
+ if (prevNote && prevNote.noteNumber !== noteNumber) {
2662
+ note.portamentoNoteNumber = prevNote.noteNumber;
2663
+ }
2664
+ if (!channel.isDrum && this.isPortamento(channel, note)) {
2665
+ this.setPortamentoVolumeEnvelope(channel, note, now);
2666
+ this.setPortamentoFilterEnvelope(channel, note, now);
2667
+ this.setPortamentoPitchEnvelope(channel, note, now);
2668
+ this.setPortamentoDetune(channel, note, now);
2669
+ }
2670
+ else {
2671
+ this.setVolumeEnvelope(channel, note, now);
2672
+ this.setFilterEnvelope(channel, note, now);
2673
+ this.setPitchEnvelope(note, now);
2674
+ this.setDetune(channel, note, now);
2675
+ }
2676
+ if (0 < state.vibratoDepth) {
2677
+ this.startVibrato(channel, note, now);
2678
+ }
2679
+ if (0 < state.modulationDepthMSB) {
2680
+ this.startModulation(channel, note, now);
2681
+ }
2682
+ if (channel.mono && channel.currentBufferSource) {
2683
+ channel.currentBufferSource.stop(startTime);
2684
+ channel.currentBufferSource = note.bufferSource;
2685
+ }
2686
+ note.bufferSource.connect(note.filterEnvelopeNode);
2687
+ note.filterEnvelopeNode.connect(note.volumeEnvelopeNode);
2688
+ note.volumeEnvelopeNode.connect(note.volumeNode);
2689
+ this.setChorusSend(channel, note, now);
2690
+ this.setReverbSend(channel, note, now);
2691
+ }
2692
+ else if (isFullCached) { // "note" mode
2693
+ note.volumeEnvelopeNode = null;
2694
+ note.filterEnvelopeNode = null;
2695
+ note.bufferSource.connect(note.volumeNode);
2696
+ this.setChorusSend(channel, note, now);
2697
+ this.setReverbSend(channel, note, now);
2698
+ }
2699
+ else { // "ads" / "asdr" mode
2700
+ note.volumeEnvelopeNode = null;
2701
+ note.filterEnvelopeNode = null;
1582
2702
  this.setDetune(channel, note, now);
2703
+ if (0 < state.modulationDepthMSB) {
2704
+ this.startModulation(channel, note, now);
2705
+ }
2706
+ note.bufferSource.connect(note.volumeNode);
2707
+ this.setChorusSend(channel, note, now);
2708
+ this.setReverbSend(channel, note, now);
1583
2709
  }
1584
- if (0 < state.vibratoDepth) {
1585
- this.startVibrato(channel, note, now);
1586
- }
1587
- if (0 < state.modulationDepthMSB) {
1588
- this.startModulation(channel, note, now);
1589
- }
1590
- if (channel.mono && channel.currentBufferSource) {
1591
- channel.currentBufferSource.stop(startTime);
1592
- channel.currentBufferSource = note.bufferSource;
1593
- }
1594
- note.bufferSource.connect(note.filterEnvelopeNode);
1595
- note.filterEnvelopeNode.connect(note.volumeEnvelopeNode);
1596
- this.setChorusSend(channel, note, now);
1597
- this.setReverbSend(channel, note, now);
1598
2710
  if (voiceParams.sample.type === "compressed") {
1599
- const offset = voiceParams.start / audioBuffer.sampleRate;
1600
- note.bufferSource.start(startTime, offset);
2711
+ note.bufferSource.start(startTime);
1601
2712
  }
1602
2713
  else {
1603
2714
  note.bufferSource.start(startTime);
@@ -1639,40 +2750,53 @@ class MidyGM2 extends EventTarget {
1639
2750
  }
1640
2751
  setNoteRouting(channelNumber, note, startTime) {
1641
2752
  const channel = this.channels[channelNumber];
1642
- const { noteNumber, volumeEnvelopeNode } = note;
1643
- if (channel.isDrum) {
1644
- const { keyBasedGainLs, keyBasedGainRs } = channel;
1645
- let gainL = keyBasedGainLs[noteNumber];
1646
- let gainR = keyBasedGainRs[noteNumber];
1647
- if (!gainL) {
1648
- const audioNodes = this.createChannelAudioNodes(this.audioContext);
1649
- gainL = keyBasedGainLs[noteNumber] = audioNodes.gainL;
1650
- gainR = keyBasedGainRs[noteNumber] = audioNodes.gainR;
1651
- }
1652
- volumeEnvelopeNode.connect(gainL);
1653
- volumeEnvelopeNode.connect(gainR);
2753
+ const { volumeNode } = note;
2754
+ if (note.renderedBuffer?.isFull) {
2755
+ volumeNode.connect(this.masterVolume);
1654
2756
  }
1655
2757
  else {
1656
- volumeEnvelopeNode.connect(channel.gainL);
1657
- volumeEnvelopeNode.connect(channel.gainR);
1658
- }
1659
- if (0.5 <= channel.state.sustainPedal) {
1660
- channel.sustainNotes.push(note);
2758
+ if (channel.isDrum) {
2759
+ const noteNumber = note.noteNumber;
2760
+ const { keyBasedGainLs, keyBasedGainRs } = channel;
2761
+ let gainL = keyBasedGainLs[noteNumber];
2762
+ let gainR = keyBasedGainRs[noteNumber];
2763
+ if (!gainL) {
2764
+ const audioNodes = this.createChannelAudioNodes(this.audioContext);
2765
+ gainL = keyBasedGainLs[noteNumber] = audioNodes.gainL;
2766
+ gainR = keyBasedGainRs[noteNumber] = audioNodes.gainR;
2767
+ }
2768
+ volumeNode.connect(gainL);
2769
+ volumeNode.connect(gainR);
2770
+ }
2771
+ else {
2772
+ volumeNode.connect(channel.gainL);
2773
+ volumeNode.connect(channel.gainR);
2774
+ }
1661
2775
  }
1662
2776
  this.handleExclusiveClass(note, channelNumber, startTime);
1663
2777
  this.handleDrumExclusiveClass(note, channelNumber, startTime);
1664
2778
  }
1665
2779
  async noteOn(channelNumber, noteNumber, velocity, startTime) {
1666
- const channel = this.channels[channelNumber];
1667
- const realtime = startTime === undefined;
1668
- if (realtime)
2780
+ const note = this.createNote(channelNumber, noteNumber, velocity, startTime);
2781
+ return await this.setupNote(channelNumber, note, startTime);
2782
+ }
2783
+ createNote(channelNumber, noteNumber, velocity, startTime) {
2784
+ if (!(0 <= startTime))
1669
2785
  startTime = this.audioContext.currentTime;
1670
2786
  const note = new Note(noteNumber, velocity, startTime);
1671
- const scheduledNotes = channel.scheduledNotes;
1672
- note.index = scheduledNotes.length;
1673
- scheduledNotes.push(note);
2787
+ note.channel = channelNumber;
2788
+ const channel = this.channels[channelNumber];
2789
+ note.index = channel.scheduledNotes.length;
2790
+ channel.scheduledNotes.push(note);
2791
+ return note;
2792
+ }
2793
+ async setupNote(channelNumber, note, startTime) {
2794
+ const realtime = startTime === undefined;
2795
+ const channel = this.channels[channelNumber];
1674
2796
  const programNumber = channel.programNumber;
1675
2797
  const bankTable = this.soundFontTable[programNumber];
2798
+ if (!bankTable)
2799
+ return;
1676
2800
  let bank = channel.isDrum ? 128 : channel.bankLSB;
1677
2801
  if (bankTable[bank] === undefined) {
1678
2802
  if (channel.isDrum)
@@ -1683,17 +2807,25 @@ class MidyGM2 extends EventTarget {
1683
2807
  if (soundFontIndex === undefined)
1684
2808
  return;
1685
2809
  const soundFont = this.soundFonts[soundFontIndex];
1686
- note.voice = soundFont.getVoice(bank, programNumber, noteNumber, velocity);
2810
+ note.voice = soundFont.getVoice(bank, programNumber, note.noteNumber, note.velocity);
1687
2811
  if (!note.voice)
1688
2812
  return;
1689
2813
  await this.setNoteAudioNode(channel, note, realtime);
1690
2814
  this.setNoteRouting(channelNumber, note, startTime);
1691
2815
  note.resolveReady();
2816
+ if (0.5 <= channel.state.sustainPedal) {
2817
+ channel.sustainNotes.push(note);
2818
+ }
2819
+ if (0.5 <= channel.state.sostenutoPedal) {
2820
+ channel.sostenutoNotes.push(note);
2821
+ }
2822
+ return note;
1692
2823
  }
1693
2824
  disconnectNote(note) {
1694
2825
  note.bufferSource.disconnect();
1695
- note.filterEnvelopeNode.disconnect();
1696
- note.volumeEnvelopeNode.disconnect();
2826
+ note.filterEnvelopeNode?.disconnect();
2827
+ note.volumeEnvelopeNode?.disconnect();
2828
+ note.volumeNode.disconnect();
1697
2829
  if (note.modLfoToPitch) {
1698
2830
  note.modLfoToVolume.disconnect();
1699
2831
  note.modLfoToPitch.disconnect();
@@ -1710,16 +2842,112 @@ class MidyGM2 extends EventTarget {
1710
2842
  note.chorusSend.disconnect();
1711
2843
  }
1712
2844
  }
2845
+ releaseFullCache(note) {
2846
+ if (note.timelineIndex == null || note.fullCacheVoiceId == null)
2847
+ return;
2848
+ const durationMap = this.fullVoiceCache.get(note.fullCacheVoiceId);
2849
+ if (!durationMap)
2850
+ return;
2851
+ const entry = durationMap.get(note.timelineIndex);
2852
+ if (entry instanceof RenderedBuffer) {
2853
+ durationMap.delete(note.timelineIndex);
2854
+ if (durationMap.size === 0) {
2855
+ this.fullVoiceCache.delete(note.fullCacheVoiceId);
2856
+ }
2857
+ }
2858
+ }
1713
2859
  releaseNote(channel, note, endTime) {
1714
2860
  endTime ??= this.audioContext.currentTime;
2861
+ if (note.renderedBuffer?.isFull) {
2862
+ const rb = note.renderedBuffer;
2863
+ const naturalEndTime = note.startTime + rb.buffer.duration;
2864
+ const noteOffTime = note.startTime + (rb.noteDuration ?? 0);
2865
+ const isEarlyCut = endTime < noteOffTime;
2866
+ if (isEarlyCut) {
2867
+ const volDuration = note.voiceParams.volRelease;
2868
+ const volRelease = endTime + volDuration;
2869
+ note.volumeNode.gain
2870
+ .cancelScheduledValues(endTime)
2871
+ .setValueAtTime(1, endTime)
2872
+ .setTargetAtTime(0, endTime, volDuration * releaseCurve);
2873
+ return new Promise((resolve) => {
2874
+ this.scheduleTask(() => {
2875
+ note.bufferSource.loop = false;
2876
+ note.bufferSource.stop(volRelease);
2877
+ this.disconnectNote(note);
2878
+ channel.scheduledNotes[note.index] = undefined;
2879
+ this.releaseFullCache(note);
2880
+ resolve();
2881
+ }, volRelease);
2882
+ });
2883
+ }
2884
+ else {
2885
+ const now = this.audioContext.currentTime;
2886
+ if (naturalEndTime <= now) {
2887
+ this.disconnectNote(note);
2888
+ channel.scheduledNotes[note.index] = undefined;
2889
+ this.releaseFullCache(note);
2890
+ return Promise.resolve();
2891
+ }
2892
+ return new Promise((resolve) => {
2893
+ this.scheduleTask(() => {
2894
+ this.disconnectNote(note);
2895
+ channel.scheduledNotes[note.index] = undefined;
2896
+ this.releaseFullCache(note);
2897
+ resolve();
2898
+ }, naturalEndTime);
2899
+ });
2900
+ }
2901
+ }
1715
2902
  const volDuration = note.voiceParams.volRelease;
1716
2903
  const volRelease = endTime + volDuration;
1717
- note.filterEnvelopeNode.frequency
1718
- .cancelScheduledValues(endTime)
1719
- .setTargetAtTime(note.adjustedBaseFreq, endTime, note.voiceParams.modRelease * releaseCurve);
1720
- note.volumeEnvelopeNode.gain
1721
- .cancelScheduledValues(endTime)
1722
- .setTargetAtTime(0, endTime, volDuration * releaseCurve);
2904
+ if (note.volumeEnvelopeNode) { // "none" mode
2905
+ note.filterEnvelopeNode.frequency
2906
+ .cancelScheduledValues(endTime)
2907
+ .setTargetAtTime(note.adjustedBaseFreq, endTime, note.voiceParams.modRelease * releaseCurve);
2908
+ note.volumeEnvelopeNode.gain
2909
+ .cancelScheduledValues(endTime)
2910
+ .setTargetAtTime(0, endTime, volDuration * releaseCurve);
2911
+ }
2912
+ else { // "ads" / "adsr" mode
2913
+ const isAdsr = note.renderedBuffer?.releaseDuration != null &&
2914
+ !note.renderedBuffer.isFull;
2915
+ if (isAdsr) {
2916
+ const rb = note.renderedBuffer;
2917
+ const naturalEndTime = note.startTime + rb.buffer.duration;
2918
+ const noteOffTime = note.startTime + (rb.noteDuration ?? 0);
2919
+ const isEarlyCut = endTime < noteOffTime;
2920
+ if (isEarlyCut) {
2921
+ const volRelease = endTime + volDuration;
2922
+ note.volumeNode.gain
2923
+ .cancelScheduledValues(endTime)
2924
+ .setValueAtTime(1, endTime)
2925
+ .setTargetAtTime(0, endTime, volDuration * releaseCurve);
2926
+ return new Promise((resolve) => {
2927
+ this.scheduleTask(() => {
2928
+ note.bufferSource.stop(volRelease);
2929
+ this.disconnectNote(note);
2930
+ channel.scheduledNotes[note.index] = undefined;
2931
+ resolve();
2932
+ }, volRelease);
2933
+ });
2934
+ }
2935
+ else {
2936
+ return new Promise((resolve) => {
2937
+ this.scheduleTask(() => {
2938
+ note.bufferSource.stop();
2939
+ this.disconnectNote(note);
2940
+ channel.scheduledNotes[note.index] = undefined;
2941
+ resolve();
2942
+ }, naturalEndTime);
2943
+ });
2944
+ }
2945
+ }
2946
+ note.volumeNode.gain
2947
+ .cancelScheduledValues(endTime)
2948
+ .setValueAtTime(1, endTime)
2949
+ .setTargetAtTime(0, endTime, volDuration * releaseCurve);
2950
+ }
1723
2951
  return new Promise((resolve) => {
1724
2952
  this.scheduleTask(() => {
1725
2953
  const bufferSource = note.bufferSource;
@@ -1938,7 +3166,7 @@ class MidyGM2 extends EventTarget {
1938
3166
  if (!note.reverbSend) {
1939
3167
  if (0 < value) {
1940
3168
  note.reverbSend = new GainNode(this.audioContext, { gain: value });
1941
- note.volumeEnvelopeNode.connect(note.reverbSend);
3169
+ note.volumeNode.connect(note.reverbSend);
1942
3170
  note.reverbSend.connect(this.reverbEffect.input);
1943
3171
  }
1944
3172
  }
@@ -1947,11 +3175,11 @@ class MidyGM2 extends EventTarget {
1947
3175
  .cancelScheduledValues(scheduleTime)
1948
3176
  .setValueAtTime(value, scheduleTime);
1949
3177
  if (0 < value) {
1950
- note.volumeEnvelopeNode.connect(note.reverbSend);
3178
+ note.volumeNode.connect(note.reverbSend);
1951
3179
  }
1952
3180
  else {
1953
3181
  try {
1954
- note.volumeEnvelopeNode.disconnect(note.reverbSend);
3182
+ note.volumeNode.disconnect(note.reverbSend);
1955
3183
  }
1956
3184
  catch { /* empty */ }
1957
3185
  }
@@ -1968,7 +3196,7 @@ class MidyGM2 extends EventTarget {
1968
3196
  if (!note.chorusSend) {
1969
3197
  if (0 < value) {
1970
3198
  note.chorusSend = new GainNode(this.audioContext, { gain: value });
1971
- note.volumeEnvelopeNode.connect(note.chorusSend);
3199
+ note.volumeNode.connect(note.chorusSend);
1972
3200
  note.chorusSend.connect(this.chorusEffect.input);
1973
3201
  }
1974
3202
  }
@@ -1977,11 +3205,11 @@ class MidyGM2 extends EventTarget {
1977
3205
  .cancelScheduledValues(scheduleTime)
1978
3206
  .setValueAtTime(value, scheduleTime);
1979
3207
  if (0 < value) {
1980
- note.volumeEnvelopeNode.connect(note.chorusSend);
3208
+ note.volumeNode.connect(note.chorusSend);
1981
3209
  }
1982
3210
  else {
1983
3211
  try {
1984
- note.volumeEnvelopeNode.disconnect(note.chorusSend);
3212
+ note.volumeNode.disconnect(note.chorusSend);
1985
3213
  }
1986
3214
  catch { /* empty */ }
1987
3215
  }
@@ -2044,7 +3272,7 @@ class MidyGM2 extends EventTarget {
2044
3272
  reverbEffectsSend: (channel, note, scheduleTime) => {
2045
3273
  this.setReverbSend(channel, note, scheduleTime);
2046
3274
  },
2047
- delayModLFO: (_channel, note, _scheduleTime) => {
3275
+ delayModLFO: (channel, note, _scheduleTime) => {
2048
3276
  if (0 < channel.state.modulationDepthMSB) {
2049
3277
  this.setDelayModLFO(note);
2050
3278
  }
@@ -2079,11 +3307,12 @@ class MidyGM2 extends EventTarget {
2079
3307
  state.set(channel.state.array);
2080
3308
  state[2] = velocity / 127;
2081
3309
  state[3] = noteNumber / 127;
2082
- state[13] = state.channelPressure / 127;
2083
3310
  return state;
2084
3311
  }
2085
3312
  applyVoiceParams(channel, controllerType, scheduleTime) {
2086
3313
  this.processScheduledNotes(channel, (note) => {
3314
+ if (note.renderedBuffer?.isFull)
3315
+ return;
2087
3316
  const controllerState = this.getControllerState(channel, note.noteNumber, note.velocity);
2088
3317
  const voiceParams = note.voice.getParams(controllerType, controllerState);
2089
3318
  let applyVolumeEnvelope = false;
@@ -2167,6 +3396,8 @@ class MidyGM2 extends EventTarget {
2167
3396
  const depth = channel.state.modulationDepthMSB *
2168
3397
  channel.modulationDepthRange;
2169
3398
  this.processScheduledNotes(channel, (note) => {
3399
+ if (note.renderedBuffer?.isFull)
3400
+ return;
2170
3401
  if (note.modLfoToPitch) {
2171
3402
  note.modLfoToPitch.gain.setValueAtTime(depth, scheduleTime);
2172
3403
  }
@@ -2303,11 +3534,15 @@ class MidyGM2 extends EventTarget {
2303
3534
  return;
2304
3535
  if (!(0 <= scheduleTime))
2305
3536
  scheduleTime = this.audioContext.currentTime;
2306
- channel.state.sustainPedal = value / 127;
3537
+ const state = channel.state;
3538
+ const prevValue = state.sustainPedal;
3539
+ state.sustainPedal = value / 127;
2307
3540
  if (64 <= value) {
2308
- this.processScheduledNotes(channel, (note) => {
2309
- channel.sustainNotes.push(note);
2310
- });
3541
+ if (prevValue < 0.5) {
3542
+ this.processScheduledNotes(channel, (note) => {
3543
+ channel.sustainNotes.push(note);
3544
+ });
3545
+ }
2311
3546
  }
2312
3547
  else {
2313
3548
  this.releaseSustainPedal(channelNumber, value, scheduleTime);
@@ -2331,13 +3566,17 @@ class MidyGM2 extends EventTarget {
2331
3566
  return;
2332
3567
  if (!(0 <= scheduleTime))
2333
3568
  scheduleTime = this.audioContext.currentTime;
2334
- channel.state.sostenutoPedal = value / 127;
3569
+ const state = channel.state;
3570
+ const prevValue = state.sostenutoPedal;
3571
+ state.sostenutoPedal = value / 127;
2335
3572
  if (64 <= value) {
2336
- const sostenutoNotes = [];
2337
- this.processActiveNotes(channel, scheduleTime, (note) => {
2338
- sostenutoNotes.push(note);
2339
- });
2340
- channel.sostenutoNotes = sostenutoNotes;
3573
+ if (prevValue < 0.5) {
3574
+ const sostenutoNotes = [];
3575
+ this.processActiveNotes(channel, scheduleTime, (note) => {
3576
+ sostenutoNotes.push(note);
3577
+ });
3578
+ channel.sostenutoNotes = sostenutoNotes;
3579
+ }
2341
3580
  }
2342
3581
  else {
2343
3582
  this.releaseSostenutoPedal(channelNumber, value, scheduleTime);
@@ -2533,10 +3772,8 @@ class MidyGM2 extends EventTarget {
2533
3772
  state[key] = defaultValue;
2534
3773
  }
2535
3774
  }
2536
- for (const key of Object.keys(this.constructor.channelSettings)) {
2537
- channel[key] = this.constructor.channelSettings[key];
2538
- }
2539
- this.resetChannelTable(channel);
3775
+ channel.resetSettings(this.constructor.channelSettings);
3776
+ channel.resetTable();
2540
3777
  this.mode = "GM2";
2541
3778
  this.masterFineTuning = 0; // cent
2542
3779
  this.masterCoarseTuning = 0; // cent
@@ -2674,7 +3911,7 @@ class MidyGM2 extends EventTarget {
2674
3911
  case 9:
2675
3912
  switch (data[3]) {
2676
3913
  case 1: // https://amei.or.jp/midistandardcommittee/Recommended_Practice/e/ca22.pdf
2677
- return this.handleChannelPressureSysEx(data, scheduelTime);
3914
+ return this.handleChannelPressureSysEx(data, scheduleTime);
2678
3915
  case 3: // https://amei.or.jp/midistandardcommittee/Recommended_Practice/e/ca22.pdf
2679
3916
  return this.handleControlChangeSysEx(data, scheduleTime);
2680
3917
  default:
@@ -2999,6 +4236,9 @@ class MidyGM2 extends EventTarget {
2999
4236
  getChannelAmplitudeControl(channel) {
3000
4237
  return this.calcChannelEffectValue(channel, 2);
3001
4238
  }
4239
+ getAmplitudeControl(channel) {
4240
+ return this.calcEffectValue(channel, 2);
4241
+ }
3002
4242
  getLFOPitchDepth(channel) {
3003
4243
  return this.calcEffectValue(channel, 3);
3004
4244
  }
@@ -3026,7 +4266,7 @@ class MidyGM2 extends EventTarget {
3026
4266
  this.setFilterEnvelope(channel, note, scheduleTime);
3027
4267
  }
3028
4268
  };
3029
- handlers[2] = (channel, note, scheduleTime) => this.applyVolume(channel, note, scheduleTime);
4269
+ handlers[2] = (channel, _note, scheduleTime) => this.applyVolume(channel, scheduleTime);
3030
4270
  handlers[3] = (channel, note, scheduleTime) => this.setModLfoToPitch(channel, note, scheduleTime);
3031
4271
  handlers[4] = (channel, note, scheduleTime) => this.setModLfoToFilterFc(channel, note, scheduleTime);
3032
4272
  handlers[5] = (channel, note, scheduleTime) => this.setModLfoToVolume(channel, note, scheduleTime);