@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.
@@ -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 ** 32) + (instrument << 16) + sampleID;
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, audioBuffer) {
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
- bufferSource.loop = voiceParams.sampleModes % 2 !== 0;
964
+ const isLoop = isRendered
965
+ ? renderedOrRaw.isLoop
966
+ : voiceParams.sampleModes % 2 !== 0;
967
+ bufferSource.loop = isLoop;
641
968
  if (bufferSource.loop) {
642
- bufferSource.loopStart = voiceParams.loopStart / voiceParams.sampleRate;
643
- bufferSource.loopEnd = voiceParams.loopEnd / voiceParams.sampleRate;
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.noteOn(event.channel, event.noteNumber, event.velocity, startTime);
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
- case "noteOff": {
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
- note.modLfoToFilterFc.connect(note.filterEnvelopeNode.frequency);
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.modLfoToVolume.connect(note.volumeEnvelopeNode.gain);
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, noteNumber, velocity, voiceParams, realtime) {
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 cachedAudioBuffer = this.realtimeVoiceCache.get(audioBufferId);
1116
- if (cachedAudioBuffer)
1117
- return cachedAudioBuffer;
1118
- const audioBuffer = await this.createAudioBuffer(voiceParams);
1119
- this.realtimeVoiceCache.set(audioBufferId, audioBuffer);
1120
- return audioBuffer;
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(audioBufferId);
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(audioBufferId);
1923
+ this.voiceCache.delete(cacheKey);
1128
1924
  }
1129
1925
  return cache.audioBuffer;
1130
1926
  }
1131
1927
  else {
1132
- const maxCount = this.voiceCounter.get(audioBufferId) ?? 0;
1133
- const audioBuffer = await this.createAudioBuffer(voiceParams);
1134
- const cache = { audioBuffer, maxCount, counter: 1 };
1135
- this.voiceCache.set(audioBufferId, cache);
1136
- return audioBuffer;
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, noteNumber, velocity, voiceParams, realtime);
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.volumeEnvelopeNode = new GainNode(audioContext);
1151
- note.filterEnvelopeNode = new BiquadFilterNode(audioContext, {
1152
- type: "lowpass",
1153
- Q: voiceParams.initialFilterQ / 10, // dB
1154
- });
1155
- this.setVolumeEnvelope(note, now);
1156
- this.setFilterEnvelope(note, now);
1157
- this.setPitchEnvelope(note, now);
1158
- this.setDetune(channel, note, now);
1159
- if (0 < state.modulationDepthMSB) {
1160
- this.startModulation(channel, note, now);
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
- const offset = voiceParams.start / audioBuffer.sampleRate;
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 volumeEnvelopeNode = note.volumeEnvelopeNode;
1190
- volumeEnvelopeNode.connect(channel.gainL);
1191
- volumeEnvelopeNode.connect(channel.gainR);
1192
- if (0.5 <= channel.state.sustainPedal) {
1193
- channel.sustainNotes.push(note);
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 channel = this.channels[channelNumber];
1199
- const realtime = startTime === undefined;
1200
- if (realtime)
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
- const scheduledNotes = channel.scheduledNotes;
1204
- note.index = scheduledNotes.length;
1205
- scheduledNotes.push(note);
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.disconnect();
1230
- note.volumeEnvelopeNode.disconnect();
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.filterEnvelopeNode.frequency
1242
- .cancelScheduledValues(endTime)
1243
- .setTargetAtTime(note.adjustedBaseFreq, endTime, note.voiceParams.modRelease * releaseCurve);
1244
- note.volumeEnvelopeNode.gain
1245
- .cancelScheduledValues(endTime)
1246
- .setTargetAtTime(0, endTime, volDuration * releaseCurve);
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
- this.scheduleTask(() => {
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
- }, volRelease);
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: (_channel, note, scheduleTime) => {
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
- channel.state.sustainPedal = value / 127;
2576
+ const state = channel.state;
2577
+ const prevValue = state.sustainPedal;
2578
+ state.sustainPedal = value / 127;
1582
2579
  if (64 <= value) {
1583
- this.processScheduledNotes(channel, (note) => {
1584
- channel.sustainNotes.push(note);
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
- .cancelScheduledValues(scheduleTime)
1805
- .setValueAtTime(value * value, scheduleTime);
2804
+ .cancelAndHoldAtTime(scheduleTime)
2805
+ .setTargetAtTime(value * value, scheduleTime, timeConstant);
1806
2806
  }
1807
2807
  handleSysEx(data, scheduleTime) {
1808
2808
  switch (data[0]) {