Merge branch 'main' into glossing/lfo-anything

This commit is contained in:
Aria
2025-12-27 20:54:39 -05:00
23 changed files with 1687 additions and 133 deletions
+2 -2
View File
@@ -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();
+6 -2
View File
@@ -1,5 +1,5 @@
import { defaultKeymap } from '@codemirror/commands';
import { Prec } from '@codemirror/state';
import { Prec, EditorState } from '@codemirror/state';
import { keymap, ViewPlugin } from '@codemirror/view';
// import { searchKeymap } from '@codemirror/search';
import { emacs } from '@replit/codemirror-emacs';
@@ -133,5 +133,9 @@ const keymaps = {
export function keybindings(name) {
const active = keymaps[name];
return [active ? Prec.high(active()) : []];
const extensions = active ? [Prec.high(active())] : [];
if (name === 'vim') {
extensions.push(EditorState.allowMultipleSelections.of(true));
}
return extensions;
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, bench } from 'vitest';
import { calculateSteps, rand, useRNG } from '../index.mjs';
const testingResolution = 128;
const _generateRandomPattern = () => rand.iter(testingResolution).fast(testingResolution).firstCycle();
describe('old random', () => {
calculateSteps(true);
bench(
'+tactus',
() => {
useRNG('legacy');
_generateRandomPattern();
},
{
time: 1000,
teardown() {
useRNG('legacy');
},
},
);
calculateSteps(false);
bench(
'-tactus',
() => {
useRNG('precise');
_generateRandomPattern();
},
{
time: 1000,
teardown() {
useRNG('legacy');
},
},
);
});
describe('random', () => {
calculateSteps(true);
bench('+tactus', _generateRandomPattern, { time: 1000 });
calculateSteps(false);
bench('-tactus', _generateRandomPattern, { time: 1000 });
});
calculateSteps(true);
+165 -41
View File
@@ -16,7 +16,7 @@ export function steady(value) {
}
export const signal = (func) => {
const query = (state) => [new Hap(undefined, state.span, func(state.span.begin))];
const query = (state) => [new Hap(undefined, state.span, func(state.span.begin, state.controls))];
return new Pattern(query);
};
@@ -186,38 +186,97 @@ export const mouseY = signal(() => _mouseY);
export const mousex = signal(() => _mouseX);
export const mouseX = signal(() => _mouseX);
// random signals
// Random number generators
const xorwise = (x) => {
// Produce "Avalanche effect" where flipping a single bit of x
// results in all output bits flipping with probability 0.5
// See e.g. https://github.com/aappleby/smhasher/blob/0ff96f7835817a27d0487325b6c16033e2992eb5/src/MurmurHash3.cpp#L68-L77
const _murmurHashFinalizer = (x) => {
x |= 0;
x ^= x >>> 16;
x = Math.imul(x, 0x85ebca6b);
x ^= x >>> 13;
x = Math.imul(x, 0xc2b2ae35);
x ^= x >>> 16;
return x >>> 0; // unsigned
};
// Convert t to a 32 bit integer, preserving temporal resolution down to 1/2^29
const _tToT = (t) => {
return Math.floor(t * 536870912);
};
// Used to decorrelate nearby T, i, and seed prior to hashing
const _decorrelate = (T, i = 0, seed = 0) => {
const lowBits = (T >>> 0) >>> 0;
const highBits = Math.floor(T / 4294967296) >>> 0; // 2^32
let key = lowBits ^ Math.imul(highBits ^ 0x85ebca6b, 0xc2b2ae35);
key ^= Math.imul(i ^ 0x7f4a7c15, 0x9e3779b9);
key ^= Math.imul(seed ^ 0x165667b1, 0x27d4eb2d);
return key >>> 0;
};
const randAt = (T, i = 0, seed = 0) => {
return _murmurHashFinalizer(_decorrelate(T, i, seed)) / 4294967296; // 2^32
};
// n samples at time t
const timeToRands = (t, n, seed = 0) => {
const T = _tToT(t);
if (n === 1) {
return randAt(T, 0, seed);
}
const out = new Array(n);
for (let i = 0; i < n; i++) out[i] = randAt(T, i, seed);
return out;
};
// Old random signals. Currently the default, but can also be chosen via
// `useRNG('legacy')`
// stretch 300 cycles over the range of [0,2**29 == 536870912) then apply the xorshift algorithm
const __xorwise = (x) => {
const a = (x << 13) ^ x;
const b = (a >> 17) ^ a;
return (b << 5) ^ b;
};
// stretch 300 cycles over the range of [0,2**29 == 536870912) then apply the xorshift algorithm
const _frac = (x) => x - Math.trunc(x);
const timeToIntSeed = (x) => xorwise(Math.trunc(_frac(x / 300) * 536870912));
const intSeedToRand = (x) => (x % 536870912) / 536870912;
const timeToRand = (x) => Math.abs(intSeedToRand(timeToIntSeed(x)));
const timeToRandsPrime = (seed, n) => {
const __frac = (x) => x - Math.trunc(x);
const __timeToIntSeed = (x) => __xorwise(Math.trunc(__frac(x / 300) * 536870912));
const __intSeedToRand = (x) => (x % 536870912) / 536870912;
const __timeToRandsPrime = (seed, n) => {
if (n === 1) {
return Math.abs(__intSeedToRand(seed));
}
const result = [];
// eslint-disable-next-line
for (let i = 0; i < n; ++i) {
result.push(intSeedToRand(seed));
seed = xorwise(seed);
for (let i = 0; i < n; i++) {
result.push(__intSeedToRand(seed));
seed = __xorwise(seed);
}
return result;
};
const __timeToRands = (t, n) => __timeToRandsPrime(__timeToIntSeed(t), n);
const timeToRands = (t, n) => timeToRandsPrime(timeToIntSeed(t), n);
// End old random
let RNG_MODE = 'legacy';
export const getRandsAtTime = (t, n = 1, seed = 0) => {
return RNG_MODE === 'legacy' ? __timeToRands(t + seed, n) : timeToRands(t, n, seed);
};
/**
* Sets which random number generator to use. Historically Strudel would
* use `useRNG('legacy')`, which remains the default. To use a new more statistically
* precise RNG, try `useRNG('precise')`.
*
* @name useRNG
* @param {string} mod - Mode. One of 'legacy', 'precise'
* @example
* useRNG('legacy')
* // Repeats every 300 cycles
* $: n(irand(50)).seg(16).scale("C:minor").ribbon(88, 32)
* $: n(irand(50)).seg(16).scale("C:minor").ribbon(388, 32)
*/
export const useRNG = (mode = 'legacy') => (RNG_MODE = mode);
/**
* A discrete pattern of numbers from 0 to n-1
@@ -300,13 +359,13 @@ export const binaryNL = (n, nBits = 16) => {
* .partials(randL(8))
*/
export const randL = (n) => {
return signal((t) => (nVal) => timeToRands(t, nVal).map(Math.abs)).appLeft(reify(n));
return signal((t) => (nVal) => getRandsAtTime(t, nVal).map(Math.abs)).appLeft(reify(n));
};
export const randrun = (n) => {
return signal((t) => {
return signal((t, controls) => {
// Without adding 0.5, the first cycle is always 0,1,2,3,...
const rands = timeToRands(t.floor().add(0.5), n);
const rands = getRandsAtTime(t.floor().add(0.5), n, controls.randSeed);
const nums = rands
.map((n, i) => [n, i])
.sort((a, b) => (a[0] > b[0]) - (a[0] < b[0]))
@@ -347,6 +406,37 @@ export const scramble = register('scramble', (n, pat) => {
return _rearrangeWith(_irand(n)._segment(n), n, pat);
});
/**
* Modify a pattern by applying a function to the `randomSeed` control if present
*
* @param {Function} func Function from seed (or undefined) to seed (or undefined)
* @param {Pattern} pat Pattern to update
* @returns Pattern
*/
export const withSeed = (func, pat) => {
return new Pattern((state) => {
let { randSeed, ...controls } = state.controls;
randSeed = func(randSeed);
return pat.query(state.setControls({ ...controls, randSeed }));
}, pat._steps);
};
/**
* Change the seed for random signals. Normally, random signals depend on time,
* so two patterns at the same time will have the same random values. Specifying
* a new seed changes the signal output by `rand`. This also affects other functions
* that use randomness, like `shuffle` and `sometimes`.
*
* @name seed
* @param {number} n A new seed. Can be any number.
* @example
* $: s("hh*4").degrade();
* $: s("bd*4").degrade().seed(1); // Will degrade different events from the hi-hat
*/
export const seed = register('seed', (n, pat) => {
return withSeed(() => n, pat);
});
/**
* A continuous pattern of random numbers, between 0 and 1.
*
@@ -356,7 +446,7 @@ export const scramble = register('scramble', (n, pat) => {
* s("bd*4,hh*8").cutoff(rand.range(500,8000))
*
*/
export const rand = signal(timeToRand);
export const rand = signal((t, controls) => getRandsAtTime(t, 1, controls.randSeed));
/**
* A continuous pattern of random numbers, between -1 and 1
*/
@@ -533,36 +623,32 @@ export const wchooseCycles = (...pairs) => _wchooseWith(rand.segment(1), ...pair
export const wrandcat = wchooseCycles;
function _perlin(t) {
function _perlin(t, seed = 0) {
let ta = Math.floor(t);
let tb = ta + 1;
const smootherStep = (x) => 6.0 * x ** 5 - 15.0 * x ** 4 + 10.0 * x ** 3;
const interp = (x) => (a) => (b) => a + smootherStep(x) * (b - a);
const v = interp(t - ta)(timeToRand(ta))(timeToRand(tb));
const ra = getRandsAtTime(ta, 1, seed);
const rb = getRandsAtTime(tb, 1, seed);
const v = interp(t - ta)(ra)(rb);
return v;
}
export const perlinWith = (tpat) => {
return tpat.fmap(_perlin);
};
function _berlin(t) {
function _berlin(t, seed = 0) {
const prevRidgeStartIndex = Math.floor(t);
const nextRidgeStartIndex = prevRidgeStartIndex + 1;
const prevRidgeBottomPoint = timeToRand(prevRidgeStartIndex);
const nextRidgeTopPoint = timeToRand(nextRidgeStartIndex) + prevRidgeBottomPoint;
const prevRidgeBottomPoint = getRandsAtTime(prevRidgeStartIndex, 1, seed);
const height = getRandsAtTime(nextRidgeStartIndex, 1, seed);
const nextRidgeTopPoint = prevRidgeBottomPoint + height;
const currentPercent = (t - prevRidgeStartIndex) / (nextRidgeStartIndex - prevRidgeStartIndex);
const interp = (a, b, t) => {
return a + (b - a) * t;
return a + t * (b - a);
};
return interp(prevRidgeBottomPoint, nextRidgeTopPoint, currentPercent) / 2;
}
export const berlinWith = (tpat) => {
return tpat.fmap(_berlin);
};
/**
* Generates a continuous pattern of [perlin noise](https://en.wikipedia.org/wiki/Perlin_noise), in the range 0..1.
*
@@ -572,7 +658,7 @@ export const berlinWith = (tpat) => {
* s("bd*4,hh*8").cutoff(perlin.range(500,8000))
*
*/
export const perlin = perlinWith(time.fmap((v) => Number(v)));
export const perlin = signal((t, controls) => _perlin(t, controls.randSeed));
/**
* Generates a continuous pattern of [berlin noise](conceived by Jame Coyne and Jade Rowland as a joke but turned out to be surprisingly cool and useful,
@@ -584,7 +670,7 @@ export const perlin = perlinWith(time.fmap((v) => Number(v)));
* n("0!16".add(berlin.fast(4).mul(14))).scale("d:minor")
*
*/
export const berlin = berlinWith(time.fmap((v) => Number(v)));
export const berlin = signal((t, controls) => _berlin(t, controls.randSeed));
export const degradeByWith = register(
'degradeByWith',
@@ -884,10 +970,48 @@ export const keyDown = register('keyDown', function (pat) {
});
/**
* A pattern that gives the duration of events that are combined with it.
* A pattern measuring the duration of events,
* in cycles per event. `cyclesPer` doesn't have structure itself, but takes structure, and therefore
* event durations, from the pattern that it is combined with.
* For example `cyclesPer.struct("1 1 [1 1] 1")` would give the same as `"0.25 0.25 [0.125 0.125] 0.25"`.
* See also its reciprocal, `per`, also known as `perCycle`.
* @example
* sound("bd sd [bd bd] sd*4 [- sd] [bd [bd bd]]").note(delta.withValue(x => 1/x + 20))
* // Shorter events are lower in pitch
* sound("saw saw [saw saw] saw")
* .note(cyclesPer.range(50, 100))
* @example
* sound("bd sd [bd bd] sd*4 [- sd] [bd [bd bd]]")
* .note(cyclesPer.add(20))
*/
export const delta = new Pattern(function (state) {
export const cyclesPer = new Pattern(function (state) {
return [new Hap(undefined, state.span, state.span.duration)];
});
/**
* A pattern measuring the 'shortness' of events, or in other words, the duration of pattern events,
* in events per cycle. `per` doesn't have structure itself, but takes structure, and therefore
* event durations, from the pattern that it is combined with.
* For example `per.struct("1 1 [1 1] 1")` would give the same as `"4 4 [8 8] 4"`.
* See also its reciprocal, `cyclesPer`.
* @synonyms perCycle
* @example
* // Shorter events are more distorted
* n("0 0*2 0 0*2 0 [0 0 0]@2").sound("bd")
* .distort(per.div(2))
*/
export const per = new Pattern(function (state) {
return [new Hap(undefined, state.span, Fraction(1).div(state.span.duration))];
});
export const perCycle = per;
/**
* Like `per` but measures the shortness of events according to an exponential curve. In
* particular, where the event duration halves, the
* returned value increases by one. `perx.struct("1 1 [1 [1 1]] 1")` would therefore be
* the same as `"3 3 [4 [5 5]] 3"`.
*/
export const perx = new Pattern(function (state) {
const n = Fraction(1).div(state.span.duration);
return [new Hap(undefined, state.span, Math.log(n) / Math.log(2) + 1)];
});
-14
View File
@@ -740,20 +740,6 @@ describe('Pattern', () => {
);
});
});
describe('signal()', () => {
it('Can make saw/saw2', () => {
expect(saw.struct(true, true, true, true).firstCycle()).toStrictEqual(
sequence(0, 1 / 4, 1 / 2, 3 / 4).firstCycle(),
);
expect(saw2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(-1, -0.5, 0, 0.5).firstCycle());
});
it('Can make isaw/isaw2', () => {
expect(isaw.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.75, 0.5, 0.25).firstCycle());
expect(isaw2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.5, 0, -0.5).firstCycle());
});
});
describe('_setContext()', () => {
it('Can set the hap context', () => {
expect(
+61
View File
@@ -0,0 +1,61 @@
/*
signal.test.mjs - <short description TODO>
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/test/pattern.test.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Fraction from 'fraction.js';
import { describe, it, expect, vi } from 'vitest';
import { saw, saw2, isaw, isaw2, per, perx, cyclesPer } from '../signal.mjs';
import { fastcat, sequence, State, TimeSpan, Hap } from '../index.mjs';
const st = (begin, end) => new State(ts(begin, end));
const ts = (begin, end) => new TimeSpan(Fraction(begin), Fraction(end));
const hap = (whole, part, value, context = {}) => new Hap(whole, part, value, context);
const third = Fraction(1, 3);
const twothirds = Fraction(2, 3);
const sameFirst = (a, b) => {
return expect(a.sortHapsByPart().firstCycle()).toStrictEqual(b.sortHapsByPart().firstCycle());
};
describe('signal()', () => {
it('Can make saw/saw2', () => {
expect(saw.struct(true, true, true, true).firstCycle()).toStrictEqual(
sequence(0, 1 / 4, 1 / 2, 3 / 4).firstCycle(),
);
expect(saw2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(-1, -0.5, 0, 0.5).firstCycle());
});
it('Can make isaw/isaw2', () => {
expect(isaw.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.75, 0.5, 0.25).firstCycle());
expect(isaw2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.5, 0, -0.5).firstCycle());
});
});
describe('cyclesPer', () => {
it('gives cycles per hap', () => {
sameFirst(
cyclesPer.struct(true, true, true, fastcat(true, true)),
sequence(0.25, 0.25, 0.25, fastcat(0.125, 0.125)).fmap(Fraction),
);
});
});
describe('per', () => {
it('gives haps per cycle', () => {
sameFirst(per.struct(true, true, true, fastcat(true, true)), sequence(4, 4, 4, fastcat(8, 8)).fmap(Fraction));
});
});
describe('perx', () => {
it('gives exponential haps per cycle', () => {
sameFirst(
perx.struct(true, true, true, fastcat(true, fastcat(true, true))),
sequence(3, 3, 3, fastcat(4, fastcat(5, 5))),
);
});
});
+5
View File
@@ -5,6 +5,11 @@ export const setDefaultAudioContext = () => {
return audioContext;
};
export const setAudioContext = (context) => {
audioContext = context;
return audioContext;
};
export const getAudioContext = () => {
if (!audioContext) {
return setDefaultAudioContext();
+1 -1
View File
@@ -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);
};
}
+4 -5
View File
@@ -42,6 +42,7 @@ export const getParamADSR = (
decay,
sustain,
release,
// min = value at start of attack, max = value at end of attack; it is possible that max < min
min,
max,
begin,
@@ -59,17 +60,15 @@ export const getParamADSR = (
max = max === 0 ? 0.001 : max;
}
const range = max - min;
const peak = max;
const sustainVal = min + sustain * range;
const duration = end - begin;
const envValAtTime = (time) => {
let val;
if (attack > time) {
let slope = getSlope(min, peak, 0, attack);
val = time * slope + (min > peak ? min : 0);
val = time * getSlope(min, max, 0, attack) + min;
} else {
val = (time - attack) * getSlope(peak, sustainVal, 0, decay) + peak;
val = (time - attack) * getSlope(max, sustainVal, 0, decay) + max;
}
if (curve === 'exponential') {
val = val || 0.001;
@@ -613,7 +612,7 @@ export const releaseAudioNode = (node) => {
// make sure all AudioScheduledSourceNodes are in a stopped state
// https://developer.mozilla.org/en-US/docs/Web/API/AudioScheduledSourceNode
if (node instanceof AudioScheduledSourceNode) {
if (node.onended && node.onended.name !== 'cleanup') {
if (process.env.NODE_ENV === 'development' && node.onended && node.onended.name !== 'cleanup') {
logger(
`[superdough] Deprecation warning: it seems your code path is setting 'node.onended = callback' instead of using the onceEnded helper`,
);
+2 -2
View File
@@ -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;
+41 -10
View File
@@ -24,20 +24,32 @@ import {
import { map } from 'nanostores';
import { errorLogger, 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 { getSuperdoughControlTargets } from './superdoughdata.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
* start to die out in first-in-first-out order once the max polyphony has been hit
*
* @name setMaxPolyphony
* @param {number} Max polyphony. Defaults to 128
* @example
* setMaxPolyphony(4)
* n(irand(24).seg(8)).scale("C#3:minor").room(1).release(4).gain(0.5)
*
*/
export function setMaxPolyphony(polyphony) {
maxPolyphony = parseInt(polyphony) ?? DEFAULT_MAX_POLYPHONY;
}
let multiChannelOrbits = false;
export let multiChannelOrbits = false;
export function setMultiChannelOrbits(bool) {
multiChannelOrbits = bool == true;
}
@@ -55,6 +67,17 @@ export function applyGainCurve(val) {
return gainCurveFunc(val);
}
/**
* Apply a function to all gains provided in patterns. Can be used to rescale gain to be
* quadratic, exponential, etc. rather than linear
*
* @name setGainCurve
* @param {Function} function to apply to all gain values
* @example
* setGainCurve((x) => x * x) // quadratic gain
* s("bd*4").gain(0.5) // equivalent to 0.25 gain normally
*
*/
export function setGainCurve(newGainCurveFunc) {
gainCurveFunc = newGainCurveFunc;
}
@@ -216,11 +239,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;
@@ -237,6 +262,7 @@ export async function initAudio(options = {}) {
setMaxPolyphony(maxPolyphony);
setMultiChannelOrbits(multiChannelOrbits);
resetSeenKeys();
if (typeof window === 'undefined') {
return;
}
@@ -258,8 +284,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;
@@ -294,6 +321,11 @@ 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);
@@ -331,7 +363,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;
@@ -521,7 +553,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)})`,
@@ -949,7 +980,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);
+1 -1
View File
@@ -75,7 +75,7 @@ if (typeof GainNode !== 'undefined') {
}
}
AudioContext.prototype.createVowelFilter = function (letter) {
BaseAudioContext.prototype.createVowelFilter = function (letter) {
return new VowelNode(this, letter);
};
}
+5
View File
@@ -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)) {
+173 -2
View File
@@ -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));
}
}
+262 -53
View File
@@ -2539,6 +2539,84 @@ exports[`runs examples > example "cut" example index 0 1`] = `
]
`;
exports[`runs examples > example "cyclesPer" example index 0 1`] = `
[
"[ 0/1 → 1/4 | s:saw note:62.5 ]",
"[ 1/4 → 1/2 | s:saw note:62.5 ]",
"[ 1/2 → 5/8 | s:saw note:56.25 ]",
"[ 5/8 → 3/4 | s:saw note:56.25 ]",
"[ 3/4 → 1/1 | s:saw note:62.5 ]",
"[ 1/1 → 5/4 | s:saw note:62.5 ]",
"[ 5/4 → 3/2 | s:saw note:62.5 ]",
"[ 3/2 → 13/8 | s:saw note:56.25 ]",
"[ 13/8 → 7/4 | s:saw note:56.25 ]",
"[ 7/4 → 2/1 | s:saw note:62.5 ]",
"[ 2/1 → 9/4 | s:saw note:62.5 ]",
"[ 9/4 → 5/2 | s:saw note:62.5 ]",
"[ 5/2 → 21/8 | s:saw note:56.25 ]",
"[ 21/8 → 11/4 | s:saw note:56.25 ]",
"[ 11/4 → 3/1 | s:saw note:62.5 ]",
"[ 3/1 → 13/4 | s:saw note:62.5 ]",
"[ 13/4 → 7/2 | s:saw note:62.5 ]",
"[ 7/2 → 29/8 | s:saw note:56.25 ]",
"[ 29/8 → 15/4 | s:saw note:56.25 ]",
"[ 15/4 → 4/1 | s:saw note:62.5 ]",
]
`;
exports[`runs examples > example "cyclesPer" example index 1 1`] = `
[
"[ 0/1 → 1/6 | s:bd note:20.166666666666668 ]",
"[ 1/6 → 1/3 | s:sd note:20.166666666666668 ]",
"[ 1/3 → 5/12 | s:bd note:20.083333333333332 ]",
"[ 5/12 → 1/2 | s:bd note:20.083333333333332 ]",
"[ 1/2 → 13/24 | s:sd note:20.041666666666668 ]",
"[ 13/24 → 7/12 | s:sd note:20.041666666666668 ]",
"[ 7/12 → 5/8 | s:sd note:20.041666666666668 ]",
"[ 5/8 → 2/3 | s:sd note:20.041666666666668 ]",
"[ 3/4 → 5/6 | s:sd note:20.083333333333332 ]",
"[ 5/6 → 11/12 | s:bd note:20.083333333333332 ]",
"[ 11/12 → 23/24 | s:bd note:20.041666666666668 ]",
"[ 23/24 → 1/1 | s:bd note:20.041666666666668 ]",
"[ 1/1 → 7/6 | s:bd note:20.166666666666668 ]",
"[ 7/6 → 4/3 | s:sd note:20.166666666666668 ]",
"[ 4/3 → 17/12 | s:bd note:20.083333333333332 ]",
"[ 17/12 → 3/2 | s:bd note:20.083333333333332 ]",
"[ 3/2 → 37/24 | s:sd note:20.041666666666668 ]",
"[ 37/24 → 19/12 | s:sd note:20.041666666666668 ]",
"[ 19/12 → 13/8 | s:sd note:20.041666666666668 ]",
"[ 13/8 → 5/3 | s:sd note:20.041666666666668 ]",
"[ 7/4 → 11/6 | s:sd note:20.083333333333332 ]",
"[ 11/6 → 23/12 | s:bd note:20.083333333333332 ]",
"[ 23/12 → 47/24 | s:bd note:20.041666666666668 ]",
"[ 47/24 → 2/1 | s:bd note:20.041666666666668 ]",
"[ 2/1 → 13/6 | s:bd note:20.166666666666668 ]",
"[ 13/6 → 7/3 | s:sd note:20.166666666666668 ]",
"[ 7/3 → 29/12 | s:bd note:20.083333333333332 ]",
"[ 29/12 → 5/2 | s:bd note:20.083333333333332 ]",
"[ 5/2 → 61/24 | s:sd note:20.041666666666668 ]",
"[ 61/24 → 31/12 | s:sd note:20.041666666666668 ]",
"[ 31/12 → 21/8 | s:sd note:20.041666666666668 ]",
"[ 21/8 → 8/3 | s:sd note:20.041666666666668 ]",
"[ 11/4 → 17/6 | s:sd note:20.083333333333332 ]",
"[ 17/6 → 35/12 | s:bd note:20.083333333333332 ]",
"[ 35/12 → 71/24 | s:bd note:20.041666666666668 ]",
"[ 71/24 → 3/1 | s:bd note:20.041666666666668 ]",
"[ 3/1 → 19/6 | s:bd note:20.166666666666668 ]",
"[ 19/6 → 10/3 | s:sd note:20.166666666666668 ]",
"[ 10/3 → 41/12 | s:bd note:20.083333333333332 ]",
"[ 41/12 → 7/2 | s:bd note:20.083333333333332 ]",
"[ 7/2 → 85/24 | s:sd note:20.041666666666668 ]",
"[ 85/24 → 43/12 | s:sd note:20.041666666666668 ]",
"[ 43/12 → 29/8 | s:sd note:20.041666666666668 ]",
"[ 29/8 → 11/3 | s:sd note:20.041666666666668 ]",
"[ 15/4 → 23/6 | s:sd note:20.083333333333332 ]",
"[ 23/6 → 47/12 | s:bd note:20.083333333333332 ]",
"[ 47/12 → 95/24 | s:bd note:20.041666666666668 ]",
"[ 95/24 → 4/1 | s:bd note:20.041666666666668 ]",
]
`;
exports[`runs examples > example "decay" example index 0 1`] = `
[
"[ 0/1 → 1/4 | note:c3 decay:0.1 sustain:0 ]",
@@ -2784,59 +2862,6 @@ exports[`runs examples > example "delaysync" example index 0 1`] = `
]
`;
exports[`runs examples > example "delta" example index 0 1`] = `
[
"[ 0/1 → 1/6 | s:bd note:26 ]",
"[ 1/6 → 1/3 | s:sd note:26 ]",
"[ 1/3 → 5/12 | s:bd note:32 ]",
"[ 5/12 → 1/2 | s:bd note:32 ]",
"[ 1/2 → 13/24 | s:sd note:44 ]",
"[ 13/24 → 7/12 | s:sd note:44 ]",
"[ 7/12 → 5/8 | s:sd note:44 ]",
"[ 5/8 → 2/3 | s:sd note:44 ]",
"[ 3/4 → 5/6 | s:sd note:32 ]",
"[ 5/6 → 11/12 | s:bd note:32 ]",
"[ 11/12 → 23/24 | s:bd note:44 ]",
"[ 23/24 → 1/1 | s:bd note:44 ]",
"[ 1/1 → 7/6 | s:bd note:26 ]",
"[ 7/6 → 4/3 | s:sd note:26 ]",
"[ 4/3 → 17/12 | s:bd note:32 ]",
"[ 17/12 → 3/2 | s:bd note:32 ]",
"[ 3/2 → 37/24 | s:sd note:44 ]",
"[ 37/24 → 19/12 | s:sd note:44 ]",
"[ 19/12 → 13/8 | s:sd note:44 ]",
"[ 13/8 → 5/3 | s:sd note:44 ]",
"[ 7/4 → 11/6 | s:sd note:32 ]",
"[ 11/6 → 23/12 | s:bd note:32 ]",
"[ 23/12 → 47/24 | s:bd note:44 ]",
"[ 47/24 → 2/1 | s:bd note:44 ]",
"[ 2/1 → 13/6 | s:bd note:26 ]",
"[ 13/6 → 7/3 | s:sd note:26 ]",
"[ 7/3 → 29/12 | s:bd note:32 ]",
"[ 29/12 → 5/2 | s:bd note:32 ]",
"[ 5/2 → 61/24 | s:sd note:44 ]",
"[ 61/24 → 31/12 | s:sd note:44 ]",
"[ 31/12 → 21/8 | s:sd note:44 ]",
"[ 21/8 → 8/3 | s:sd note:44 ]",
"[ 11/4 → 17/6 | s:sd note:32 ]",
"[ 17/6 → 35/12 | s:bd note:32 ]",
"[ 35/12 → 71/24 | s:bd note:44 ]",
"[ 71/24 → 3/1 | s:bd note:44 ]",
"[ 3/1 → 19/6 | s:bd note:26 ]",
"[ 19/6 → 10/3 | s:sd note:26 ]",
"[ 10/3 → 41/12 | s:bd note:32 ]",
"[ 41/12 → 7/2 | s:bd note:32 ]",
"[ 7/2 → 85/24 | s:sd note:44 ]",
"[ 85/24 → 43/12 | s:sd note:44 ]",
"[ 43/12 → 29/8 | s:sd note:44 ]",
"[ 29/8 → 11/3 | s:sd note:44 ]",
"[ 15/4 → 23/6 | s:sd note:32 ]",
"[ 23/6 → 47/12 | s:bd note:32 ]",
"[ 47/12 → 95/24 | s:bd note:44 ]",
"[ 95/24 → 4/1 | s:bd note:44 ]",
]
`;
exports[`runs examples > example "density" example index 0 1`] = `
[
"[ 0/1 → 1/4 | s:crackle density:0.01 ]",
@@ -7966,6 +7991,51 @@ exports[`runs examples > example "penv" example index 0 1`] = `
]
`;
exports[`runs examples > example "per" example index 0 1`] = `
[
"[ 0/1 → 1/7 | n:0 s:bd distort:3.5 ]",
"[ 1/7 → 3/14 | n:0 s:bd distort:7 ]",
"[ 3/14 → 2/7 | n:0 s:bd distort:7 ]",
"[ 2/7 → 3/7 | n:0 s:bd distort:3.5 ]",
"[ 3/7 → 1/2 | n:0 s:bd distort:7 ]",
"[ 1/2 → 4/7 | n:0 s:bd distort:7 ]",
"[ 4/7 → 5/7 | n:0 s:bd distort:3.5 ]",
"[ 5/7 → 17/21 | n:0 s:bd distort:5.25 ]",
"[ 17/21 → 19/21 | n:0 s:bd distort:5.25 ]",
"[ 19/21 → 1/1 | n:0 s:bd distort:5.25 ]",
"[ 1/1 → 8/7 | n:0 s:bd distort:3.5 ]",
"[ 8/7 → 17/14 | n:0 s:bd distort:7 ]",
"[ 17/14 → 9/7 | n:0 s:bd distort:7 ]",
"[ 9/7 → 10/7 | n:0 s:bd distort:3.5 ]",
"[ 10/7 → 3/2 | n:0 s:bd distort:7 ]",
"[ 3/2 → 11/7 | n:0 s:bd distort:7 ]",
"[ 11/7 → 12/7 | n:0 s:bd distort:3.5 ]",
"[ 12/7 → 38/21 | n:0 s:bd distort:5.25 ]",
"[ 38/21 → 40/21 | n:0 s:bd distort:5.25 ]",
"[ 40/21 → 2/1 | n:0 s:bd distort:5.25 ]",
"[ 2/1 → 15/7 | n:0 s:bd distort:3.5 ]",
"[ 15/7 → 31/14 | n:0 s:bd distort:7 ]",
"[ 31/14 → 16/7 | n:0 s:bd distort:7 ]",
"[ 16/7 → 17/7 | n:0 s:bd distort:3.5 ]",
"[ 17/7 → 5/2 | n:0 s:bd distort:7 ]",
"[ 5/2 → 18/7 | n:0 s:bd distort:7 ]",
"[ 18/7 → 19/7 | n:0 s:bd distort:3.5 ]",
"[ 19/7 → 59/21 | n:0 s:bd distort:5.25 ]",
"[ 59/21 → 61/21 | n:0 s:bd distort:5.25 ]",
"[ 61/21 → 3/1 | n:0 s:bd distort:5.25 ]",
"[ 3/1 → 22/7 | n:0 s:bd distort:3.5 ]",
"[ 22/7 → 45/14 | n:0 s:bd distort:7 ]",
"[ 45/14 → 23/7 | n:0 s:bd distort:7 ]",
"[ 23/7 → 24/7 | n:0 s:bd distort:3.5 ]",
"[ 24/7 → 7/2 | n:0 s:bd distort:7 ]",
"[ 7/2 → 25/7 | n:0 s:bd distort:7 ]",
"[ 25/7 → 26/7 | n:0 s:bd distort:3.5 ]",
"[ 26/7 → 80/21 | n:0 s:bd distort:5.25 ]",
"[ 80/21 → 82/21 | n:0 s:bd distort:5.25 ]",
"[ 82/21 → 4/1 | n:0 s:bd distort:5.25 ]",
]
`;
exports[`runs examples > example "perlin" example index 0 1`] = `
[
"[ 0/1 → 1/8 | s:hh cutoff:500 ]",
@@ -10343,6 +10413,18 @@ exports[`runs examples > example "scrub" example index 1 1`] = `
]
`;
exports[`runs examples > example "seed" example index 0 1`] = `
[
"[ 0/1 → 1/4 | s:bd ]",
"[ 1/4 → 1/2 | s:bd ]",
"[ 1/2 → 3/4 | s:bd ]",
"[ 1/1 → 5/4 | s:bd ]",
"[ 7/4 → 2/1 | s:bd ]",
"[ 9/4 → 5/2 | s:bd ]",
"[ 11/4 → 3/1 | s:bd ]",
]
`;
exports[`runs examples > example "segment" example index 0 1`] = `
[
"[ 0/1 → 1/24 | note:40 ]",
@@ -10526,6 +10608,64 @@ exports[`runs examples > example "seqPLoop" example index 0 1`] = `
]
`;
exports[`runs examples > example "setGainCurve" example index 0 1`] = `
[
"[ 0/1 → 1/4 | s:bd gain:0.5 ]",
"[ 1/4 → 1/2 | s:bd gain:0.5 ]",
"[ 1/2 → 3/4 | s:bd gain:0.5 ]",
"[ 3/4 → 1/1 | s:bd gain:0.5 ]",
"[ 1/1 → 5/4 | s:bd gain:0.5 ]",
"[ 5/4 → 3/2 | s:bd gain:0.5 ]",
"[ 3/2 → 7/4 | s:bd gain:0.5 ]",
"[ 7/4 → 2/1 | s:bd gain:0.5 ]",
"[ 2/1 → 9/4 | s:bd gain:0.5 ]",
"[ 9/4 → 5/2 | s:bd gain:0.5 ]",
"[ 5/2 → 11/4 | s:bd gain:0.5 ]",
"[ 11/4 → 3/1 | s:bd gain:0.5 ]",
"[ 3/1 → 13/4 | s:bd gain:0.5 ]",
"[ 13/4 → 7/2 | s:bd gain:0.5 ]",
"[ 7/2 → 15/4 | s:bd gain:0.5 ]",
"[ 15/4 → 4/1 | s:bd gain:0.5 ]",
]
`;
exports[`runs examples > example "setMaxPolyphony" example index 0 1`] = `
[
"[ 0/1 → 1/8 | note:C#3 room:1 release:4 gain:0.5 ]",
"[ 1/8 → 1/4 | note:E5 room:1 release:4 gain:0.5 ]",
"[ 1/4 → 3/8 | note:D#4 room:1 release:4 gain:0.5 ]",
"[ 3/8 → 1/2 | note:E4 room:1 release:4 gain:0.5 ]",
"[ 1/2 → 5/8 | note:B3 room:1 release:4 gain:0.5 ]",
"[ 5/8 → 3/4 | note:F#3 room:1 release:4 gain:0.5 ]",
"[ 3/4 → 7/8 | note:G#3 room:1 release:4 gain:0.5 ]",
"[ 7/8 → 1/1 | note:E4 room:1 release:4 gain:0.5 ]",
"[ 1/1 → 9/8 | note:A4 room:1 release:4 gain:0.5 ]",
"[ 9/8 → 5/4 | note:E5 room:1 release:4 gain:0.5 ]",
"[ 5/4 → 11/8 | note:F#5 room:1 release:4 gain:0.5 ]",
"[ 11/8 → 3/2 | note:G#3 room:1 release:4 gain:0.5 ]",
"[ 3/2 → 13/8 | note:C#5 room:1 release:4 gain:0.5 ]",
"[ 13/8 → 7/4 | note:G#4 room:1 release:4 gain:0.5 ]",
"[ 7/4 → 15/8 | note:G#3 room:1 release:4 gain:0.5 ]",
"[ 15/8 → 2/1 | note:C#5 room:1 release:4 gain:0.5 ]",
"[ 2/1 → 17/8 | note:E6 room:1 release:4 gain:0.5 ]",
"[ 17/8 → 9/4 | note:C#6 room:1 release:4 gain:0.5 ]",
"[ 9/4 → 19/8 | note:D#4 room:1 release:4 gain:0.5 ]",
"[ 19/8 → 5/2 | note:B5 room:1 release:4 gain:0.5 ]",
"[ 5/2 → 21/8 | note:G#4 room:1 release:4 gain:0.5 ]",
"[ 21/8 → 11/4 | note:F#3 room:1 release:4 gain:0.5 ]",
"[ 11/4 → 23/8 | note:D#5 room:1 release:4 gain:0.5 ]",
"[ 23/8 → 3/1 | note:C#3 room:1 release:4 gain:0.5 ]",
"[ 3/1 → 25/8 | note:A3 room:1 release:4 gain:0.5 ]",
"[ 25/8 → 13/4 | note:D#4 room:1 release:4 gain:0.5 ]",
"[ 13/4 → 27/8 | note:E6 room:1 release:4 gain:0.5 ]",
"[ 27/8 → 7/2 | note:E4 room:1 release:4 gain:0.5 ]",
"[ 7/2 → 29/8 | note:E4 room:1 release:4 gain:0.5 ]",
"[ 29/8 → 15/4 | note:D#6 room:1 release:4 gain:0.5 ]",
"[ 15/4 → 31/8 | note:A5 room:1 release:4 gain:0.5 ]",
"[ 31/8 → 4/1 | note:F#5 room:1 release:4 gain:0.5 ]",
]
`;
exports[`runs examples > example "setcpm" example index 0 1`] = `
[
"[ 0/1 → 1/4 | s:bd bank:tr707 ]",
@@ -12871,6 +13011,75 @@ exports[`runs examples > example "unit" example index 0 1`] = `
]
`;
exports[`runs examples > example "useRNG" example index 0 1`] = `
[
"[ 0/1 → 1/16 | note:D8 ]",
"[ 1/16 → 1/8 | note:Bb7 ]",
"[ 1/8 → 3/16 | note:Ab6 ]",
"[ 3/16 → 1/4 | note:D5 ]",
"[ 1/4 → 5/16 | note:Ab9 ]",
"[ 5/16 → 3/8 | note:D3 ]",
"[ 3/8 → 7/16 | note:G5 ]",
"[ 7/16 → 1/2 | note:G3 ]",
"[ 1/2 → 9/16 | note:Ab3 ]",
"[ 9/16 → 5/8 | note:Eb6 ]",
"[ 5/8 → 11/16 | note:Eb6 ]",
"[ 11/16 → 3/4 | note:Eb5 ]",
"[ 3/4 → 13/16 | note:G7 ]",
"[ 13/16 → 7/8 | note:Bb5 ]",
"[ 7/8 → 15/16 | note:D3 ]",
"[ 15/16 → 1/1 | note:Eb7 ]",
"[ 1/1 → 17/16 | note:Eb9 ]",
"[ 17/16 → 9/8 | note:G8 ]",
"[ 9/8 → 19/16 | note:Ab3 ]",
"[ 19/16 → 5/4 | note:Ab5 ]",
"[ 5/4 → 21/16 | note:C10 ]",
"[ 21/16 → 11/8 | note:C7 ]",
"[ 11/8 → 23/16 | note:G5 ]",
"[ 23/16 → 3/2 | note:Ab7 ]",
"[ 3/2 → 25/16 | note:G6 ]",
"[ 25/16 → 13/8 | note:Bb6 ]",
"[ 13/8 → 27/16 | note:Eb9 ]",
"[ 27/16 → 7/4 | note:G9 ]",
"[ 7/4 → 29/16 | note:G7 ]",
"[ 29/16 → 15/8 | note:C10 ]",
"[ 15/8 → 31/16 | note:Eb3 ]",
"[ 31/16 → 2/1 | note:Ab7 ]",
"[ 2/1 → 33/16 | note:F6 ]",
"[ 33/16 → 17/8 | note:C5 ]",
"[ 17/8 → 35/16 | note:Ab4 ]",
"[ 35/16 → 9/4 | note:G5 ]",
"[ 9/4 → 37/16 | note:C9 ]",
"[ 37/16 → 19/8 | note:Eb6 ]",
"[ 19/8 → 39/16 | note:C9 ]",
"[ 39/16 → 5/2 | note:Eb9 ]",
"[ 5/2 → 41/16 | note:D5 ]",
"[ 41/16 → 21/8 | note:F5 ]",
"[ 21/8 → 43/16 | note:F9 ]",
"[ 43/16 → 11/4 | note:Bb7 ]",
"[ 11/4 → 45/16 | note:Ab6 ]",
"[ 45/16 → 23/8 | note:Bb9 ]",
"[ 23/8 → 47/16 | note:C8 ]",
"[ 47/16 → 3/1 | note:Eb5 ]",
"[ 3/1 → 49/16 | note:F3 ]",
"[ 49/16 → 25/8 | note:G4 ]",
"[ 25/8 → 51/16 | note:D7 ]",
"[ 51/16 → 13/4 | note:D4 ]",
"[ 13/4 → 53/16 | note:F8 ]",
"[ 53/16 → 27/8 | note:C7 ]",
"[ 27/8 → 55/16 | note:Ab5 ]",
"[ 55/16 → 7/2 | note:Ab3 ]",
"[ 7/2 → 57/16 | note:F9 ]",
"[ 57/16 → 29/8 | note:D8 ]",
"[ 29/8 → 59/16 | note:F5 ]",
"[ 59/16 → 15/4 | note:Eb9 ]",
"[ 15/4 → 61/16 | note:Bb4 ]",
"[ 61/16 → 31/8 | note:C6 ]",
"[ 31/8 → 63/16 | note:C4 ]",
"[ 63/16 → 4/1 | note:G8 ]",
]
`;
exports[`runs examples > example "velocity" example index 0 1`] = `
[
"[ 0/1 → 1/8 | s:hh gain:0.4 velocity:0.4 ]",
+1
View File
@@ -16,5 +16,6 @@ export default defineConfig({
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress}.config.*',
'**/shared.test.mjs',
],
setupFiles: './vitest.setup.mjs',
},
});
+7
View File
@@ -0,0 +1,7 @@
import { afterEach } from 'vitest';
import { useRNG } from './packages/core/signal.mjs';
afterEach(() => {
// Avoid bleed between tests
useRNG('legacy');
});
@@ -142,6 +142,8 @@ The "~" represents a rest, and will create silence between other events:
<MiniRepl client:idle tune={`note("[b4 [~ c5] d5 e5]")`} punchcard />
Alternatively, "-" can be used instead of "~". It means the same thing.
## Parallel / polyphony
Using commas, we can play chords.
+651
View File
@@ -0,0 +1,651 @@
/*
audiograph.mjs - show a svg view of the web audio API graph built during a playback
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/website/src/repl/audiograph.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// main entry point is `debugAudiograph`
import { logger } from '@strudel/core';
import { getAudioContext, getSuperdoughAudioController, webaudioOutput } from '@strudel/webaudio';
let mermaid = null;
let svgPanZoom = null;
let running = false;
let hap_count = 0;
let cache = new Map();
const initCache = JSON.stringify({
connect: [],
where: [],
disconnectAll: 0,
disconnectOne: 0,
hasStop: false,
stopCount: 0,
ac: null,
creation: null,
});
let toggleOrig;
function stackTrace() {
var err = new Error();
const stacktrace = err.stack;
const lines = stacktrace.split('\n');
let lineIndex = lines.findIndex((line) => line !== 'Error' && !line.includes('audiograph.mjs'));
if (lines[lineIndex].includes('gainNode')) lineIndex++;
if (lines[lineIndex].includes('getWorklet')) lineIndex++;
const line = lines[lineIndex].replace(/\s*at\s/, '').replace('http', '@');
let match;
match = line.match(/([^@]*)@.*packages(\/[^:]+:\d+:\d+)/);
if (match) {
return match[1].replace(/[^.:/a-zA-Z0-9]/g, '') + '@' + match[2].replace(/[^.:/a-zA-Z0-9]/g, '');
}
return '@';
}
// This captures all AudioNodes lazily
// when an `.audioid` property is called
// no solution was found to hook
// AudioNode's constructor directly
let audioid = 0;
const lazyRegister = (o) => {
Object.defineProperty(o.prototype, 'audioid', {
get: function () {
if (!this._audioid) {
this._audioid = ++audioid;
const s = JSON.parse(initCache);
s.type = this.constructor.name === 'AudiographNode' ? this.constructor._parentClassName : this.constructor.name;
// special case for subclassed AudioNodes
// they are implemented in superdough but hard to get a reference on here
// they are not AudioScheduledSourceNodes anyway
if (['FeedbackDelayNode', 'VowelNode'].indexOf(s.type) === -1) {
s.hasStop = window[s.type].prototype instanceof AudioScheduledSourceNode;
}
s.ac = this.context?.constructor.name || 'AudioParam';
s.creation = s.creation || stackTrace();
cache.set(this._audioid, s);
}
return this._audioid;
},
enumerable: false,
configurable: true,
});
};
// extend a specific AudioNode's constructor
// necessary when creation is done direclty by
// calling the constructor
// eg: new GainNode(...)
const audioNodeHook = (node) => {
const name = node.prototype.constructor.name;
const PatchedNode = class AudiographNode extends node {
constructor(...args) {
super(...args);
// trigger the lazy register
this._audioid = this.audioid;
}
};
PatchedNode._parentClassName = name;
window[name] = PatchedNode;
};
const drawMessage = async function (message) {
const element = document.querySelector('.strudel-mermaid');
let gd = '';
gd += '---\n';
gd += 'config:\n';
gd += ' flowchart:\n';
gd += ' wrappingWidth: 600\n';
gd += '---\n';
gd += 'flowchart LR\n';
gd += 'id[' + message.replaceAll(' ', '&nbsp;') + ']\n';
let { svg } = await mermaid.render('strudelSvgId', gd);
svg = svg.replace(/max-width:\s[0-9.]*px;/i, 'height: 100%');
svg = svg.replaceAll('&amp;nbsp;', ' ');
element.innerHTML = svg;
};
const drawDiagram = async function () {
const element = document.querySelector('.strudel-mermaid');
let code = window.strudelMirror.code;
code = code.replace(/^await debugAudiograph.*\n?/gm, '');
code = '// date: ' + new Date().toISOString() + '\n\n' + code;
code = '// host: ' + document.location.hostname + '\n' + code;
const codeLines = code.split(/(?:\n|\r\n?)/);
const maxLineLength = codeLines.reduce((memo, line) => Math.max(memo, line.length), 0);
// https://mermaid.js.org/syntax/flowchart.html
let gd = '';
gd += '---\n';
gd += 'config:\n';
gd += ' flowchart:\n';
gd += ' wrappingWidth: ' + 14 * maxLineLength + '\n';
gd += '---\n';
gd += 'flowchart TB\n';
gd += '\tsubgraph AG[STRUDEL AUDIOGRAPH]\n';
// seed graph builder with all
// unconnected nodes
let lookup = [];
cache.forEach((v, k) => {
if (v.connect.length === 0) lookup.push(k);
});
const relations = [];
let curRelations;
const zombieCount = 0;
const sourceLoc = (stack) => {
if (stack === '@') return stack;
return stack.replace('@', '\n').replace('/superdough/', '/');
};
const label = (s) => {
const source = s.creation ? '\n' + sourceLoc(s.creation) : '';
let lb = '[' + '**' + s.type + '**' + source + ']';
if (s.ac === 'OfflineAudioContext') lb = '[' + lb + ']';
return lb;
};
const isConnectLeak = (s) => {
return (
['AudioDestinationNode', 'AudioParam'].indexOf(s.type) === -1 &&
s.disconnectAll === 0 &&
s.connect.length > s.disconnectOne
);
};
const isStopLeak = (s) => {
return s.hasStop && s.stopCount === 0;
};
do {
curRelations = relations.length;
lookup.slice().forEach((n) => {
cache.forEach((v, k) => {
if (v.connect.indexOf(n) !== -1) {
if (lookup.indexOf(k) === -1) lookup.push(k);
gd += v.connect
.map((i) => {
if (lookup.indexOf(i) === -1) lookup.push(i);
if (relations.indexOf(k + '-' + i) === -1) {
relations.push(k + '-' + i);
return (
'\t\tnode' +
k +
label(v) +
' -- ' +
sourceLoc(v.where[0]) +
' --> node' +
i +
label(cache.get(i)) +
'\n'
);
}
})
.join('');
}
if (k === n) {
gd += v.connect
.map((i) => {
if (lookup.indexOf(i) === -1) lookup.push(i);
if (relations.indexOf(k + '-' + i) === -1) {
relations.push(k + '-' + i);
return (
'\t\tnode' +
k +
label(v) +
' -- ' +
sourceLoc(v.where[0]) +
' --> node' +
i +
label(cache.get(i)) +
'\n'
);
}
})
.join('');
}
});
});
} while (relations.length > curRelations /*&& lookup.length < 100*/);
// add orphan nodes
const inRelation = '-' + relations.join('-') + '-';
cache.forEach((v, k) => {
if (!inRelation.includes('-' + k + '-')) {
gd += '\t\tnode' + k + label(v) + '\n';
}
});
const codePlaceholder = 'm'.repeat(maxLineLength);
gd += '\tsubgraph LEGEND\n';
gd += '\t\tlegend1[in AudioContext]\n';
gd += '\t\tlegend2[[in OfflineAudioContext]]\n';
gd += '\t\tlegend3[not disconnected]\n';
gd += '\t\tlegend4[AudioParam]\n';
gd += '\t\tlegend5[AudioDestinationNode]\n';
gd += '\t\tlegend6[not stopped]\n';
gd += '\tend\n';
gd += '\tsubgraph CODE[Strudel Code]\n';
// we use a codePlaceholder to
// - avoid problems with special chars
// - stop mermaid to split lines on space with multiple tspans
// - force mermaid to prepare a sufficiently sized zone
gd += '\ncode[' + (codePlaceholder + '<br>').repeat(codeLines.length) + ']\n';
gd += '\tend\n';
gd += '\tend\n';
gd += '\tclassDef audioparam fill:#6f6;\n';
gd += '\tclassDef destination fill:#99f;\n';
gd += '\tclassDef connectleak fill:#f96,stroke:#f00,stroke-width:2px;\n';
gd += '\tclassDef stopleak fill:#f55,stroke:#f00,stroke-width:2px;\n';
gd += '\tclass legend3 connectleak;\n';
gd += '\tclass legend4 audioparam;\n';
gd += '\tclass legend5 destination;\n';
gd += '\tclass legend6 stopleak;\n';
cache.forEach((v, k) => {
if (isConnectLeak(v)) {
gd += '\tclass node' + k + ' connectleak;\n';
} else if (isStopLeak(v)) {
gd += '\tclass node' + k + ' stopleak;\n';
}
if (v.type === 'AudioParam') {
gd += '\tclass node' + k + ' audioparam;\n';
}
if (v.type === 'AudioDestinationNode') {
gd += '\tclass node' + k + ' destination;\n';
}
});
let { svg } = await mermaid.render('strudelSvgId', gd);
// put real code in code zone
let idx = 0;
const escapeHtml = (unsafe) => {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
svg = svg.replaceAll(codePlaceholder, () => escapeHtml(codeLines[idx++]));
// improve sizing on web page
svg = svg.replace(/max-width:\s[0-9.]*px;/i, 'height: 100%');
element.innerHTML = svg;
// align the code lines
let svgText = document.querySelector('[id^=flowchart-code] text');
svgText.setAttributeNS(null, 'style', 'text-anchor: start;');
const svgElement = document.querySelector('svg');
const svgLabel = document.querySelector('svg [id^=flowchart-code] .label');
const transformList = svgLabel.transform.baseVal;
const svgTransform = svgElement.createSVGTransform();
const tspans = Array.from(document.querySelectorAll('[id^=flowchart-code] tspan.text-inner-tspan'));
let tspansMaxLength = tspans.reduce((memo, tspan) => Math.max(memo, tspan.getComputedTextLength()), 0);
svgTransform.setTranslate(-tspansMaxLength / 2, 0);
transformList.appendItem(svgTransform);
let doPan = false;
let eventsHandler;
let panZoom;
let mousepos;
eventsHandler = {
haltEventListeners: ['mousedown', 'mousemove', 'mouseup'],
mouseDownHandler: function (ev) {
if (event.target.className == '[object SVGAnimatedString]') {
doPan = true;
mousepos = {
x: ev.clientX,
y: ev.clientY,
};
}
},
mouseMoveHandler: function (ev) {
if (doPan) {
panZoom.panBy({
x: ev.clientX - mousepos.x,
y: ev.clientY - mousepos.y,
});
mousepos = {
x: ev.clientX,
y: ev.clientY,
};
window.getSelection().removeAllRanges();
}
},
mouseUpHandler: function (ev) {
doPan = false;
},
init: function (options) {
options.svgElement.addEventListener('mousedown', this.mouseDownHandler, false);
options.svgElement.addEventListener('mousemove', this.mouseMoveHandler, false);
options.svgElement.addEventListener('mouseup', this.mouseUpHandler, false);
},
destroy: function (options) {
options.svgElement.removeEventListener('mousedown', this.mouseDownHandler, false);
options.svgElement.removeEventListener('mousemove', this.mouseMoveHandler, false);
options.svgElement.removeEventListener('mouseup', this.mouseUpHandler, false);
},
};
panZoom = svgPanZoom('#strudelSvgId', {
zoomEnabled: true,
controlIconsEnabled: true,
fit: 1,
center: 1,
zoomScaleSensitivity: 0.4,
customEventsHandler: eventsHandler,
});
};
const svgExport = async () => {
const a = document.createElement('a');
document.body.appendChild(a);
a.style = 'display: none';
const selector = '.strudel-mermaid';
const bbox = document.querySelector('svg g').getBBox();
let transform, style;
// clean pan-zoom viewport
const pzViewport = document.querySelector('.svg-pan-zoom_viewport');
if (pzViewport) {
transform = pzViewport.transform;
style = pzViewport.style;
pzViewport.setAttribute('transform', '');
pzViewport.style = '';
}
const spzMin = await fetch('https://cdn.jsdelivr.net/npm/svg-pan-zoom@3.6.2/dist/svg-pan-zoom.min.js').then((res) =>
res.text(),
);
const scriptContent = '<![CDATA[' + spzMin + ';svgPanZoom("svg");]]>';
// prepare svg
const content = document
.querySelector(selector)
.innerHTML.replaceAll('<br>', '<br/>')
// remove useless tags
.replace(/<g id="svg-pan-zoom.*<\/g>/, '<script>' + scriptContent + '</script>')
.replace(/<defs>.*<\/defs>/, '')
// give inkscape true sizes
.replace('width="100%"', 'width="' + bbox.width + '" height="' + bbox.height + '"');
// restore pan-zoom viewport
if (pzViewport) {
pzViewport.setAttribute('transform', transform);
pzViewport.style = style;
}
// trigger download
var blob = new Blob([content], { type: 'image/svg+xml' }),
url = window.URL.createObjectURL(blob);
a.href = url;
a.download = 'audiograph.svg';
a.click();
window.URL.revokeObjectURL(url);
};
const resetAudioOutput = function (audioid) {
// calling reset on SuperdoughAudioController
// will discard output nodes AND recreate them
// so we keep the same `cache` to handle the
// `disconnects` knowing that new nodes will be
// stricly after the current audioid.
// then we purge the old nodes from the `cache`
// to have a clean state
// make sure destination will be recreated in the
// cache
const destination = getAudioContext().destination;
if (destination._audioid) delete destination._audioid;
const sac = getSuperdoughAudioController();
sac.reset();
Array.from(cache.keys()).map((k) => {
if (k <= audioid) cache.delete(k);
});
};
const postProcessing = async function () {
hap_count = 0;
await drawDiagram();
resetAudioOutput(audioid);
};
const defaultOptions = {
StopAfterHapCount: 10,
hapsBatch: 0,
maxEdges: 10000,
maxTextSize: 200000,
audioAPIBreathingRoomSec: 5,
};
// `StopAfterHapCount` :
// The player will auto-stop after hap count have
// been played. when StopAfterHapCount = 0, it will
// continue playing until 'stop' is clicked
// `audioAPIBreathingRoomSec` :
// how much time should we wait after 'stop' to let
// the audioAPI finish its tail of ondended calls
// `hapsBatch` :
// the AudioGraph will be displayed every hapsBatch haps
// when hapsBatch = 0, AudioGraph will only be displayed
// after and auto-stop or after 'stop' is clicked
// In hapsBatch mode you will probably see a trailing of
// non disconnected notes on the graph because the audio
// API may have some lag disconnecting them
// cf also audioAPIBreathingRoomSec
// `maxEdges`
// This is a mermaid.js config that forces a hard limit
// on the maximum number of Edges of a graph
// needs a reload to be taken into account
// `maxTextSize`
// This is a mermaid.js config that forces a hard limit
// on the maximum text size of a graph definition
// needs a reload to be taken into account
export const debugAudiograph = async (argOptions = {}) => {
const options = Object.assign({}, defaultOptions, argOptions);
const { StopAfterHapCount, hapsBatch, maxEdges, maxTextSize, audioAPIBreathingRoomSec } = options;
const sm = window.strudelMirror;
const code = sm.code;
if (!code.match(/await\s+debugAudiograph/)) {
throw new Error('you need to call `await debugAudiograph()` for audiograph to work');
}
const emptyOptions = /await\s+debugAudiograph\(\)/.exec(code);
if (emptyOptions) {
const cutCode = emptyOptions.index + emptyOptions[0].length - 1;
const codeOptions = JSON.stringify({ StopAfterHapCount: StopAfterHapCount }).replaceAll('"', '');
sm.setCode(code.slice(0, cutCode) + codeOptions + code.slice(cutCode));
}
if (window.audiograph === undefined) {
const ag = (window.audiograph = {});
toggleOrig = sm.toggle;
////////////////////////////////////////
// step 1: web audio api instrumentation
////////////////////////////////////////
// path AudioNode & AudioParam
// to give them lazy ids
// this captures both `ac.createGain`
// and `new GainNode(..)` patterns
lazyRegister(AudioNode);
lazyRegister(AudioParam);
lazyRegister(PeriodicWave);
const audioNodes = [
AudioBufferSourceNode,
AudioWorkletNode,
AnalyserNode,
BiquadFilterNode,
ChannelMergerNode,
ChannelSplitterNode,
ConstantSourceNode,
ConvolverNode,
DelayNode,
DynamicsCompressorNode,
GainNode,
IIRFilterNode,
OscillatorNode,
PannerNode,
StereoPannerNode,
WaveShaperNode,
];
audioNodes.map((n) => {
if (n.prototype instanceof AudioScheduledSourceNode) {
const stopOrig = n.prototype.stop;
n.prototype.stop = function (...args) {
// stop called
const result = stopOrig.call(this, ...args);
const s = cache.get(this.audioid);
s.stopCount++;
return result;
};
}
audioNodeHook(n);
});
// patch BaseAudioContext factory methods
// to capture the source reference
Object.getOwnPropertyNames(BaseAudioContext.prototype)
.filter((n) => n.startsWith('create') && ['createBuffer'].indexOf(n) === -1)
.map((name) => {
const orig = BaseAudioContext.prototype[name];
BaseAudioContext.prototype[name] = function (...args) {
const result = orig.call(this, ...args);
const s = cache.get(result.audioid);
s.creation = stackTrace();
return result;
};
});
const connectOrig = AudioNode.prototype.connect;
AudioNode.prototype.connect = function (destination, ...args) {
const result = connectOrig.call(this, destination, ...args);
const s = cache.get(this.audioid);
s.connect.push(destination.audioid);
s.where.push(stackTrace());
return result;
};
const disconnectOrig = AudioNode.prototype.disconnect;
AudioNode.prototype.disconnect = function (destination, ...args) {
const result = disconnectOrig.call(this, destination, ...args);
const s = cache.get(this.audioid);
if (s.connect.length) {
if (destination) {
s.disconnectOne++;
} else {
s.disconnectAll++;
}
} else {
logger('WEIRD: node ' + this.audioid + 'called disconnect before any call to connect !');
//logger(new Error().stack);
console.log(cache);
}
return result;
};
// call reset 2 times to handle reload + 'play'
// the first reset's disconnect adds audioid tags on previous outputs
// that were not tagged (wrong cutoff)
resetAudioOutput(audioid);
// the second reset has the correct audioid cutoff
resetAudioOutput(audioid);
////////////////////////////////////////
// step 2: Load external modules
////////////////////////////////////////
const { default: mermaidModule } = await import(
'https://cdn.jsdelivr.net/npm/mermaid@11.12.1/dist/mermaid.esm.mjs'
);
mermaid = mermaidModule;
mermaid.initialize({
startOnLoad: false,
themeCSS: '.flowchart { height: 100%; }',
maxEdges: maxEdges,
maxTextSize: maxTextSize,
htmlLabels: false,
flowchart: {
htmlLabels: false,
},
});
const { default: svgPanZoomModule } = await import('https://esm.sh/svg-pan-zoom');
svgPanZoom = svgPanZoomModule;
//////////////////////////////////////////
// step 3: UI modifications
//////////////////////////////////////////
// add audiograph panel
if (!document.querySelector('.strudel-mermaid')) {
const mermaidDiv = document.createElement('div');
mermaidDiv.className = 'strudel-mermaid';
mermaidDiv.style = 'min-height: 600px; width: 60%';
const referenceNode = document.querySelector('#code');
referenceNode.parentNode.insertBefore(mermaidDiv, referenceNode.nextSibling);
}
// add svg export button
if (!document.querySelector('button[title=svg]')) {
const exportButton = document.createElement('button');
exportButton.innerHTML = '<span>ExportDiagram</span>';
exportButton.title = 'svg';
exportButton.onclick = svgExport;
const updateButton = document.querySelector('button[title=update]');
updateButton.parentNode.insertBefore(exportButton, updateButton);
}
}
if (!running) {
running = true;
}
if (hapsBatch === 0 || hap_count < hapsBatch) {
let msg = '';
msg += 'Recording activity...';
msg += '\npress stop to build diagram';
if (StopAfterHapCount) {
msg += '\nwill stop automatically in ' + Math.max(StopAfterHapCount - hap_count, 0) + ' haps';
}
await drawMessage(msg);
}
sm.toggle = async () => {
running = false;
sm.toggle = toggleOrig;
// schedule `toggle` on the js main loop
// to avoid interfering with any on-flight onTick
// not doing this can lead to a phase > 0 which will
// break the next start
setTimeout(sm.toggle.bind(sm), 0);
await drawMessage('please wait ' + audioAPIBreathingRoomSec + ' seconds\n' + 'the audio API is finishing its work');
setTimeout(postProcessing, audioAPIBreathingRoomSec * 1000);
};
/*global all*/
all((pat) =>
pat.onTrigger(async (hap, duration, cps, t) => {
hap_count++;
const key = Object.entries(hap.value)
.map((param) => param.join('/'))
.join('/');
// if we reached StopAfterHapCount, click 'stop'
if (StopAfterHapCount && hap_count > StopAfterHapCount) {
if (running) {
await sm.toggle();
}
// stop sending haps to superdough(...)
return;
}
await webaudioOutput(hap, t, hap.duration / cps, cps, t);
if (hapsBatch && hap_count % hapsBatch === 0) drawDiagram();
}),
);
};
@@ -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 (
<label className={cx(disabled && 'opacity-50')}>
<input disabled={disabled} type="checkbox" checked={value} onChange={onChange} />
{' ' + label}
</label>
);
}
function FormItem({ label, children, disabled }) {
return (
<div className="grid gap-2 w-full">
<label className={cx(disabled && 'opacity-50')}>{label}</label>
{children}
</div>
);
}
export default function ExportTab(Props) {
const { handleExport } = Props;
const [downloadName, setDownloadName] = useState('');
const [startCycle, setStartCycle] = useState(0);
const [endCycle, setEndCycle] = useState(1);
const [sampleRate, setSampleRate] = useState(48000);
const [multiChannelOrbits, setMultiChannelOrbits] = useState(true);
const [maxPolyphony, setMaxPolyphony] = useState(1024);
const [exporting, setExporting] = useState(false);
const [progress, setProgress] = useState(0);
const [length, setLength] = useState(1);
const refreshProgress = () => {
const audioContext = getAudioContext();
if (audioContext instanceof OfflineAudioContext) {
setProgress(audioContext.currentTime);
setLength(audioContext.length / sampleRate);
setTimeout(refreshProgress, 100);
}
};
return (
<>
<div className="text-foreground w-full p-4 space-y-4">
<FormItem label="File name" disabled={exporting}>
<Textbox
onBlur={(e) => {
setDownloadName(e.target.value);
}}
onChange={(v) => {
setDownloadName(v);
}}
disabled={exporting}
placeholder="Leave empty to use current date"
className={cx('placeholder:opacity-50', exporting && 'opacity-50 border-opacity-50')}
value={downloadName ?? ''}
/>
</FormItem>
<div className="flex flex-row gap-4 w-full">
<FormItem label="Start cycle" disabled={exporting}>
<Textbox
min={1}
max={Infinity}
onBlur={(e) => {
let v = parseInt(e.target.value);
v = isNaN(v) ? 0 : Math.max(0, v);
setStartCycle(v);
}}
onChange={(v) => {
v = parseInt(v);
setStartCycle(v);
}}
type="number"
placeholder=""
disabled={exporting}
className={cx(exporting && 'opacity-50 border-opacity-50', 'w-full')}
value={startCycle ?? ''}
/>
</FormItem>
<FormItem label="End cycle" disabled={exporting}>
<Textbox
min={1}
max={Infinity}
onBlur={(e) => {
let v = parseInt(e.target.value);
v = isNaN(v) ? Math.max(startCycle + 1, parseInt(v)) : v;
setEndCycle(v);
}}
onChange={(v) => {
v = parseInt(v);
setEndCycle(v);
}}
type="number"
placeholder=""
disabled={exporting}
className={cx(exporting && 'opacity-50 border-opacity-50', 'w-full')}
value={endCycle ?? ''}
/>
</FormItem>
</div>
<div className="flex flex-row gap-4">
<FormItem label="Sample rate" disabled={exporting}>
<Textbox
min={1}
max={Infinity}
onBlur={(e) => {
let v = parseInt(e.target.value);
v = isNaN(v) ? 1 : Math.max(1, v);
setSampleRate(v);
}}
onChange={(v) => {
v = parseInt(v);
setSampleRate(v);
}}
type="number"
placeholder=""
disabled={exporting}
className={cx(exporting && 'opacity-50 border-opacity-50')}
value={sampleRate ?? ''}
/>
</FormItem>
<FormItem label="Maximum polyphony" disabled={exporting}>
<Textbox
min={1}
max={Infinity}
onBlur={(e) => {
let v = parseInt(e.target.value);
v = isNaN(v) ? Math.max(1, parseInt(v)) : v;
setMaxPolyphony(v);
}}
onChange={(v) => {
v = Math.max(1, parseInt(v));
setMaxPolyphony(v);
}}
type="number"
placeholder=""
disabled={exporting}
className={cx(exporting && 'opacity-50 border-opacity-50')}
value={maxPolyphony ?? ''}
/>
</FormItem>
</div>
<div>
<Checkbox
label="Multi Channel Orbits"
onChange={(cbEvent) => {
const val = cbEvent.target.checked;
setMultiChannelOrbits(val);
}}
disabled={exporting}
value={multiChannelOrbits}
/>
</div>
<button
className={cx('bg-background p-2 w-full rounded-md hover:opacity-75 relative', exporting && 'opacity-50')}
disabled={exporting}
onClick={async () => {
setExporting(true);
setTimeout(refreshProgress, 2000);
const modal = document.getElementById('exportProgressModal');
modal.showModal();
await handleExport(startCycle, endCycle, sampleRate, maxPolyphony, multiChannelOrbits, downloadName)
.then(() => {
const modal = document.getElementById('exportProgressModal');
modal.close();
})
.finally(() => {
setExporting(false);
setProgress(0);
setLength(1);
});
}}
>
<div
className="absolute top-0 left-0 right-0 bottom-0 backdrop-invert"
style={{
width: `${(exporting ? 1 : 0) + (progress / length) * 99}%`,
}}
/>
<span className="text-foreground">{exporting ? 'Exporting...' : 'Export to WAV'}</span>
</button>
</div>
<dialog
closedby={exporting ? 'none' : 'closerequest'}
id="exportProgressModal"
className="text-md bg-background text-foreground rounded-lg backdrop:bg-background backdrop:opacity-25"
/>
</>
);
}
@@ -9,6 +9,7 @@ import { useLogger } from '../useLogger';
import { WelcomeTab } from './WelcomeTab';
import { PatternsTab } from './PatternsTab';
import { ChevronLeftIcon, XMarkIcon } from '@heroicons/react/16/solid';
import ExportTab from './ExportTab';
const TAURI = typeof window !== 'undefined' && window.__TAURI__;
@@ -80,6 +81,7 @@ const tabNames = {
patterns: 'patterns',
sounds: 'sounds',
reference: 'reference',
export: 'export',
console: 'console',
settings: 'settings',
};
@@ -126,6 +128,8 @@ function PanelContent({ context, tab }) {
return <SoundsTab />;
case tabNames.reference:
return <Reference />;
case tabNames.export:
return <ExportTab handleExport={context.handleExport} />;
case tabNames.settings:
return <SettingsTab started={context.started} />;
case tabNames.files:
+19
View File
@@ -393,6 +393,8 @@ samples({
bass: { d2: 'https://cdn.freesound.org/previews/608/608286_13074022-lq.mp3' }
})
useRNG('legacy')
stack(
// bells
n("0").euclidLegato(3,8)
@@ -430,6 +432,7 @@ export const festivalOfFingers3 = `// "Festival of fingers 3"
// @by Felix Roos
setcps(1)
useRNG('legacy')
n("[-7*3],0,2,6,[8 7]")
.echoWith(
@@ -454,6 +457,8 @@ export const meltingsubmarine = `// "Melting submarine"
// @by Felix Roos
samples('github:tidalcycles/dirt-samples')
useRNG('legacy')
stack(
s("bd:5,[~ <sd:1!3 sd:1(3,4,3)>],hh27(3,4,1)") // drums
.speed(perlin.range(.7,.9)) // random sample speed variation
@@ -602,6 +607,9 @@ export const belldub = `// "Belldub"
samples({ bell: {b4:'https://cdn.freesound.org/previews/339/339809_5121236-lq.mp3'}})
// "Hand Bells, B, Single.wav" by InspectorJ (www.jshaw.co.uk) of Freesound.org
useRNG('legacy')
stack(
// bass
note("[0 ~] [2 [0 2]] [4 4*2] [[4 ~] [2 ~] 0@2]".scale('g1 dorian').superimpose(x=>x.add(.02)))
@@ -638,6 +646,7 @@ export const dinofunk = `// "Dinofunk"
// @by Felix Roos
setcps(1)
useRNG('legacy')
samples({bass:'https://cdn.freesound.org/previews/614/614637_2434927-hq.mp3',
dino:{b4:'https://cdn.freesound.org/previews/316/316403_5123851-hq.mp3'}})
@@ -666,6 +675,8 @@ export const sampleDemo = `// "Sample demo"
// @license CC BY-NC-SA 4.0 https://creativecommons.org/licenses/by-nc-sa/4.0/
// @by Felix Roos
useRNG('legacy')
stack(
// percussion
s("[woodblock:1 woodblock:2*2] snare_rim:0,gong/8,brakedrum:1(3,8),~@3 cowbell:3")
@@ -684,6 +695,8 @@ export const holyflute = `// "Holy flute"
// @license CC BY-NC-SA 4.0 https://creativecommons.org/licenses/by-nc-sa/4.0/
// @by Felix Roos
useRNG('legacy')
"c3 eb3(3,8) c4/2 g3*2"
.superimpose(
x=>x.slow(2).add(12),
@@ -699,6 +712,8 @@ export const flatrave = `// "Flatrave"
// @license CC BY-NC-SA 4.0 https://creativecommons.org/licenses/by-nc-sa/4.0/
// @by Felix Roos
useRNG('legacy')
stack(
s("bd*2,~ [cp,sd]").bank('RolandTR909'),
@@ -727,6 +742,8 @@ export const amensister = `// "Amensister"
samples('github:tidalcycles/dirt-samples')
useRNG('legacy')
stack(
// amen
n("0 1 2 3 4 5 6 7")
@@ -834,6 +851,8 @@ export const arpoon = `// "Arpoon"
// @license CC BY-NC-SA 4.0 https://creativecommons.org/licenses/by-nc-sa/4.0/
// @by Felix Roos
useRNG('legacy')
samples('github:tidalcycles/dirt-samples')
n("[0,3] 2 [1,3] 2".fast(3).lastOf(4, fast(2))).clip(2)
+29
View File
@@ -9,11 +9,13 @@ import { getDrawContext } from '@strudel/draw';
import { evaluate, transpiler } from '@strudel/transpiler';
import {
getAudioContextCurrentTime,
renderPatternAudio,
webaudioOutput,
resetGlobalEffects,
resetLoadedSounds,
initAudioOnFirstClick,
resetDefaults,
initAudio,
} from '@strudel/webaudio';
import { setVersionDefaultsFrom } from './util.mjs';
import { StrudelMirror, defaultSettings } from '@strudel/codemirror';
@@ -36,6 +38,7 @@ import { getRandomTune, initCode, loadModules, shareCode } from './util.mjs';
import './Repl.css';
import { setInterval, clearInterval } from 'worker-timers';
import { getMetadata } from '../metadata_parser';
import { debugAudiograph } from './audiograph';
const { latestCode, maxPolyphony, audioDeviceName, multiChannelOrbits } = settingsMap.get();
let modulesLoading, presets, drawContext, clearCanvas, audioReady;
@@ -129,6 +132,7 @@ export function useReplContext() {
bgFill: false,
});
window.strudelMirror = editor;
window.debugAudiograph = debugAudiograph;
// init settings
initCode().then(async (decoded) => {
@@ -207,6 +211,30 @@ export function useReplContext() {
const handleEvaluate = () => {
editorRef.current.evaluate();
};
const handleExport = async (begin, end, sampleRate, maxPolyphony, multiChannelOrbits, downloadName = undefined) => {
await editorRef.current.evaluate(false);
editorRef.current.repl.scheduler.stop();
await renderPatternAudio(
editorRef.current.repl.state.pattern,
editorRef.current.repl.scheduler.cps,
begin,
end,
sampleRate,
maxPolyphony,
multiChannelOrbits,
downloadName,
).finally(async () => {
const { latestCode, maxPolyphony, audioDeviceName, multiChannelOrbits } = settingsMap.get();
await initAudio({
latestCode,
maxPolyphony,
audioDeviceName,
multiChannelOrbits,
});
editorRef.current.repl.scheduler.stop();
});
};
const handleShuffle = async () => {
const patternData = await getRandomTune();
const code = patternData.code;
@@ -235,6 +263,7 @@ export function useReplContext() {
handleShuffle,
handleShare,
handleEvaluate,
handleExport,
init,
error,
editorRef,