From 75f58bc875fb7f8d8ed555543c94e89e78f6e72c Mon Sep 17 00:00:00 2001 From: Tijmen Zwaan Date: Thu, 13 Aug 2026 19:24:53 +0200 Subject: [PATCH 1/2] Fix wav export on firefox --- packages/webaudio/webaudio.mjs | 64 ++++++++++++++++++++++------------ 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/packages/webaudio/webaudio.mjs b/packages/webaudio/webaudio.mjs index c9bedc26d..54ee9bcb3 100644 --- a/packages/webaudio/webaudio.mjs +++ b/packages/webaudio/webaudio.mjs @@ -57,7 +57,13 @@ export async function renderPatternAudio( multiChannelOrbits, }); - return renderPatternAudioInChunks(audioContext, pattern, cps, begin, end, 1) + // Firefox currently doesn't support suspending an OfflineAudioContext, + // so no chunked rendering. Bad performance, but at least it works. + return ( + audioContext.suspend === undefined + ? renderPatternAudioWhole(audioContext, pattern, cps, begin, end) + : renderPatternAudioInChunks(audioContext, pattern, cps, begin, end, 1) + ) .then((renderedBuffer) => { const wavBuffer = audioBufferToWav(renderedBuffer); const blob = new Blob([wavBuffer], { type: 'audio/wav' }); @@ -78,6 +84,16 @@ export async function renderPatternAudio( }); } +async function renderPatternAudioWhole(audioContext, pattern, cps, begin, end) { + logger(`[webaudio] preloading`); + + await scheduleHapsChunk(pattern, cps, begin, begin, end); + + logger('[webaudio] start rendering'); + + return audioContext.startRendering(); +} + async function renderPatternAudioInChunks(audioContext, pattern, cps, begin, end, chunkSizeInCycles) { let currentCycle = begin; let renderPromise = null; @@ -90,27 +106,7 @@ async function renderPatternAudioInChunks(audioContext, pattern, cps, begin, end logger(`[webaudio] preloading cycles ${chunkStart} - ${chunkEnd}`); - // Calling superdough(...) in ascending onset time order is important - // for controls that depend on the audio graph state like `cut` - let haps = pattern - .queryArc(chunkStart, chunkEnd, { _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'); - } - } - } + await scheduleHapsChunk(pattern, cps, begin, chunkStart, chunkEnd); logger(`[webaudio] rendering cycles ${chunkStart} - ${chunkEnd}`); @@ -142,6 +138,30 @@ async function renderPatternAudioInChunks(audioContext, pattern, cps, begin, end return renderPromise; } +async function scheduleHapsChunk(pattern, cps, begin, chunkStart, chunkEnd) { + // Calling superdough(...) in ascending onset time order is important + // for controls that depend on the audio graph state like `cut` + let haps = pattern + .queryArc(chunkStart, chunkEnd, { _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'); + } + } + } +} + export function webaudioRepl(options = {}) { const audioContext = options.audioContext ?? getAudioContext(); setAudioContext(audioContext); From c5b42b55557a812922ab0ac7ab9b54beecd9c661 Mon Sep 17 00:00:00 2001 From: Tijmen Zwaan Date: Thu, 13 Aug 2026 19:26:52 +0200 Subject: [PATCH 2/2] Fix webAudioTimeout connecting the wrong AudioContext after export --- packages/superdough/audioContext.mjs | 3 +++ packages/superdough/helpers.mjs | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/superdough/audioContext.mjs b/packages/superdough/audioContext.mjs index 1c708e814..6a701d9ad 100644 --- a/packages/superdough/audioContext.mjs +++ b/packages/superdough/audioContext.mjs @@ -19,6 +19,9 @@ export const setAudioContext = (context) => { // Existing nodes in the node pool contain references to the previous AudioContext, // so all the nodes in the pool must be cleared when we set a new AudioContext. clearNodePool(); + if (audioContext && audioContext.state !== 'closed') { + audioContext.close(); + } audioContext = context; return audioContext; }; diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index 07199aff6..7431bf530 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -6,8 +6,8 @@ import { clamp, nanFallback, midiToFreq, noteToMidi } from './util.mjs'; export const noises = ['pink', 'white', 'brown', 'crackle']; -export function gainNode(value) { - const node = getAudioContext().createGain(); +export function gainNode(value, audioContext = getAudioContext()) { + const node = audioContext.createGain(); node.gain.value = value; return node; } @@ -374,7 +374,7 @@ export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) { // Certain browsers requires audio nodes to be connected in order for their onended events // to fire, so we _mute it_ and then connect it to the destination - const zeroGain = gainNode(0); + const zeroGain = gainNode(0, audioContext); zeroGain.connect(audioContext.destination); constantNode.connect(zeroGain);