jevstrudel.git / packages / webaudio / webaudio.mjs

webaudio.mjs - <short description TODO> Copyright (C) 2022 Strudel contributors - see https://codeberg.org/uzu/strudel/src/branch/main/packages/webaudio/webaudio.mjs This 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/.

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);
25const { Pattern, logger, repl } = strudel;
26
27setLogger(logger);
28
29const hap2value = (hap) => {
30  hap.ensureObjectValue();
31  return hap.value;
32};

uses more precise, absolute t if available, see https://github.com/tidalcycles/strudel/pull/1004 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};
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  });

Firefox currently doesn't support suspending an OfflineAudioContext, 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}
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;

According to the MDN docs, suspends should be scheduled while the audioContext is not currently running for better precision. 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    }
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);

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  }
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}