From c8b2e1ac9e529333f72cb4ac8e87fe80cdf82a6e Mon Sep 17 00:00:00 2001 From: 1d10t <1d10t@noreply.codeberg.org> Date: Fri, 12 Dec 2025 21:22:21 +0100 Subject: [PATCH 1/7] feat(superdough/audiocontext): make AudioContext injection optional - Modify `setDefaultAudioContext()` to accept an optional `existingAudioCtx` parameter. - If provided, it will use the existing context; otherwise, it creates a new one. - This allows for better integration with external audio systems that manage their own AudioContext. --- packages/superdough/audioContext.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/superdough/audioContext.mjs b/packages/superdough/audioContext.mjs index 71e01d57d..9bae75bd9 100644 --- a/packages/superdough/audioContext.mjs +++ b/packages/superdough/audioContext.mjs @@ -1,7 +1,7 @@ let audioContext; -export const setDefaultAudioContext = () => { - audioContext = new AudioContext(); +export const setDefaultAudioContext = (existingAudioCtx) => { + audioContext = existingAudioCtx ?? new AudioContext(); return audioContext; }; From 4e14718e93b7d9a5ebe7fb1892109745577b8bc7 Mon Sep 17 00:00:00 2001 From: 1d10t <1d10t@noreply.codeberg.org> Date: Fri, 12 Dec 2025 21:56:31 +0100 Subject: [PATCH 2/7] feat(webaudio): enable AudioContext sharing for webaudioRepl - Add support for optional `existingAudioCtx` parameter in `webaudioRepl()` function - When provided, sets it as the default AudioContext via `setDefaultAudioContext()` - Allows integration with external systems that manage their own Web Audio API context - Maintains backward compatibility with existing usage patterns --- packages/webaudio/webaudio.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/webaudio/webaudio.mjs b/packages/webaudio/webaudio.mjs index 383e87f87..8fc9b860f 100644 --- a/packages/webaudio/webaudio.mjs +++ b/packages/webaudio/webaudio.mjs @@ -5,7 +5,7 @@ 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, setDefaultAudioContext, getAudioContext, setLogger, doughTrigger, registerWorklet } from 'superdough'; import './supradough.mjs'; import { workletUrl } from 'supradough'; @@ -27,6 +27,9 @@ export const webaudioOutput = (hap, _deadline, hapDuration, cps, t) => { }; export function webaudioRepl(options = {}) { + if (options.existingAudioCtx) { + setDefaultAudioContext(options.existingAudioCtx); + } options = { getTime: () => getAudioContext().currentTime, defaultOutput: webaudioOutput, From adf9596d070eb33ee8c52726cbd10fef7dea0141 Mon Sep 17 00:00:00 2001 From: Sergey S Yaglov Date: Sat, 13 Dec 2025 00:28:13 +0300 Subject: [PATCH 3/7] codestyle(webaudio) --- packages/webaudio/webaudio.mjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/webaudio/webaudio.mjs b/packages/webaudio/webaudio.mjs index 8fc9b860f..399e68ab1 100644 --- a/packages/webaudio/webaudio.mjs +++ b/packages/webaudio/webaudio.mjs @@ -5,7 +5,14 @@ This program is free software: you can redistribute it and/or modify it under th */ import * as strudel from '@strudel/core'; -import { superdough, setDefaultAudioContext, getAudioContext, setLogger, doughTrigger, registerWorklet } from 'superdough'; +import { + superdough, + setDefaultAudioContext, + getAudioContext, + setLogger, + doughTrigger, + registerWorklet, +} from 'superdough'; import './supradough.mjs'; import { workletUrl } from 'supradough'; From a86e5751eb9d21365f64f1deece2a31a4a232e03 Mon Sep 17 00:00:00 2001 From: 1d10t <1d10t@noreply.codeberg.org> Date: Sat, 3 Jan 2026 21:55:16 +0100 Subject: [PATCH 4/7] reset audioContext to main branch --- packages/superdough/audioContext.mjs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/superdough/audioContext.mjs b/packages/superdough/audioContext.mjs index 9bae75bd9..2c34f9daf 100644 --- a/packages/superdough/audioContext.mjs +++ b/packages/superdough/audioContext.mjs @@ -1,7 +1,12 @@ let audioContext; -export const setDefaultAudioContext = (existingAudioCtx) => { - audioContext = existingAudioCtx ?? new AudioContext(); +export const setDefaultAudioContext = () => { + audioContext = new AudioContext(); + return audioContext; +}; + +export const setAudioContext = (context) => { + audioContext = context; return audioContext; }; From aa7ba6ae99c76944f9d6932c7e5a68a71980a7f6 Mon Sep 17 00:00:00 2001 From: 1d10t <1d10t@noreply.codeberg.org> Date: Sat, 3 Jan 2026 22:01:37 +0100 Subject: [PATCH 5/7] update webaudio to main branch --- packages/webaudio/webaudio.mjs | 175 +++++++++++++++++++++++++++++++-- 1 file changed, 169 insertions(+), 6 deletions(-) diff --git a/packages/webaudio/webaudio.mjs b/packages/webaudio/webaudio.mjs index 399e68ab1..5377013e2 100644 --- a/packages/webaudio/webaudio.mjs +++ b/packages/webaudio/webaudio.mjs @@ -7,15 +7,19 @@ This program is free software: you can redistribute it and/or modify it under th import * as strudel from '@strudel/core'; import { superdough, - setDefaultAudioContext, 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; @@ -33,12 +37,76 @@ export const webaudioOutput = (hap, _deadline, hapDuration, cps, t) => { return superdough(hap2value(hap), t, hapDuration, cps, hap.whole?.begin.valueOf()); }; -export function webaudioRepl(options = {}) { - if (options.existingAudioCtx) { - setDefaultAudioContext(options.existingAudioCtx); +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 = {}) { + const audioContext = options.audioContext ?? getAudioContext(); + setAudioContext(audioContext); options = { - getTime: () => getAudioContext().currentTime, + getTime: () => audioContext.currentTime, defaultOutput: webaudioOutput, ...options, }; @@ -48,3 +116,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)); + } +} From 40f9f4cde0870283d774f144d5eb3d879205b10e Mon Sep 17 00:00:00 2001 From: alienmind Date: Fri, 9 Jan 2026 15:38:52 +0100 Subject: [PATCH 6/7] allow alternative URLs for self-hosted strudel --- website/astro.config.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 151c48fc1..37904cb82 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -10,8 +10,8 @@ import bundleAudioWorkletPlugin from 'vite-plugin-bundle-audioworklet'; import tailwind from '@astrojs/tailwind'; import AstroPWA from '@vite-pwa/astro'; -const site = `https://strudel.cc/`; // root url without a path -const base = '/'; // base path of the strudel site +const site = process.env.STRUDEL_SITE || `https://strudel.cc/`; // root url without a path +const base = process.env.STRUDEL_BASE || '/'; // base path of the strudel site const baseNoTrailing = base.endsWith('/') ? base.slice(0, -1) : base; // this rehype plugin fixes relative links From eefae851d50a96d1d2280d79022ef61d319605f3 Mon Sep 17 00:00:00 2001 From: alienmind Date: Fri, 9 Jan 2026 15:40:01 +0100 Subject: [PATCH 7/7] fix malformed script src attributes --- website/src/components/HeadCommon.astro | 2 +- website/src/components/HeadCommonNext.astro | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/components/HeadCommon.astro b/website/src/components/HeadCommon.astro index 0b03c29fe..fe8d34bb2 100644 --- a/website/src/components/HeadCommon.astro +++ b/website/src/components/HeadCommon.astro @@ -25,7 +25,7 @@ const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL - + diff --git a/website/src/components/HeadCommonNext.astro b/website/src/components/HeadCommonNext.astro index 9f323a7a3..1f878bac0 100644 --- a/website/src/components/HeadCommonNext.astro +++ b/website/src/components/HeadCommonNext.astro @@ -24,7 +24,7 @@ const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL - +