diff --git a/packages/codemirror/codemirror.mjs b/packages/codemirror/codemirror.mjs index 69fce4b50..a612ab610 100644 --- a/packages/codemirror/codemirror.mjs +++ b/packages/codemirror/codemirror.mjs @@ -293,9 +293,9 @@ export class StrudelMirror { console.warn('first frame could not be painted'); } } - async evaluate() { + async evaluate(autostart = true) { this.flash(); - await this.repl.evaluate(this.code); + await this.repl.evaluate(this.code, autostart); } async stop() { this.repl.scheduler.stop(); diff --git a/packages/superdough/audioContext.mjs b/packages/superdough/audioContext.mjs index 71e01d57d..2c34f9daf 100644 --- a/packages/superdough/audioContext.mjs +++ b/packages/superdough/audioContext.mjs @@ -5,6 +5,11 @@ export const setDefaultAudioContext = () => { return audioContext; }; +export const setAudioContext = (context) => { + audioContext = context; + return audioContext; +}; + export const getAudioContext = () => { if (!audioContext) { return setDefaultAudioContext(); diff --git a/packages/superdough/feedbackdelay.mjs b/packages/superdough/feedbackdelay.mjs index c182d6558..b8269bc02 100644 --- a/packages/superdough/feedbackdelay.mjs +++ b/packages/superdough/feedbackdelay.mjs @@ -25,7 +25,7 @@ if (typeof DelayNode !== 'undefined') { } } - AudioContext.prototype.createFeedbackDelay = function (wet, time, feedback) { + BaseAudioContext.prototype.createFeedbackDelay = function (wet, time, feedback) { return new FeedbackDelayNode(this, wet, time, feedback); }; } diff --git a/packages/superdough/reverb.mjs b/packages/superdough/reverb.mjs index 2960b597c..c8b6eb3e5 100644 --- a/packages/superdough/reverb.mjs +++ b/packages/superdough/reverb.mjs @@ -2,7 +2,7 @@ import reverbGen from './reverbGen.mjs'; import { clamp } from './util.mjs'; if (typeof AudioContext !== 'undefined') { - AudioContext.prototype.adjustLength = function (duration, buffer, speed = 1, offsetAmount = 0) { + BaseAudioContext.prototype.adjustLength = function (duration, buffer, speed = 1, offsetAmount = 0) { const sampleOffset = Math.floor(clamp(offsetAmount, 0, 1) * buffer.length); const newLength = buffer.sampleRate * duration; const newBuffer = this.createBuffer(buffer.numberOfChannels, buffer.length, buffer.sampleRate); @@ -23,7 +23,7 @@ if (typeof AudioContext !== 'undefined') { return newBuffer; }; - AudioContext.prototype.createReverb = function (duration, fade, lp, dim, ir, irspeed, irbegin) { + BaseAudioContext.prototype.createReverb = function (duration, fade, lp, dim, ir, irspeed, irbegin) { const convolver = this.createConvolver(); convolver.generate = (d = 2, fade = 0.1, lp = 15000, dim = 1000, ir, irspeed, irbegin) => { convolver.duration = d; diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 6765923e4..4fd6164dc 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -22,13 +22,14 @@ import { import { map } from 'nanostores'; import { logger } from './logger.mjs'; import { loadBuffer } from './sampler.mjs'; -import { getAudioContext } from './audioContext.mjs'; +import { getAudioContext, setAudioContext } from './audioContext.mjs'; import { SuperdoughAudioController } from './superdoughoutput.mjs'; +import { resetSeenKeys } from './wavetable.mjs'; export const DEFAULT_MAX_POLYPHONY = 128; const DEFAULT_AUDIO_DEVICE_NAME = 'System Standard'; -let maxPolyphony = DEFAULT_MAX_POLYPHONY; +export let maxPolyphony = DEFAULT_MAX_POLYPHONY; /** * Set the max polyphony. If notes are ringing out via `release` then they will @@ -45,7 +46,7 @@ export function setMaxPolyphony(polyphony) { maxPolyphony = parseInt(polyphony) ?? DEFAULT_MAX_POLYPHONY; } -let multiChannelOrbits = false; +export let multiChannelOrbits = false; export function setMultiChannelOrbits(bool) { multiChannelOrbits = bool == true; } @@ -234,11 +235,13 @@ export function registerWorklet(url) { } let workletsLoading; -function loadWorklets() { +export function loadWorklets() { if (!workletsLoading) { const audioCtx = getAudioContext(); const allWorkletURLs = externalWorklets.concat([workletsUrl]); - workletsLoading = Promise.all(allWorkletURLs.map((workletURL) => audioCtx.audioWorklet.addModule(workletURL))); + workletsLoading = Promise.all(allWorkletURLs.map((workletURL) => audioCtx.audioWorklet.addModule(workletURL))).then( + () => (workletsLoading = undefined), + ); } return workletsLoading; @@ -255,6 +258,7 @@ export async function initAudio(options = {}) { setMaxPolyphony(maxPolyphony); setMultiChannelOrbits(multiChannelOrbits); + resetSeenKeys(); if (typeof window === 'undefined') { return; } @@ -276,8 +280,9 @@ export async function initAudio(options = {}) { logger('[superdough] failed to set audio interface', 'warning'); } } - - await audioCtx.resume(); + if ((!audioCtx) instanceof OfflineAudioContext) { + await audioCtx.resume(); + } if (disableWorklets) { logger('[superdough]: AudioWorklets disabled with disableWorklets'); return; @@ -311,6 +316,12 @@ export function getSuperdoughAudioController() { } return controller; } + +export function setSuperdoughAudioController(newController) { + controller = newController; + return controller; +} + export function connectToDestination(input, channels) { const controller = getSuperdoughAudioController(); controller.output.connectToDestination(input, channels); @@ -348,7 +359,7 @@ export let analysers = {}, analysersData = {}; export function getAnalyserById(id, fftSize = 1024, smoothingTimeConstant = 0.5) { - if (!analysers[id]) { + if (!analysers[id] || analysers[id].audioContext != getAudioContext()) { // make sure this doesn't happen too often as it piles up garbage const analyserNode = getAudioContext().createAnalyser(); analyserNode.fftSize = fftSize; @@ -410,7 +421,6 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) // duration is passed as value too.. value.duration = hapDuration; // calculate absolute time - if (t < ac.currentTime) { console.warn( `[superdough]: cannot schedule sounds in the past (target: ${t.toFixed(2)}, now: ${ac.currentTime.toFixed(2)})`, @@ -782,7 +792,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) } // analyser - if (analyze) { + if (analyze && !(ac instanceof OfflineAudioContext)) { const analyserNode = getAnalyserById(analyze, 2 ** (fft + 5)); const analyserSend = effectSend(post, analyserNode, 1); audioNodes.push(analyserSend); diff --git a/packages/superdough/vowel.mjs b/packages/superdough/vowel.mjs index fda0c6479..bfc1c9fcd 100644 --- a/packages/superdough/vowel.mjs +++ b/packages/superdough/vowel.mjs @@ -75,7 +75,7 @@ if (typeof GainNode !== 'undefined') { } } - AudioContext.prototype.createVowelFilter = function (letter) { + BaseAudioContext.prototype.createVowelFilter = function (letter) { return new VowelNode(this, letter); }; } diff --git a/packages/superdough/wavetable.mjs b/packages/superdough/wavetable.mjs index 4311c5cab..ebf951a49 100644 --- a/packages/superdough/wavetable.mjs +++ b/packages/superdough/wavetable.mjs @@ -40,6 +40,11 @@ export const Warpmode = Object.freeze({ }); const seenKeys = new Set(); + +export function resetSeenKeys() { + seenKeys.clear(); +} + async function getPayload(url, label, frameLen = 2048) { const key = `${url},${frameLen}`; if (!seenKeys.has(key)) { diff --git a/packages/webaudio/webaudio.mjs b/packages/webaudio/webaudio.mjs index 383e87f87..36ee9d645 100644 --- a/packages/webaudio/webaudio.mjs +++ b/packages/webaudio/webaudio.mjs @@ -5,10 +5,21 @@ This program is free software: you can redistribute it and/or modify it under th */ import * as strudel from '@strudel/core'; -import { superdough, getAudioContext, setLogger, doughTrigger, registerWorklet } from 'superdough'; +import { + superdough, + getAudioContext, + setLogger, + doughTrigger, + registerWorklet, + setAudioContext, + initAudio, + setSuperdoughAudioController, + resetGlobalEffects, + errorLogger, +} from 'superdough'; import './supradough.mjs'; import { workletUrl } from 'supradough'; - +import { SuperdoughAudioController } from 'superdough/superdoughoutput.mjs'; registerWorklet(workletUrl); const { Pattern, logger, repl } = strudel; @@ -26,6 +37,71 @@ export const webaudioOutput = (hap, _deadline, hapDuration, cps, t) => { return superdough(hap2value(hap), t, hapDuration, cps, hap.whole?.begin.valueOf()); }; +export async function renderPatternAudio( + pattern, + cps, + begin, + end, + sampleRate, + maxPolyphony, + multiChannelOrbits, + downloadName = undefined, +) { + let audioContext = getAudioContext(); + await audioContext.close(); + audioContext = new OfflineAudioContext(2, ((end - begin) / cps) * sampleRate, sampleRate); + setAudioContext(audioContext); + setSuperdoughAudioController(new SuperdoughAudioController(audioContext)); + await initAudio({ + maxPolyphony, + multiChannelOrbits, + }); + logger('[webaudio] preloading'); + + // Calling superdough(...) in ascending onset time order is important + // for controls that depend on the audio graph state like `cut` + let haps = pattern + .queryArc(begin, end, { _cps: cps }) + .sort((a, b) => a.whole.begin.valueOf() - b.whole.begin.valueOf()); + for (const hap of haps) { + if (hap.hasOnset()) { + try { + await superdough( + hap2value(hap), + (hap.whole.begin.valueOf() - begin) / cps, + hap.duration / cps, + cps, + (hap.whole?.begin.valueOf() - begin) / cps, + ); + } catch (err) { + errorLogger(err, 'webaudio'); + } + } + } + logger('[webaudio] start rendering'); + + return audioContext + .startRendering() + .then((renderedBuffer) => { + const wavBuffer = audioBufferToWav(renderedBuffer); + const blob = new Blob([wavBuffer], { type: 'audio/wav' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + downloadName = downloadName ? `${downloadName}.wav` : `${new Date().toISOString()}.wav`; + a.download = `${downloadName}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }) + .finally(async () => { + setAudioContext(null); + setSuperdoughAudioController(null); + resetGlobalEffects(); + }); +} + export function webaudioRepl(options = {}) { options = { getTime: () => getAudioContext().currentTime, @@ -38,3 +114,98 @@ export function webaudioRepl(options = {}) { Pattern.prototype.dough = function () { return this.onTrigger(doughTrigger, 1); }; + +function audioBufferToWav(buffer, opt) { + opt = opt || {}; + + var numChannels = buffer.numberOfChannels; + var sampleRate = buffer.sampleRate; + var format = opt.float32 ? 3 : 1; + var bitDepth = format === 3 ? 32 : 16; + + var result; + if (numChannels === 2) { + result = interleave(buffer.getChannelData(0), buffer.getChannelData(1)); + } else { + result = buffer.getChannelData(0); + } + + return encodeWAV(result, format, sampleRate, numChannels, bitDepth); +} + +function encodeWAV(samples, format, sampleRate, numChannels, bitDepth) { + var bytesPerSample = bitDepth / 8; + var blockAlign = numChannels * bytesPerSample; + + var buffer = new ArrayBuffer(44 + samples.length * bytesPerSample); + var view = new DataView(buffer); + + /* RIFF identifier */ + writeString(view, 0, 'RIFF'); + /* RIFF chunk length */ + view.setUint32(4, 36 + samples.length * bytesPerSample, true); + /* RIFF type */ + writeString(view, 8, 'WAVE'); + /* format chunk identifier */ + writeString(view, 12, 'fmt '); + /* format chunk length */ + view.setUint32(16, 16, true); + /* sample format (raw) */ + view.setUint16(20, format, true); + /* channel count */ + view.setUint16(22, numChannels, true); + /* sample rate */ + view.setUint32(24, sampleRate, true); + /* byte rate (sample rate * block align) */ + view.setUint32(28, sampleRate * blockAlign, true); + /* block align (channel count * bytes per sample) */ + view.setUint16(32, blockAlign, true); + /* bits per sample */ + view.setUint16(34, bitDepth, true); + /* data chunk identifier */ + writeString(view, 36, 'data'); + /* data chunk length */ + view.setUint32(40, samples.length * bytesPerSample, true); + if (format === 1) { + // Raw PCM + floatTo16BitPCM(view, 44, samples); + } else { + writeFloat32(view, 44, samples); + } + + return buffer; +} + +function interleave(inputL, inputR) { + var length = inputL.length + inputR.length; + var result = new Float32Array(length); + + var index = 0; + var inputIndex = 0; + + while (index < length) { + result[index++] = inputL[inputIndex]; + result[index++] = inputR[inputIndex]; + inputIndex++; + } + return result; +} + +function writeFloat32(output, offset, input) { + for (var i = 0; i < input.length; i++, offset += 4) { + output.setFloat32(offset, input[i], true); + } +} + +function floatTo16BitPCM(output, offset, input) { + for (var i = 0; i < input.length; i++, offset += 2) { + var s = Math.max(-1, Math.min(1, input[i])); + output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true); + } +} + +function writeString(view, offset, string) { + for (var i = 0; i < string.length; i++) { + view.setUint8(offset + i, string.charCodeAt(i)); + } +} diff --git a/website/src/repl/components/panel/ExportTab.jsx b/website/src/repl/components/panel/ExportTab.jsx new file mode 100644 index 000000000..9671ff1a3 --- /dev/null +++ b/website/src/repl/components/panel/ExportTab.jsx @@ -0,0 +1,197 @@ +import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon'; +import cx from '@src/cx.mjs'; +import NumberInput from '@src/repl/components/NumberInput'; +import { useEffect, useState } from 'react'; +import { Textbox } from '../textbox/Textbox'; +import { getAudioContext } from '@strudel/webaudio'; +import XMarkIcon from '@heroicons/react/24/outline/XMarkIcon'; + +function Checkbox({ label, value, onChange, disabled = false }) { + return ( + + ); +} + +function FormItem({ label, children, disabled }) { + return ( +