diff --git a/packages/superdough/audioContext.mjs b/packages/superdough/audioContext.mjs
index 94ec32d15..1c708e814 100644
--- a/packages/superdough/audioContext.mjs
+++ b/packages/superdough/audioContext.mjs
@@ -7,14 +7,18 @@ Copyright (C) 2025 Strudel contributors - see .
*/
+import { clearNodePool } from './nodePools.mjs';
+
let audioContext;
export const setDefaultAudioContext = () => {
- audioContext = new AudioContext();
- return audioContext;
+ return setAudioContext(new AudioContext());
};
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();
audioContext = context;
return audioContext;
};
diff --git a/packages/superdough/nodePools.mjs b/packages/superdough/nodePools.mjs
index 90b4c0ae7..66319e8c6 100644
--- a/packages/superdough/nodePools.mjs
+++ b/packages/superdough/nodePools.mjs
@@ -5,11 +5,24 @@ Copyright (C) 2025 Strudel contributors - see .
*/
+import { releaseAudioNode } from './helpers.mjs';
+
const nodePools = new Map();
const POOL_KEY = Symbol('nodePoolKey');
export const isPoolable = (node) => !!node[POOL_KEY];
+export const clearNodePool = () => {
+ for (const pool of nodePools) {
+ for (const node of pool) {
+ if (node instanceof AudioNode) {
+ releaseAudioNode(node);
+ }
+ }
+ }
+ nodePools.clear();
+};
+
const getNodeTime = (node) => {
return node.context?.currentTime ?? 0;
};
diff --git a/packages/webaudio/webaudio.mjs b/packages/webaudio/webaudio.mjs
index 5377013e2..c9bedc26d 100644
--- a/packages/webaudio/webaudio.mjs
+++ b/packages/webaudio/webaudio.mjs
@@ -56,32 +56,8 @@ export async function renderPatternAudio(
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()
+ return renderPatternAudioInChunks(audioContext, pattern, cps, begin, end, 1)
.then((renderedBuffer) => {
const wavBuffer = audioBufferToWav(renderedBuffer);
const blob = new Blob([wavBuffer], { type: 'audio/wav' });
@@ -102,6 +78,70 @@ export async function renderPatternAudio(
});
}
+async function renderPatternAudioInChunks(audioContext, pattern, cps, begin, end, chunkSizeInCycles) {
+ let currentCycle = begin;
+ let renderPromise = null;
+
+ logger('[webaudio] start rendering');
+
+ while (currentCycle <= end) {
+ const chunkStart = currentCycle;
+ const chunkEnd = Math.min(currentCycle + chunkSizeInCycles, 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');
+ }
+ }
+ }
+
+ logger(`[webaudio] rendering cycles ${chunkStart} - ${chunkEnd}`);
+
+ 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.
+ var suspendPromise;
+ if (currentCycle < end) {
+ // Make sure to suspend one cycle before the next currentCycle
+ // so the next haps can be scheduled on time.
+ suspendPromise = audioContext.suspend((currentCycle - begin - 1) / cps);
+ }
+
+ if (renderPromise === null) {
+ renderPromise = audioContext.startRendering();
+ } else {
+ await audioContext.resume();
+ }
+
+ if (currentCycle < end) {
+ await suspendPromise;
+ }
+ }
+
+ logger('[webaudio] finish rendering');
+
+ return renderPromise;
+}
+
export function webaudioRepl(options = {}) {
const audioContext = options.audioContext ?? getAudioContext();
setAudioContext(audioContext);