jevstrudel.git / packages / soundfonts / fontloader.mjs
1import { noteToMidi, freqToMidi, getSoundIndex } from '@strudel/core';
2import {
3  getAudioContext,
4  registerSound,
5  getParamADSR,
6  getADSRValues,
7  getPitchEnvelope,
8  getVibratoOscillator,
9  onceEnded,
10  releaseAudioNode,
11} from '@strudel/webaudio';
12import gm from './gm.mjs';
13
14let defaultSoundfontUrl = 'https://felixroos.github.io/webaudiofontdata/sound';
15let soundfontUrl = defaultSoundfontUrl;
16
17export function setSoundfontUrl(value) {
18  soundfontUrl = value;
19}
20
21let loadCache = {};
22async function loadFont(name) {
23  if (loadCache[name]) {
24    return loadCache[name];
25  }
26  const load = async () => {
27    // TODO: make soundfont source configurable
28    const url = `${soundfontUrl}/${name}.js`;
29    const preset = await fetch(url).then((res) => res.text());
30    let [_, data] = preset.split('={');
31    return eval('{' + data);
32  };
33  loadCache[name] = load();
34  return loadCache[name];
35}
36
37export async function getFontBufferSource(name, value, ac) {
38  let { note = 'c3', freq } = value;
39  let midi;
40  if (freq) {
41    midi = freqToMidi(freq);
42  } else if (typeof note === 'string') {
43    midi = noteToMidi(note);
44  } else if (typeof note === 'number') {
45    midi = note;
46  } else {
47    throw new Error(`unexpected "note" type "${typeof note}"`);
48  }
49
50  const { buffer, zone } = await getFontPitch(name, midi, ac);
51  const src = ac.createBufferSource();
52  src.buffer = buffer;
53  const baseDetune = zone.originalPitch - 100.0 * zone.coarseTune - zone.fineTune;
54  const playbackRate = 1.0 * Math.pow(2, (100.0 * midi - baseDetune) / 1200.0);
55  // src detune?
56  src.playbackRate.value = playbackRate;
57  const loop = zone.loopStart > 1 && zone.loopStart < zone.loopEnd;
58  if (!loop) {
59    /* const waveDuration = duration + this.afterTime;
60          if (waveDuration > zone.buffer.duration / playbackRate) {
61            waveDuration = zone.buffer.duration / playbackRate;
62            // TODO: do sth with waveduration
63          } */
64  } else {
65    src.loop = true;
66    src.loopStart = zone.loopStart / zone.sampleRate;
67    src.loopEnd = zone.loopEnd / zone.sampleRate;
68    //+ (zone.delay ? zone.delay : 0);
69  }
70  return src;
71}
72
73let bufferCache = {};
74export async function getFontPitch(name, pitch, ac) {
75  const key = `${name}:::${pitch}`;
76  if (bufferCache[key]) {
77    return bufferCache[key];
78  }
79  // console.log('load buffer', key);
80  const load = async () => {
81    const preset = await loadFont(name);
82    if (!preset) {
83      throw new Error(`Could not load soundfont ${name}`);
84    }
85    const zone = findZone(preset, pitch);
86    if (!zone) {
87      throw new Error('no soundfont zone found for preset ', name, 'pitch', pitch);
88    }
89    const buffer = await getBuffer(zone, ac);
90    if (!buffer) {
91      throw new Error(`no soundfont buffer found for preset ${name}, pitch: ${pitch}`);
92    }
93    return { buffer, zone };
94  };
95  bufferCache[key] = load(); // dont await here to cache promise immediately!
96  return bufferCache[key];
97}
98
99function findZone(preset, pitch) {
100  return preset.find((zone) => {
101    return zone.keyRangeLow <= pitch && zone.keyRangeHigh + 1 >= pitch;
102  });
103}
104
105// promisified version of https://github.com/felixroos/webaudiofont/blob/c6f97249b60dcfafc20fca5bb381294a6b2f8f51/npm/dist/WebAudioFontPlayer.js#L740
106async function getBuffer(zone, audioContext) {
107  if (zone.sample) {
108    console.warn('zone.sample untested!');
109    const decoded = atob(zone.sample);
110    zone.buffer = audioContext.createBuffer(1, decoded.length / 2, zone.sampleRate);
111    const float32Array = zone.buffer.getChannelData(0);
112    let b1, b2, n;
113    for (var i = 0; i < decoded.length / 2; i++) {
114      b1 = decoded.charCodeAt(i * 2);
115      b2 = decoded.charCodeAt(i * 2 + 1);
116      if (b1 < 0) {
117        b1 = 256 + b1;
118      }
119      if (b2 < 0) {
120        b2 = 256 + b2;
121      }
122      n = b2 * 256 + b1;
123      if (n >= 65536 / 2) {
124        n = n - 65536;
125      }
126      float32Array[i] = n / 65536.0;
127    }
128  } else {
129    if (zone.file) {
130      const datalen = zone.file.length;
131      const arraybuffer = new ArrayBuffer(datalen);
132      const view = new Uint8Array(arraybuffer);
133      const decoded = atob(zone.file);
134      let b;
135      for (let i = 0; i < decoded.length; i++) {
136        b = decoded.charCodeAt(i);
137        view[i] = b;
138      }
139      return new Promise((resolve) => audioContext.decodeAudioData(arraybuffer, resolve));
140    }
141  }
142}
143
144export function registerSoundfonts() {
145  Object.entries(gm).forEach(([name, fonts]) => {
146    registerSound(
147      name,
148      async (time, value, onended) => {
149        const [attack, decay, sustain, release] = getADSRValues([
150          value.attack,
151          value.decay,
152          value.sustain,
153          value.release,
154        ]);
155
156        const { duration } = value;
157        const n = getSoundIndex(value.n, fonts.length);
158        const font = fonts[n];
159        const ctx = getAudioContext();
160        const bufferSource = await getFontBufferSource(font, value, ctx);
161        bufferSource.start(time);
162        const envGain = ctx.createGain();
163        const node = bufferSource.connect(envGain);
164        const holdEnd = time + duration;
165        getParamADSR(node.gain, attack, decay, sustain, release, 0, 0.3, time, holdEnd, 'linear');
166        let envEnd = holdEnd + release + 0.01;
167
168        // vibrato
169        const vibratoHandle = getVibratoOscillator(bufferSource.detune, value, time);
170        // pitch envelope
171        getPitchEnvelope(bufferSource.detune, value, time, holdEnd);
172
173        bufferSource.stop(envEnd);
174        const stop = (releaseTime) => {};
175        onceEnded(bufferSource, () => {
176          releaseAudioNode(bufferSource);
177          vibratoHandle?.stop();
178          onended();
179        });
180        return { node, stop, nodes: { source: [bufferSource], ...vibratoHandle?.nodes } };
181      },
182      { type: 'soundfont', prebake: true, fonts },
183    );
184  });
185}