jevstrudel.git / packages / webaudio / webaudio.mjs
1/*
2webaudio.mjs - <short description TODO>
3Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/webaudio/webaudio.mjs>
4This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program.  If not, see <https://www.gnu.org/licenses/>.
5*/
6
7import * as strudel from '@strudel/core';
8import {
9  superdough,
10  getAudioContext,
11  setLogger,
12  rawdspTrigger,
13  registerWorklet,
14  setAudioContext,
15  initAudio,
16  setSuperdoughAudioController,
17  resetGlobalEffects,
18  errorLogger,
19} from 'superdough';
20import './supradough.mjs';
21import { workletUrl } from 'supradough';
22import { SuperdoughAudioController } from 'superdough/superdoughoutput.mjs';
23registerWorklet(workletUrl);
24
25const { Pattern, logger, repl } = strudel;
26
27setLogger(logger);
28
29const hap2value = (hap) => {
30  hap.ensureObjectValue();
31  return hap.value;
32};
33
34// uses more precise, absolute t if available, see https://github.com/tidalcycles/strudel/pull/1004
35// TODO: refactor output callbacks to eliminate deadline
36export const webaudioOutput = (hap, _deadline, hapDuration, cps, t) => {
37  return superdough(hap2value(hap), t, hapDuration, cps, hap.whole?.begin.valueOf());
38};
39
40export async function renderPatternAudio(
41  pattern,
42  cps,
43  begin,
44  end,
45  sampleRate,
46  maxPolyphony,
47  multiChannelOrbits,
48  downloadName = undefined,
49) {
50  let audioContext = getAudioContext();
51  await audioContext.close();
52  audioContext = new OfflineAudioContext(2, ((end - begin) / cps) * sampleRate, sampleRate);
53  setAudioContext(audioContext);
54  setSuperdoughAudioController(new SuperdoughAudioController(audioContext));
55  await initAudio({
56    maxPolyphony,
57    multiChannelOrbits,
58  });
59
60  // Firefox currently doesn't support suspending an OfflineAudioContext,
61  // so no chunked rendering. Bad performance, but at least it works.
62  return (
63    audioContext.suspend === undefined
64      ? renderPatternAudioWhole(audioContext, pattern, cps, begin, end)
65      : renderPatternAudioInChunks(audioContext, pattern, cps, begin, end, 1)
66  )
67    .then((renderedBuffer) => {
68      const wavBuffer = audioBufferToWav(renderedBuffer);
69      const blob = new Blob([wavBuffer], { type: 'audio/wav' });
70      const url = URL.createObjectURL(blob);
71      const a = document.createElement('a');
72      a.href = url;
73      downloadName = downloadName ? `${downloadName}.wav` : `${new Date().toISOString()}.wav`;
74      a.download = `${downloadName}`;
75      document.body.appendChild(a);
76      a.click();
77      document.body.removeChild(a);
78      URL.revokeObjectURL(url);
79    })
80    .finally(async () => {
81      setAudioContext(null);
82      setSuperdoughAudioController(null);
83      resetGlobalEffects();
84    });
85}
86
87async function renderPatternAudioWhole(audioContext, pattern, cps, begin, end) {
88  logger(`[webaudio] preloading`);
89
90  await scheduleHapsChunk(pattern, cps, begin, begin, end);
91
92  logger('[webaudio] start rendering');
93
94  return audioContext.startRendering();
95}
96
97async function renderPatternAudioInChunks(audioContext, pattern, cps, begin, end, chunkSizeInCycles) {
98  let currentCycle = begin;
99  let renderPromise = null;
100
101  logger('[webaudio] start rendering');
102
103  while (currentCycle <= end) {
104    const chunkStart = currentCycle;
105    const chunkEnd = Math.min(currentCycle + chunkSizeInCycles, end);
106
107    logger(`[webaudio] preloading cycles ${chunkStart} - ${chunkEnd}`);
108
109    await scheduleHapsChunk(pattern, cps, begin, chunkStart, chunkEnd);
110
111    logger(`[webaudio] rendering cycles ${chunkStart} - ${chunkEnd}`);
112
113    currentCycle += chunkSizeInCycles;
114
115    // According to the MDN docs, suspends should be scheduled while
116    // the audioContext is not currently running for better precision.
117    // So we schedule the suspend first, and await after resuming.
118    var suspendPromise;
119    if (currentCycle < end) {
120      // Make sure to suspend one cycle before the next currentCycle
121      // so the next haps can be scheduled on time.
122      suspendPromise = audioContext.suspend((currentCycle - begin - 1) / cps);
123    }
124
125    if (renderPromise === null) {
126      renderPromise = audioContext.startRendering();
127    } else {
128      await audioContext.resume();
129    }
130
131    if (currentCycle < end) {
132      await suspendPromise;
133    }
134  }
135
136  logger('[webaudio] finish rendering');
137
138  return renderPromise;
139}
140
141async function scheduleHapsChunk(pattern, cps, begin, chunkStart, chunkEnd) {
142  // Calling superdough(...) in ascending onset time order is important
143  // for controls that depend on the audio graph state like `cut`
144  let haps = pattern
145    .queryArc(chunkStart, chunkEnd, { _cps: cps })
146    .sort((a, b) => a.whole.begin.valueOf() - b.whole.begin.valueOf());
147
148  for (const hap of haps) {
149    if (hap.hasOnset()) {
150      try {
151        await superdough(
152          hap2value(hap),
153          (hap.whole.begin.valueOf() - begin) / cps,
154          hap.duration / cps,
155          cps,
156          (hap.whole?.begin.valueOf() - begin) / cps,
157        );
158      } catch (err) {
159        errorLogger(err, 'webaudio');
160      }
161    }
162  }
163}
164
165export function webaudioRepl(options = {}) {
166  const audioContext = options.audioContext ?? getAudioContext();
167  setAudioContext(audioContext);
168  options = {
169    getTime: () => audioContext.currentTime,
170    defaultOutput: webaudioOutput,
171    ...options,
172  };
173  return repl(options);
174}
175
176Pattern.prototype.rawdsp = function () {
177  return this.onTrigger(rawdspTrigger, 1);
178};
179
180function audioBufferToWav(buffer, opt) {
181  opt = opt || {};
182
183  var numChannels = buffer.numberOfChannels;
184  var sampleRate = buffer.sampleRate;
185  var format = opt.float32 ? 3 : 1;
186  var bitDepth = format === 3 ? 32 : 16;
187
188  var result;
189  if (numChannels === 2) {
190    result = interleave(buffer.getChannelData(0), buffer.getChannelData(1));
191  } else {
192    result = buffer.getChannelData(0);
193  }
194
195  return encodeWAV(result, format, sampleRate, numChannels, bitDepth);
196}
197
198function encodeWAV(samples, format, sampleRate, numChannels, bitDepth) {
199  var bytesPerSample = bitDepth / 8;
200  var blockAlign = numChannels * bytesPerSample;
201
202  var buffer = new ArrayBuffer(44 + samples.length * bytesPerSample);
203  var view = new DataView(buffer);
204
205  /* RIFF identifier */
206  writeString(view, 0, 'RIFF');
207  /* RIFF chunk length */
208  view.setUint32(4, 36 + samples.length * bytesPerSample, true);
209  /* RIFF type */
210  writeString(view, 8, 'WAVE');
211  /* format chunk identifier */
212  writeString(view, 12, 'fmt ');
213  /* format chunk length */
214  view.setUint32(16, 16, true);
215  /* sample format (raw) */
216  view.setUint16(20, format, true);
217  /* channel count */
218  view.setUint16(22, numChannels, true);
219  /* sample rate */
220  view.setUint32(24, sampleRate, true);
221  /* byte rate (sample rate * block align) */
222  view.setUint32(28, sampleRate * blockAlign, true);
223  /* block align (channel count * bytes per sample) */
224  view.setUint16(32, blockAlign, true);
225  /* bits per sample */
226  view.setUint16(34, bitDepth, true);
227  /* data chunk identifier */
228  writeString(view, 36, 'data');
229  /* data chunk length */
230  view.setUint32(40, samples.length * bytesPerSample, true);
231  if (format === 1) {
232    // Raw PCM
233    floatTo16BitPCM(view, 44, samples);
234  } else {
235    writeFloat32(view, 44, samples);
236  }
237
238  return buffer;
239}
240
241function interleave(inputL, inputR) {
242  var length = inputL.length + inputR.length;
243  var result = new Float32Array(length);
244
245  var index = 0;
246  var inputIndex = 0;
247
248  while (index < length) {
249    result[index++] = inputL[inputIndex];
250    result[index++] = inputR[inputIndex];
251    inputIndex++;
252  }
253  return result;
254}
255
256function writeFloat32(output, offset, input) {
257  for (var i = 0; i < input.length; i++, offset += 4) {
258    output.setFloat32(offset, input[i], true);
259  }
260}
261
262function floatTo16BitPCM(output, offset, input) {
263  for (var i = 0; i < input.length; i++, offset += 2) {
264    var s = Math.max(-1, Math.min(1, input[i]));
265    output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
266  }
267}
268
269function writeString(view, offset, string) {
270  for (var i = 0; i < string.length; i++) {
271    view.setUint8(offset + i, string.charCodeAt(i));
272  }
273}