diff --git a/packages/codemirror/codemirror.mjs b/packages/codemirror/codemirror.mjs
index 4dc23996f..645a8a190 100644
--- a/packages/codemirror/codemirror.mjs
+++ b/packages/codemirror/codemirror.mjs
@@ -42,9 +42,9 @@ const extensions = {
isMultiCursorEnabled: (on) =>
on
? [
- EditorState.allowMultipleSelections.of(true),
- EditorView.clickAddsSelectionRange.of((ev) => ev.metaKey || ev.ctrlKey),
- ]
+ EditorState.allowMultipleSelections.of(true),
+ EditorView.clickAddsSelectionRange.of((ev) => ev.metaKey || ev.ctrlKey),
+ ]
: [],
};
const compartments = Object.fromEntries(Object.keys(extensions).map((key) => [key, new Compartment()]));
@@ -268,6 +268,10 @@ export class StrudelMirror {
this.flash();
await this.repl.evaluate(this.code);
}
+ async exportAudio(begin, end) {
+ // await this.repl.evaluate(this.code, false)
+ this.repl.exportAudio(begin, end);
+ }
async stop() {
this.repl.scheduler.stop();
}
diff --git a/packages/core/cyclist.mjs b/packages/core/cyclist.mjs
index 59e410c53..b7ab7dde4 100644
--- a/packages/core/cyclist.mjs
+++ b/packages/core/cyclist.mjs
@@ -6,6 +6,7 @@ This program is free software: you can redistribute it and/or modify it under th
import createClock from './zyklus.mjs';
import { errorLogger, logger } from './logger.mjs';
+import { loadBuffer, renderPatternAudio, setAudioContext, superdough } from '@strudel/webaudio';
export class Cyclist {
constructor({
@@ -98,6 +99,7 @@ export class Cyclist {
this.started = v;
this.onToggle?.(v);
}
+
async start() {
await this.beforeStart?.();
this.num_ticks_since_cps_change = 0;
@@ -109,6 +111,17 @@ export class Cyclist {
this.clock.start();
this.setStarted(true);
}
+
+ async exportAudio(begin, end) {
+ if (!this.pattern) {
+ throw new Error('Scheduler: no pattern set! call .setPattern first.');
+ }
+ logger('[cyclist] exporting');
+ // this.clock.start();
+ // this.setStarted(true);
+ await renderPatternAudio(this.pattern, this.cps, begin, end)
+ }
+
pause() {
logger('[cyclist] pause');
this.clock.pause();
diff --git a/packages/core/repl.mjs b/packages/core/repl.mjs
index 171697eb9..d0cb1e19f 100644
--- a/packages/core/repl.mjs
+++ b/packages/core/repl.mjs
@@ -91,6 +91,7 @@ export function repl({
const stop = () => scheduler.stop();
const start = () => scheduler.start();
+ const exportAudio = (begin, end) => scheduler.exportAudio(begin, end);
const pause = () => scheduler.pause();
const toggle = () => scheduler.toggle();
const setCps = (cps) => {
@@ -257,23 +258,23 @@ export function repl({
}
};
const setCode = (code) => updateState({ code });
- return { scheduler, evaluate, start, stop, pause, setCps, setPattern, setCode, toggle, state };
+ return { scheduler, evaluate, start, exportAudio, stop, pause, setCps, setPattern, setCode, toggle, state };
}
export const getTrigger =
({ getTime, defaultOutput }) =>
- async (hap, deadline, duration, cps, t) => {
- // ^ this signature is different from hap.context.onTrigger, as set by Pattern.onTrigger(onTrigger)
- // TODO: get rid of deadline after https://codeberg.org/uzu/strudel/pulls/1004
- try {
- if (!hap.context.onTrigger || !hap.context.dominantTrigger) {
- await defaultOutput(hap, deadline, duration, cps, t);
+ async (hap, deadline, duration, cps, t) => {
+ // ^ this signature is different from hap.context.onTrigger, as set by Pattern.onTrigger(onTrigger)
+ // TODO: get rid of deadline after https://codeberg.org/uzu/strudel/pulls/1004
+ try {
+ if (!hap.context.onTrigger || !hap.context.dominantTrigger) {
+ await defaultOutput(hap, deadline, duration, cps, t);
+ }
+ if (hap.context.onTrigger) {
+ // call signature of output / onTrigger is different...
+ await hap.context.onTrigger(hap, getTime(), cps, t);
+ }
+ } catch (err) {
+ errorLogger(err, 'getTrigger');
}
- if (hap.context.onTrigger) {
- // call signature of output / onTrigger is different...
- await hap.context.onTrigger(hap, getTime(), cps, t);
- }
- } catch (err) {
- errorLogger(err, 'getTrigger');
- }
- };
+ };
diff --git a/packages/superdough/audioContext.mjs b/packages/superdough/audioContext.mjs
index 71e01d57d..30fbd9fd0 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 a37504b1d..80205a298 100644
--- a/packages/superdough/superdough.mjs
+++ b/packages/superdough/superdough.mjs
@@ -13,7 +13,7 @@ import { createFilter, gainNode, getCompressor, getDistortion, getLfo, getWorkle
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';
export const DEFAULT_MAX_POLYPHONY = 128;
@@ -367,6 +367,7 @@ function mapChannelNumbers(channels) {
}
export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) => {
+ controller = null;
// new: t is always expected to be the absolute target onset time
const ac = getAudioContext();
const audioController = getSuperdoughAudioController();
@@ -387,14 +388,13 @@ 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) {
+ if (t < ac.currentTime && !(ac instanceof OfflineAudioContext)) {
console.warn(
`[superdough]: cannot schedule sounds in the past (target: ${t.toFixed(2)}, now: ${ac.currentTime.toFixed(2)})`,
);
return;
- }
- // destructure
+ } // FIXME: fix
+ // destructure
let {
tremolo,
tremolosync,
@@ -736,4 +736,4 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
export const superdoughTrigger = (t, hap, ct, cps) => {
superdough(hap, t - ct, hap.duration / cps, cps);
-};
+};
\ No newline at end of file
diff --git a/packages/superdough/vowel.mjs b/packages/superdough/vowel.mjs
index 3f30aef1e..025677fc1 100644
--- a/packages/superdough/vowel.mjs
+++ b/packages/superdough/vowel.mjs
@@ -63,7 +63,7 @@ if (typeof GainNode !== 'undefined') {
}
}
- AudioContext.prototype.createVowelFilter = function (letter) {
+ BaseAudioContext.prototype.createVowelFilter = function (letter) {
return new VowelNode(this, letter);
};
}
diff --git a/packages/webaudio/webaudio.mjs b/packages/webaudio/webaudio.mjs
index 383e87f87..46bef88d6 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, getAudioContext, setLogger, doughTrigger, registerWorklet, setAudioContext, getSampleBufferSource, loadBuffer, getSampleInfo, getSound } from 'superdough';
import './supradough.mjs';
import { workletUrl } from 'supradough';
@@ -26,6 +26,60 @@ 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) {
+ let audioContext = new OfflineAudioContext(2, (end - begin) / cps * 48000, 48000);
+ setAudioContext(audioContext)
+ logger('[webaudio] start rendering');
+ console.log(audioContext)
+
+ let haps = pattern.queryArc(begin, end, { _cps: cps })
+ Promise.all(haps.map(async h => {
+ let s;
+ if (h.value.s) {
+ if (h.value.bank) {
+ s = `${h.value.bank}_${h.value.s}`;
+ }
+ else {
+ s = h.value.s
+ }
+ }
+ let bank = getSound(s).data.samples
+ if (bank) {
+ let { url: sampleUrl, label } = getSampleInfo(h.value, bank);
+ await loadBuffer(sampleUrl, audioContext, label)
+ }
+ }))
+ haps.forEach(async (hap) => {
+ if (hap.hasOnset()) {
+ hap.ensureObjectValue();
+ await superdough(hap.value, hap.whole.begin.valueOf() / cps, hap.duration, cps, hap.whole?.begin.valueOf());
+ }
+ })
+
+ let context = getAudioContext()
+ context.startRendering().then((renderedBuffer) => {
+ // console.log(renderedBuffer)
+ const wavBuffer = audioBufferToWav(renderedBuffer);
+ const blob = new Blob([wavBuffer], { type: 'audio/wav' });
+ let downloadName = ''
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ // remove leading dash if they want an empty download name
+ if (downloadName == '') {
+ downloadName = `${new Date().toISOString()}.wav`;
+ } else {
+ downloadName = downloadName + `-${new Date().toISOString()}.wav`;
+ }
+ a.download = `${downloadName}`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ setAudioContext(null)
+ });
+}
+
export function webaudioRepl(options = {}) {
options = {
getTime: () => getAudioContext().currentTime,
@@ -38,3 +92,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))
+ }
+}
\ No newline at end of file
diff --git a/website/src/repl/components/Header.jsx b/website/src/repl/components/Header.jsx
index ca4e1ecf9..656aa7861 100644
--- a/website/src/repl/components/Header.jsx
+++ b/website/src/repl/components/Header.jsx
@@ -8,7 +8,7 @@ const { BASE_URL } = import.meta.env;
const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
export function Header({ context, embedded = false }) {
- const { started, pending, isDirty, activeCode, handleTogglePlay, handleEvaluate, handleShuffle, handleShare } =
+ const { started, pending, isDirty, activeCode, handleTogglePlay, handleEvaluate, handleShuffle, handleShare, handleExport } =
context;
const isEmbedded = typeof window !== 'undefined' && (embedded || window.location !== window.parent.location);
const { isZen, isButtonRowHidden, isCSSAnimationDisabled, fontFamily } = useSettings();
@@ -93,6 +93,16 @@ export function Header({ context, embedded = false }) {
>
{!isEmbedded && update}
+
{/* !isEmbedded && (