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}