@marmooo/midy 0.4.9 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -1
- package/esm/midy-GM1.d.ts +63 -9
- package/esm/midy-GM1.d.ts.map +1 -1
- package/esm/midy-GM1.js +1094 -94
- package/esm/midy-GM2.d.ts +74 -24
- package/esm/midy-GM2.d.ts.map +1 -1
- package/esm/midy-GM2.js +1298 -234
- package/esm/midy-GMLite.d.ts +63 -8
- package/esm/midy-GMLite.d.ts.map +1 -1
- package/esm/midy-GMLite.js +1099 -92
- package/esm/midy.d.ts +49 -30
- package/esm/midy.d.ts.map +1 -1
- package/esm/midy.js +1310 -248
- package/esm/reverb.d.ts +58 -0
- package/esm/reverb.d.ts.map +1 -0
- package/esm/reverb.js +389 -0
- package/package.json +1 -1
- package/script/midy-GM1.d.ts +63 -9
- package/script/midy-GM1.d.ts.map +1 -1
- package/script/midy-GM1.js +1094 -94
- package/script/midy-GM2.d.ts +74 -24
- package/script/midy-GM2.d.ts.map +1 -1
- package/script/midy-GM2.js +1298 -234
- package/script/midy-GMLite.d.ts +63 -8
- package/script/midy-GMLite.d.ts.map +1 -1
- package/script/midy-GMLite.js +1099 -92
- package/script/midy.d.ts +49 -30
- package/script/midy.d.ts.map +1 -1
- package/script/midy.js +1310 -248
- package/script/reverb.d.ts +58 -0
- package/script/reverb.d.ts.map +1 -0
- package/script/reverb.js +405 -0
package/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 **
|
|
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,
|
|
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
|
-
|
|
961
|
+
const isLoop = isRendered
|
|
962
|
+
? renderedOrRaw.isLoop
|
|
963
|
+
: voiceParams.sampleModes % 2 !== 0;
|
|
964
|
+
bufferSource.loop = isLoop;
|
|
638
965
|
if (bufferSource.loop) {
|
|
639
|
-
|
|
640
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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
|
|
1113
|
-
if (
|
|
1114
|
-
return
|
|
1115
|
-
const
|
|
1116
|
-
this.
|
|
1117
|
-
|
|
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(
|
|
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(
|
|
1920
|
+
this.voiceCache.delete(cacheKey);
|
|
1125
1921
|
}
|
|
1126
1922
|
return cache.audioBuffer;
|
|
1127
1923
|
}
|
|
1128
1924
|
else {
|
|
1129
|
-
const maxCount = this.voiceCounter.get(
|
|
1130
|
-
const
|
|
1131
|
-
const
|
|
1132
|
-
|
|
1133
|
-
|
|
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,
|
|
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.
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
this.
|
|
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
|
-
|
|
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
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
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
|
|
1196
|
-
|
|
1197
|
-
|
|
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
|
-
|
|
1201
|
-
note
|
|
1202
|
-
|
|
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
|
|
1227
|
-
note.volumeEnvelopeNode
|
|
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.
|
|
1239
|
-
.
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
.
|
|
1243
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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: (
|
|
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
|
-
|
|
2573
|
+
const state = channel.state;
|
|
2574
|
+
const prevValue = state.sustainPedal;
|
|
2575
|
+
state.sustainPedal = value / 127;
|
|
1579
2576
|
if (64 <= value) {
|
|
1580
|
-
|
|
1581
|
-
|
|
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
|
-
.
|
|
1802
|
-
.
|
|
2801
|
+
.cancelAndHoldAtTime(scheduleTime)
|
|
2802
|
+
.setTargetAtTime(value * value, scheduleTime, timeConstant);
|
|
1803
2803
|
}
|
|
1804
2804
|
handleSysEx(data, scheduleTime) {
|
|
1805
2805
|
switch (data[0]) {
|