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