@marmooo/midy 0.4.9 → 0.5.1

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