diff --git a/packages/codemirror/codemirror.mjs b/packages/codemirror/codemirror.mjs index 69fce4b50..05725264a 100644 --- a/packages/codemirror/codemirror.mjs +++ b/packages/codemirror/codemirror.mjs @@ -27,7 +27,7 @@ import { updateWidgets, widgetPlugin } from './widget.mjs'; export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands'; -const extensions = { +export const extensions = { isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []), isBracketMatchingEnabled: (on) => (on ? bracketMatching({ brackets: '()[]{}<>' }) : []), isBracketClosingEnabled: (on) => (on ? closeBrackets() : []), @@ -48,7 +48,7 @@ const extensions = { ] : [], }; -const compartments = Object.fromEntries(Object.keys(extensions).map((key) => [key, new Compartment()])); +export const compartments = Object.fromEntries(Object.keys(extensions).map((key) => [key, new Compartment()])); export const defaultSettings = { keybindings: 'codemirror', @@ -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(); @@ -411,7 +411,7 @@ export class StrudelMirror { } } -function parseBooleans(value) { +export function parseBooleans(value) { return { true: true, false: false }[value] ?? value; } diff --git a/packages/codemirror/index.mjs b/packages/codemirror/index.mjs index 3a5f2a23c..2d5b3de21 100644 --- a/packages/codemirror/index.mjs +++ b/packages/codemirror/index.mjs @@ -4,3 +4,4 @@ export * from './flash.mjs'; export * from './slider.mjs'; export * from './themes.mjs'; export * from './widget.mjs'; +export { Vim } from './keybindings.mjs'; diff --git a/packages/codemirror/keybindings.mjs b/packages/codemirror/keybindings.mjs index 81dc87a52..24437b7e1 100644 --- a/packages/codemirror/keybindings.mjs +++ b/packages/codemirror/keybindings.mjs @@ -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'; @@ -131,7 +131,13 @@ const keymaps = { vscode: vscodeExtension, }; +export { Vim } from '@replit/codemirror-vim'; + 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; } diff --git a/packages/core/bench/signal.bench.mjs b/packages/core/bench/signal.bench.mjs new file mode 100644 index 000000000..bd651ccd3 --- /dev/null +++ b/packages/core/bench/signal.bench.mjs @@ -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); diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index f28d13f62..b18a8dff6 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -4,7 +4,8 @@ Copyright (C) 2022 Strudel contributors - see . */ -import { Pattern, register, reify } from './pattern.mjs'; +import { logger } from './logger.mjs'; +import { Pattern, pure, register, reify } from './pattern.mjs'; export function createParam(names) { let isMulti = Array.isArray(names); @@ -2044,6 +2045,28 @@ export const { octave, oct } = registerControl('octave', 'oct'); * ) */ export const { orbit } = registerControl('orbit', 'o'); + +/** + * A `bus` is a send which can be used for mixing patterns. It combines with.. + * s("bus") to play that bus through another pattern (for, say, applying non-linear + * effects like distortion to multiple signals) + * + * otherPat.bmod(..) (to modulate another pattern with the bus) + * + * @name bus + * @param {number | Pattern} number + */ +export const { bus } = registerControl('bus'); + +/** + * Postgain multiplier prior to sending the signal to the audio bus. + * + * @name busgain + * @synonyms bgain + * @param {number | Pattern} number + */ +export const { busgain, bgain } = registerControl('busgain', 'bgain'); + // TODO: what is this? not found in tidal doc Answer: gain is limited to maximum of 2. This allows you to go over that export const { overgain } = registerControl('overgain'); // TODO: what is this? not found in tidal doc. Similar to above, but limited to 1 @@ -2088,8 +2111,7 @@ export const { panorient } = registerControl('panorient'); // ['pitch2'], // ['pitch3'], // ['portamento'], -// TODO: LFO rate see https://tidalcycles.org/docs/patternlib/tutorials/synthesizers/#supersquare -export const { rate } = registerControl('rate'); + // TODO: slide param for certain synths export const { slide } = registerControl('slide'); // TODO: detune? https://tidalcycles.org/docs/patternlib/tutorials/synthesizers/#supersquare @@ -2515,7 +2537,6 @@ export const { curve } = registerControl('curve'); export const { deltaSlide } = registerControl('deltaSlide'); export const { pitchJump } = registerControl('pitchJump'); export const { pitchJumpTime } = registerControl('pitchJumpTime'); -export const { lfo, repeatTime } = registerControl('lfo', 'repeatTime'); // noise on the frequency or as bubo calls it "frequency fog" :) export const { znoise } = registerControl('znoise'); export const { zmod } = registerControl('zmod'); @@ -2792,6 +2813,251 @@ export const scrub = register( false, ); +const subControlAliases = new Map(); +const registerSubControl = (control, subControl, ...aliases) => { + const aliasMap = subControlAliases.get(control) ?? new Map(); + const allKeys = new Set([subControl, ...aliases]); + for (const alias of allKeys) { + aliasMap.set(String(alias).toLowerCase(), subControl); + } + subControlAliases.set(control, aliasMap); +}; + +const registerSubControls = (control, subControlAliases = []) => { + for (const [subControl, ...aliases] of subControlAliases) { + registerSubControl(control, subControl, ...aliases); + } +}; + +const getMainSubcontrolName = (control, subKey) => { + const aliasMap = subControlAliases.get(control); + if (!aliasMap) return subKey; + return aliasMap.get(String(subKey).toLowerCase()) ?? subKey; +}; + +registerSubControls('lfo', [ + ['control', 'c'], + ['subControl', 'sc'], + ['rate', 'r'], + ['depth', 'dep', 'dr'], + ['depthabs', 'da'], + ['dcoffset', 'dc'], + ['shape', 'sh'], + ['skew', 'sk'], + ['curve'], + ['sync', 's'], + ['fxi'], +]); +registerSubControls('env', [ + ['control', 'c'], + ['subControl', 'sc'], + ['attack', 'att', 'a'], + ['decay', 'dec', 'd'], + ['sustain', 'sus', 's'], + ['release', 'rel', 'r'], + ['depth', 'dep', 'dr'], + ['depthabs', 'da'], + ['acurve', 'ac'], + ['dcurve', 'dc'], + ['rcurve', 'rc'], + ['fxi'], +]); +registerSubControls('bmod', [ + ['bus', 'b'], + ['control', 'c'], + ['subControl', 'sc'], + ['depth', 'dep', 'dr'], + ['depthabs', 'da'], + ['dc'], + ['fxi'], +]); + +Pattern.prototype.modulate = function (type, config, id) { + config = { control: undefined, ...config }; + const modulatorKeys = ['lfo', 'env', 'bmod']; + if (!modulatorKeys.includes(type)) { + logger(`[core] Modulation type ${type} not found. Please use one of 'lfo', 'env', 'bmod'`); + return this; + } + let output = this; + let defaultValue = undefined; + for (const [rawKey, value] of Object.entries(config)) { + const key = getMainSubcontrolName(type, rawKey); + const valuePat = reify(value); + output = output + .fmap((v) => (c) => { + if (defaultValue === undefined) { + // default control to the control set just before this in the chain + // e.g. pat.gain(0.5).lfo({..}) will be a gain-LFO + let control = getControlName(Object.keys(v).at(-1)); + if (modulatorKeys.includes(control)) { + control = `${control}_${[...v[control].__ids].at(-1)}`; + } + defaultValue = control; + } + v[type] ??= { __ids: new Set() }; + const t = v[type]; + id ??= t.__ids.size; + t[id] ??= { control: defaultValue }; + t.__ids.add(id); // keeps track of insertion order + if (c === undefined) return v; + if (key === 'control' || key === 'subControl') { + t[id][key] = getControlName(c); + } else { + t[id][key] = c; + } + return v; + }) + .appLeft(valuePat); + } + return output; +}; + +/** + * Configures an LFO. Can be called in sequence like pat.lfo(...).lfo(...) to set up multiple LFOs. + * There are two ways to declare which control will be modulated: + * 1. Explicitly put `control` in the config (e.g. `lfo({ c: "lpf" })`) + * 2. If the control parameter is absent, the control _immediately before_ the `lfo` call will be used + * (e.g. `s("saw").lpf(500).lfo()` to modulate `lpf`) + * + * Modulators can be referred to by `id` so that they can be updated later e.g. inside + * a `sometimes`. See example below. + * + * @name lfo + * @param {Object} config LFO configuration. + * @param {string | Pattern} [config.control] Node to modulate. Aliases: t + * @param {string | Pattern} [config.subControl] Sub-control name to append to the control key. Aliases: sc, p + * @param {number | Pattern} [config.rate] Modulation rate. Aliases: rate, r + * @param {number | Pattern} [config.depth] Relative modulation depth. Aliases: dep, dr + * @param {number | Pattern} [config.depthabs] Absolute modulation depth. Aliases: da + * @param {number | Pattern} [config.dcoffset] DC offset / bias for the waveform. Aliases: dc + * @param {number | Pattern} [config.shape] Shape index. Aliases: sh + * @param {number | Pattern} [config.skew] Skew amount. Aliases: sk + * @param {number | Pattern} [config.curve] Exponential curve amount. Aliases: c + * @param {number | Pattern} [config.sync] Tempo-synced modulation rate. Aliases: s + * @param {number | Pattern} [config.fxi] FX index to target + * @param {string | Pattern} id ID to use for this modulator + * @returns Pattern + * + * @example + * s("saw").note("F1").lpf(500).lfo() + * + * @example + * s("saw").lfo().lpf(500).lfo({ s: 0.3 }) + * + * @example + * s("saw").lpf(500).diode(0.3) + * .lfo({ c: "lpf" }) + * + * @example + * s("pulse").lpf(500).lfo() + * .lfo({ c: "s" }) + * .diode(0.3) + * .sometimes(x => x.lfo({ s: "8" }, 1)) // lfo #1 (0-indexed) + * + * @example + * s("pulse").lpf(500).lfo({ depth: 4 }, 'lpf_mod') + * .lfo({ c: "s" }) + * .diode(0.3) + * .sometimes(x => x.lfo({ s: "8" }, 'lpf_mod')) + */ +Pattern.prototype.lfo = function (config, id) { + return this.modulate('lfo', config, id); +}; +export const lfo = (config) => pure({}).lfo(config); + +/** + * Configures an envelope. Can be called in sequence like pat.env(...).env(...) to set up multiple envelopes + * There are two ways to declare which control will be modulated: + * 1. Explicitly put `control` in the config (e.g. `env({ c: "lpf" })`) + * 2. If the control parameter is absent, the control _immediately before_ the `env` call will be used + * (e.g. `s("saw").lpf(500).env({ a: 1 })` to modulate `lpf`) + * + * Modulators can be referred to by `id` so that they can be updated later e.g. inside + * a `sometimes`. See example below. + * + * @name env + * @param {Object} config Envelope configuration. + * @param {string | Pattern} [config.control] Node to modulate. Aliases: t + * @param {string | Pattern} [config.subControl] Sub-control name to append to the control key. Aliases: sc, p + * @param {number | Pattern} [config.depth] Relative modulation depth. Aliases: dep, dr + * @param {number | Pattern} [config.depthabs] Absolute modulation depth. Aliases: da + * @param {number | Pattern} [config.attack] Time to reach depth. Aliases: att, a + * @param {number | Pattern} [config.decay] Time to reach sustain. Aliases: dec, d + * @param {number | Pattern} [config.sustain] Sustain depth. Aliases: sus, s + * @param {number | Pattern} [config.release] Time to return to nominal value. Aliases: rel, r + * @param {number | Pattern} [config.acurve] Snappiness of attack curve (-1 = relaxed, 1 = snappy). Aliases: ac + * @param {number | Pattern} [config.dcurve] Snappiness of decay curve (-1 = relaxed, 1 = snappy). Aliases: dc + * @param {number | Pattern} [config.rcurve] Snappiness of release curve (-1 = relaxed, 1 = snappy). Aliases: rc + * @param {number | Pattern} [config.fxi] FX index to target + * @param {string | Pattern} id ID to use for this modulator + * @returns Pattern + * + * @example + * s("saw").note("F1").lpf(500).env({ a: 1 }) + * + * @example + * s("saw").env({ d: 1 }).note("F1") + * .lpq(4).lpf(50) + * .env({ a: 0.1, d: 1, ac: 0.8, dc: 0.3, depth: 50 }) + * + * @example + * s("saw").lpf(500).diode(0.3) + * .env({ c: "lpf", a: 0.5, d: 0.5 }) + * + * @example + * s("pulse").lpf(500).env({ a: 1 }) + * .env({ c: "s", a: 1 }) + * .diode(0.3) + * .sometimes(x => x.env({ a: "0.5" }, 1)) // envelope #1 (0-indexed) + * + * @example + * s("pulse").lpf(500).env({ a: 1 }, 'lpf_mod') + * .env({ c: "s", a: 1 }) + * .diode(0.3) + * .sometimes(x => x.env({ a: "0.5" }, 'lpf_mod')) + */ +Pattern.prototype.env = function (config, id) { + return this.modulate('env', config, id); +}; +export const env = (config) => pure({}).env(config); + +/** + * Modulates with the output from a given `bus`. + * Can be called in sequence like pat.bmod(...).bmod(...) to set up multiple modulators + * + * Send to an audio bus with `otherPat.bus(..)`. + * + * There are two ways to declare which control will be modulated: + * 1. Explicitly put `control` in the config (e.g. `bmod({ id: 2, c: "lpf" })`) + * 2. If the control parameter is absent, the control _immediately before_ the `bmod` call will be used + * (e.g. `s("saw").lpf(500).bmod({ id: 2 })` to modulate `lpf`) + * + * Modulators can be referred to by `id` so that they can be updated later e.g. inside + * a `sometimes`. See example below. + * + * @name bmod + * @param {Object} config Bus modulation configuration. + * @param {string | Pattern} [config.bus] Bus to get modulation signal from + * @param {string | Pattern} [config.control] Node to modulate. Aliases: t + * @param {string | Pattern} [config.subControl] Sub-control name to append to the control key. Aliases: sc, p + * @param {number | Pattern} [config.depth] Relative modulation depth. Aliases: dep, dr + * @param {number | Pattern} [config.depthabs] Absolute modulation depth. Aliases: da + * @param {number | Pattern} [config.dc] DC offset prior to application + * @param {number | Pattern} [config.fxi] FX index to target + * @param {string | Pattern} id ID to use for this modulator + * @returns Pattern + * + * @example + * modulator: s("one").seg(64).gain(slider(0, 0, 1)).bus(1).dry(0) + * carrier: s("saw").bmod({ b: 1 }) + * + */ +Pattern.prototype.bmod = function (config, id) { + return this.modulate('bmod', config, id); +}; +export const bmod = (config) => pure({}).bmod(config); + /** * Transient shaper. Gives independent control over the emphasis on transients * and sustains @@ -2805,3 +3071,5 @@ export const scrub = register( * s("hh*16").bank("tr909").transient("<-1:1 1:-1>") */ export const { transient } = registerControl(['transient', 'transsustain']); + +export const { FXrelease, FXrel, FXr, fxr } = registerControl('FXrelease', 'FXrel', 'FXr', 'fxr'); diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index 6f214386f..02cf91881 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -3721,3 +3721,35 @@ Pattern.prototype.phases = function (list) { export const phases = (list) => { return _ensureListPattern(list).as('phases'); }; + +/** + * Establishes an FX chain. Can be called by chaining .FX(fx1).FX(fx2).. + * calls and/or in a single .FX(fx1, fx2, ..) call. The fx1, .. are _patterns_ which + * establish the controls of the given effect. See examples. + * @name FX + * @memberof Pattern + * @returns Pattern + * @example + * $: s("[sbd ]*4").dec(.4) + * .FX( + * phaser(0.5).gain(2), + * bpf(800), + * distort(1.3), + * room(0.2), + * delay(0.5).gain(1.25), + * distort(0.3), + * ).fxr(1.7) // sets release time of effects (like delay) + * @example + * $: s("saw").fm(0.5) + * .delay(0.3) // outer effects are applied *last* + * .FX(coarse(4)) // first coarse + * .FX(lpf(500).lpe(4).lpa(1).lpd(2)) // then lpf + * .FX(distort(1)) // then distort + */ +Pattern.prototype.FX = function (...effects) { + effects = effects.map(reify); + return this.withValue((v) => (vEff) => { + const currFX = v.FX ?? []; + return { ...v, FX: currFX.concat(vEff) }; + }).appLeft(parray(effects)); +}; diff --git a/packages/core/signal.mjs b/packages/core/signal.mjs index 9ac768951..c9ed21663 100644 --- a/packages/core/signal.mjs +++ b/packages/core/signal.mjs @@ -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)]; +}); diff --git a/packages/core/test/pattern.test.mjs b/packages/core/test/pattern.test.mjs index 408ff274b..aaa14bd27 100644 --- a/packages/core/test/pattern.test.mjs +++ b/packages/core/test/pattern.test.mjs @@ -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( diff --git a/packages/core/test/signal.test.mjs b/packages/core/test/signal.test.mjs new file mode 100644 index 000000000..b02f8a08b --- /dev/null +++ b/packages/core/test/signal.test.mjs @@ -0,0 +1,61 @@ +/* +signal.test.mjs - +Copyright (C) 2022 Strudel contributors - see +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 . +*/ + +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))), + ); + }); +}); diff --git a/packages/soundfonts/fontloader.mjs b/packages/soundfonts/fontloader.mjs index 95e210890..b4ae59828 100644 --- a/packages/soundfonts/fontloader.mjs +++ b/packages/soundfonts/fontloader.mjs @@ -166,7 +166,7 @@ export function registerSoundfonts() { let envEnd = holdEnd + release + 0.01; // vibrato - let vibratoOscillator = getVibratoOscillator(bufferSource.detune, value, time); + const vibratoHandle = getVibratoOscillator(bufferSource.detune, value, time); // pitch envelope getPitchEnvelope(bufferSource.detune, value, time, holdEnd); @@ -174,10 +174,10 @@ export function registerSoundfonts() { const stop = (releaseTime) => {}; onceEnded(bufferSource, () => { releaseAudioNode(bufferSource); - releaseAudioNode(vibratoOscillator); + vibratoHandle?.stop(); onended(); }); - return { node, stop }; + return { node, stop, nodes: { source: [bufferSource], ...vibratoHandle?.nodes } }; }, { type: 'soundfont', prebake: true, fonts }, ); diff --git a/packages/superdough/feedbackdelay.mjs b/packages/superdough/feedbackdelay.mjs index c182d6558..387176134 100644 --- a/packages/superdough/feedbackdelay.mjs +++ b/packages/superdough/feedbackdelay.mjs @@ -5,19 +5,18 @@ if (typeof DelayNode !== 'undefined') { wet = Math.abs(wet); this.delayTime.value = time; - const feedbackGain = ac.createGain(); - feedbackGain.gain.value = Math.min(Math.abs(feedback), 0.995); - this.feedback = feedbackGain.gain; + this.feedbackGain = ac.createGain(); + this.feedbackGain.gain.value = Math.min(Math.abs(feedback), 0.995); + this.feedback = this.feedbackGain.gain; - const delayGain = ac.createGain(); - delayGain.gain.value = wet; - this.delayGain = delayGain; + this.delayGain = ac.createGain(); + this.delayGain.gain.value = wet; - this.connect(feedbackGain); - this.connect(delayGain); - feedbackGain.connect(this); + this.connect(this.feedbackGain); + this.connect(this.delayGain); + this.feedbackGain.connect(this); - this.connect = (target) => delayGain.connect(target); + this.connect = (target) => this.delayGain.connect(target); return this; } start(t) { @@ -25,7 +24,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/helpers.mjs b/packages/superdough/helpers.mjs index 5abc2bad1..8bd8b067b 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -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; @@ -105,22 +104,40 @@ function getModulationShapeInput(val) { return { tri: 0, triangle: 0, sine: 1, ramp: 2, saw: 3, square: 4 }[val] ?? 0; } -export function getLfo(audioContext, begin, end, properties = {}) { - const { shape = 0, ...props } = properties; - const { dcoffset = -0.5, depth = 1 } = properties; +export function getEnvelope(audioContext, properties = {}) { + return getWorklet(audioContext, 'envelope-processor', properties); +} + +export function getLfo(audioContext, properties = {}) { + const { + shape = 0, + begin = 0, + end = 0, + time, + depth = 1, + dcoffset = -0.5, + frequency = 1, + skew = 0.5, + phaseoffset = 0, + curve = 1, + min, + max, + ...props + } = properties; + const lfoprops = { - frequency: 1, - depth, - skew: 0.5, - phaseoffset: 0, - time: begin, begin, end, - shape: getModulationShapeInput(shape), + time: time ?? begin, + depth, dcoffset, - min: dcoffset * depth, - max: dcoffset * depth + depth, - curve: 1, + frequency, + skew, + phaseoffset, + curve, + shape: getModulationShapeInput(shape), + min: min ?? dcoffset * depth, + max: max ?? dcoffset * depth + depth, ...props, }; @@ -162,7 +179,9 @@ export function getParamLfo(audioContext, param, start, end, lfoValues) { } let lfo; if (depth) { - lfo = getLfo(audioContext, start, end, { + lfo = getLfo(audioContext, { + begin: start, + end, depth, dcoffset, ...getLfoInputs, @@ -332,7 +351,7 @@ export function getVibratoOscillator(param, value, t) { releaseAudioNode(vibratoOscillator); }); vibratoOscillator.start(t); - return vibratoOscillator; + return { stop: (t) => vibratoOscillator.stop(t), nodes: { vib: [vibratoOscillator], vib_gain: [gain] } }; } } @@ -388,6 +407,7 @@ export function applyFM(param, value, begin) { const ac = getAudioContext(); const toStop = []; // fm oscillators we will expose `stop` for const fms = {}; + const nodes = {}; // Matrix for (let i = 1; i <= 8; i++) { for (let j = 0; j <= 8; j++) { @@ -438,11 +458,13 @@ export function applyFM(param, value, begin) { output = osc.connect(envGain); } fms[idx] = { input: osc.frequency, output, freq, osc, toCleanup }; + nodes[`fm_${idx}`] = [osc]; } const { input, output, freq, osc, toCleanup } = fms[idx]; const g = gainNode(amt * freq); io.push(isMod ? output.connect(g) : input); cleanupOnEnd(osc, [...toCleanup, g]); + nodes[`fm_${idx}_gain`] = [g]; } if (!io[1]) { logger( @@ -455,6 +477,7 @@ export function applyFM(param, value, begin) { } } return { + nodes, stop: (t) => toStop.forEach((m) => m?.stop(t)), }; } @@ -593,7 +616,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`, ); diff --git a/packages/superdough/index.mjs b/packages/superdough/index.mjs index a382bd15a..c3d2f6c52 100644 --- a/packages/superdough/index.mjs +++ b/packages/superdough/index.mjs @@ -10,6 +10,7 @@ export * from './helpers.mjs'; export * from './synth.mjs'; export * from './zzfx.mjs'; export * from './logger.mjs'; +export * from './modulators.mjs'; export * from './dspworklet.mjs'; export * from './audioContext.mjs'; export * from './wavetable.mjs'; diff --git a/packages/superdough/modulators.mjs b/packages/superdough/modulators.mjs new file mode 100644 index 000000000..7d119a06b --- /dev/null +++ b/packages/superdough/modulators.mjs @@ -0,0 +1,178 @@ +/* +modulators.mjs - Helpers for constructing modulators (envelopes, LFOs, etc.) +Copyright (C) 2025 Strudel contributors - see +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 . +*/ + +import { getAudioContext } from './audioContext.mjs'; +import { gainNode, getEnvelope, getLfo, webAudioTimeout } from './helpers.mjs'; +import { errorLogger } from './logger.mjs'; +import { getSuperdoughControlTargets } from './superdoughdata.mjs'; +import { clamp } from './util.mjs'; + +const getNodeParam = (node, name) => { + // Worklet case + if (node?.parameters) { + const p = node.parameters.get(name); + if (p instanceof AudioParam) { + return p; + } + } + // Built-in node case + let p = node?.[name]; + if (p === undefined && name === 'frequency') { + // Fallbacks for source nodes without 'frequency' params (e.g. soundfonts) + p = node?.['detune'] ?? node?.['playbackRate']; + } + if (p instanceof AudioParam) { + return p; + } + return undefined; +}; + +const controlTargets = getSuperdoughControlTargets(); + +const getControlData = (control) => { + return controlTargets[control.split('_')[0]]; +}; + +const getRangeForParam = (paramName, currentValue) => { + // We clamp the frequency to a reasonable range unless the currentValue + // is low, which indicates this may be an LFO + if (paramName === 'frequency' && currentValue >= 30) { + return { min: 20 - currentValue, max: 24000 - currentValue }; + } + return { min: undefined, max: undefined }; +}; + +const clampWithWaveShaper = (modulator, min, max) => { + const ac = getAudioContext(); + const curve = new Float32Array(256); + for (let i = 0; i < curve.length; i++) { + const x = (i / (curve.length - 1)) * 2 - 1; + curve[i] = clamp(x * max, min, max); + } + const shaper = new WaveShaperNode(ac, { curve }); + const scaleGain = gainNode(1 / max); + modulator.connect(scaleGain).connect(shaper); + return { modulator, toCleanup: [shaper, scaleGain] }; +}; + +const getTargetParamsForControl = (control, nodes, subControl) => { + const lookupKey = subControl ? `${control}_${subControl}` : control; + const targetInfo = getControlData(lookupKey) ?? getControlData(control); + if (!targetInfo) { + errorLogger( + new Error(`Could not find control data for target '${control}'. It may not be modulatable.`), + 'superdough', + ); + return { targetParams: [], paramName: control }; + } + const paramName = targetInfo.param; + const nodeKey = nodes[targetInfo.node] ? targetInfo.node : control; + const targetNodes = nodes[nodeKey]; + if (!targetNodes) { + const keys = Object.keys(nodes); + errorLogger( + new Error(`Could not connect to target '${nodeKey}' — it does not exist. Available targets: ${keys.join(', ')}`), + 'superdough', + ); + return { targetParams: [], paramName }; + } + const audioParams = []; + targetNodes.forEach((targetNode) => { + const targetParam = getNodeParam(targetNode, paramName); + audioParams.push(targetParam); + }); + return { targetParams: audioParams, paramName }; +}; + +export const connectLFO = (id, params, nodeTracker) => { + const { + rate = 1, + sync, + cps, + cycle, + control = 'lfo', + subControl, + fxi = 'main', + depth = 1, + depthabs, + ...filteredParams + } = params; + const { targetParams, paramName } = getTargetParamsForControl(control, nodeTracker[fxi], subControl); + if (!targetParams.length) return; + let currentValue = targetParams[0].value; + currentValue = currentValue === 0 ? 1 : currentValue; + const { min, max } = getRangeForParam(paramName, currentValue); + const depthValue = depthabs != null ? depthabs : depth * currentValue; + const modParams = { + ...filteredParams, + frequency: sync !== undefined ? sync * cps : rate, + time: cycle / cps, + depth: depthValue, + min, + max, + }; + const lfoNode = getLfo(getAudioContext(), modParams); + nodeTracker.main[`lfo_${id}`] = [lfoNode]; + targetParams.forEach((t) => lfoNode.connect(t)); + return lfoNode; +}; + +export const connectEnvelope = (id, params, nodeTracker) => { + const { control, subControl, acurve, dcurve, rcurve, depth = 1, depthabs, fxi = 'main', ...filteredParams } = params; + const { targetParams, paramName } = getTargetParamsForControl(control, nodeTracker[fxi], subControl); + if (!targetParams.length) return; + let currentValue = targetParams[0].value; + currentValue = currentValue === 0 ? 1 : currentValue; + const { min, max } = getRangeForParam(paramName, currentValue); + const depthValue = depthabs != null ? depthabs : depth * currentValue; + const envNode = getEnvelope(getAudioContext(), { + ...filteredParams, + depth: depthValue, + min, + max, + attackCurve: acurve, + decayCurve: dcurve, + releaseCurve: rcurve, + }); + nodeTracker.main[`env_${id}`] = [envNode]; + targetParams.forEach((t) => envNode.connect(t)); + return envNode; +}; + +export const connectBusModulator = (params, nodeTracker, controller) => { + const ac = getAudioContext(); + const { control, subControl, depth = 1, depthabs, fxi = 'main' } = params; + const { targetParams, paramName } = getTargetParamsForControl(control, nodeTracker[fxi], subControl); + if (!targetParams.length) return { toCleanup: [] }; + const signal = controller.getBus(params.bus); + const dc = new ConstantSourceNode(ac, { offset: params.dc ?? 0 }); + dc.start(params.begin); + const shifted = dc.connect(gainNode(1)); + signal.connect(shifted); + let currentValue = targetParams[0].value; + currentValue = currentValue === 0 ? 1 : currentValue; + const { min, max } = getRangeForParam(paramName, currentValue); + const depthValue = depthabs != null ? depthabs : depth * currentValue; + const depthGain = gainNode((Math.sign(depthValue) * Math.abs(depthValue)) / 0.3); + const unClamped = shifted.connect(depthGain); + const toCleanup = []; + let modulator = unClamped; + if (min !== undefined && max !== undefined) { + const wsData = clampWithWaveShaper(unClamped, min, max); + modulator = wsData.modulator; + toCleanup.push(...wsData.toCleanup); + } + webAudioTimeout( + ac, + () => { + targetParams.forEach((t) => modulator.connect(t)); + }, + 0, + params.begin, + ); + toCleanup.push(dc, shifted, depthGain); + return { modulator, toCleanup }; +}; 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/reverbGen.mjs b/packages/superdough/reverbGen.mjs index bb050874c..0949a30d5 100644 --- a/packages/superdough/reverbGen.mjs +++ b/packages/superdough/reverbGen.mjs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { releaseAudioNode } from './helpers.mjs'; + var reverbGen = {}; /** Generates a reverb impulse response. @@ -104,8 +106,8 @@ var applyGradualLowpass = function (input, lpFreqStart, lpFreqEnd, lpFreqEndAt, player.start(); context.oncomplete = function (event) { callback(event.renderedBuffer); - filter.disconnect(); - player.disconnect(); + releaseAudioNode(filter); + releaseAudioNode(player); }; context.startRendering(); diff --git a/packages/superdough/sampler.mjs b/packages/superdough/sampler.mjs index 2deeb4b74..819de3c78 100644 --- a/packages/superdough/sampler.mjs +++ b/packages/superdough/sampler.mjs @@ -301,7 +301,7 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) { } // vibrato - let vibratoOscillator = getVibratoOscillator(bufferSource.detune, value, t); + const vibratoHandle = getVibratoOscillator(bufferSource.detune, value, t); const time = t + nudge; bufferSource.start(time, offset); @@ -324,7 +324,7 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) { node.connect(out); onceEnded(bufferSource, function () { releaseAudioNode(bufferSource); - releaseAudioNode(vibratoOscillator); + vibratoHandle?.stop(); releaseAudioNode(node); releaseAudioNode(out); onended(); @@ -334,7 +334,7 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) { const stop = (endTime) => { bufferSource.stop(endTime); }; - const handle = { node: out, bufferSource, stop }; + const handle = { node: out, nodes: { source: [bufferSource], ...vibratoHandle?.nodes }, stop }; // cut groups if (cut !== undefined) { diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 6891c9408..c2515e324 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -7,34 +7,48 @@ This program is free software: you can redistribute it and/or modify it under th import './feedbackdelay.mjs'; import './reverb.mjs'; import './vowel.mjs'; -import { nanFallback, _mod, cycleToSeconds, pickAndRename } from './util.mjs'; +import { clamp, nanFallback, _mod, cycleToSeconds, pickAndRename } from './util.mjs'; import workletsUrl from './worklets.mjs?audioworklet'; import { createFilter, + effectSend, gainNode, getCompressor, getDistortion, getLfo, getWorklet, - effectSend, releaseAudioNode, + webAudioTimeout, } from './helpers.mjs'; import { map } from 'nanostores'; import { logger } from './logger.mjs'; +import { connectLFO, connectEnvelope, connectBusModulator } from './modulators.mjs'; import { loadBuffer } from './sampler.mjs'; import { getAudioContext } from './audioContext.mjs'; import { SuperdoughAudioController } from './superdoughoutput.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; } @@ -52,6 +66,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; } @@ -161,6 +186,7 @@ let defaultDefaultValues = { distortvol: 1, distorttype: 0, delay: 0, + busgain: 1, byteBeatExpression: '0', delayfeedback: 0.5, delaysync: 3 / 16, @@ -168,6 +194,9 @@ let defaultDefaultValues = { i: 1, velocity: 1, fft: 8, + tremolodepth: 1, + tremolophase: 0, + release: 0.01, }; const defaultDefaultDefaultValues = Object.freeze({ ...defaultDefaultValues }); @@ -212,11 +241,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; @@ -233,6 +264,7 @@ export async function initAudio(options = {}) { setMaxPolyphony(maxPolyphony); setMultiChannelOrbits(multiChannelOrbits); + resetSeenKeys(); if (typeof window === 'undefined') { return; } @@ -254,8 +286,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; @@ -289,14 +322,20 @@ 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); } -function getPhaser(time, end, frequency = 1, depth = 0.5, centerFrequency = 1000, sweep = 2000) { +function getPhaser(begin, end, frequency = 1, depth = 0.5, centerFrequency = 1000, sweep = 2000) { const ac = getAudioContext(); - const lfo = getLfo(ac, time, end, { frequency, depth: sweep * 2 }); + const lfo = getLfo(ac, { frequency, depth: sweep * 2, begin, end }); //filters const numStages = 1; //num of filters in series @@ -326,7 +365,7 @@ export let analysers = {}, analysersData = {}; export function getAnalyserById(id, fftSize = 1024, smoothingTimeConstant = 0.5) { - if (!analysers[id]) { + if (!analysers[id] || analysers[id].context != getAudioContext()) { // make sure this doesn't happen too often as it piles up garbage const analyserNode = getAudioContext().createAnalyser(); analyserNode.fftSize = fftSize; @@ -367,7 +406,37 @@ function mapChannelNumbers(channels) { return (Array.isArray(channels) ? channels : [channels]).map((ch) => ch - 1); } +class Chain { + constructor(head) { + this.audioNodes = [head]; + this.tails = [head]; + } + connect(...nodes) { + nodes.forEach((node) => { + this.tails.forEach((tail) => { + tail.connect(node); + }); + }); + this.tails = nodes; + this.audioNodes.push(...nodes); + return this; + } + connectOne(idx, node) { + this.tails[idx].connect(node); + this.tails[idx] = node; + this.audioNodes.push(node); + return this; + } + releaseNodes() { + this.audioNodes.forEach((n) => releaseAudioNode(n)); + this.audioNodes = []; + this.tails = []; + } +} + export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) => { + // mapping from main FX and numbered FX chains to nodes + const nodes = { main: {} }; // new: t is always expected to be the absolute target onset time const ac = getAudioContext(); const audioController = getSuperdoughAudioController(); @@ -388,7 +457,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)})`, @@ -397,49 +465,24 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) } // destructure let { - tremolo, - tremolosync, - tremolodepth = 1, - tremoloskew, - tremolophase = 0, - tremoloshape, s = getDefaultValue('s'), bank, source, - gain = getDefaultValue('gain'), postgain = getDefaultValue('postgain'), - density = getDefaultValue('density'), duckorbit, duckonset, duckattack, duckdepth, djf, - // filters - fanchor = getDefaultValue('fanchor'), - release = 0, - - //phaser - phaserrate, - phaserdepth = getDefaultValue('phaserdepth'), - phasersweep, - phasercenter, - // - coarse, - - crush, + release = getDefaultValue('release'), dry, - shape, - shapevol = getDefaultValue('shapevol'), - distort, - distortvol = getDefaultValue('distortvol'), - distorttype = getDefaultValue('distorttype'), - pan, - vowel, delay = getDefaultValue('delay'), delayfeedback = getDefaultValue('delayfeedback'), delaysync = getDefaultValue('delaysync'), delaytime, orbit = getDefaultValue('orbit'), + bus, + busgain = getDefaultValue('busgain'), room, roomfade, roomlp, @@ -449,16 +492,10 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) irspeed, irbegin, i = getDefaultValue('i'), - velocity = getDefaultValue('velocity'), analyze, // analyser wet fft = getDefaultValue('fft'), // fftSize 0 - 10 - compressor: compressorThreshold, - compressorRatio, - compressorKnee, - compressorAttack, - compressorRelease, - transient, - transsustain, + FX = [], + FXrelease, } = value; delaytime = delaytime ?? cycleToSeconds(delaysync, cps); @@ -473,17 +510,13 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) audioController.duck(duckorbit, t, duckonset, duckattack, duckdepth); } - gain = applyGainCurve(nanFallback(gain, 1)); postgain = applyGainCurve(postgain); - shapevol = applyGainCurve(shapevol); - distortvol = applyGainCurve(distortvol); delay = applyGainCurve(delay); - velocity = applyGainCurve(velocity); - tremolodepth = applyGainCurve(tremolodepth); - gain *= velocity; // velocity currently only multiplies with gain. it might do other things in the future + busgain = applyGainCurve(busgain); const end = t + hapDuration; - const endWithRelease = end + release; + const fullRelease = Math.max(release, FXrelease ?? 0); + const endWithRelease = end + fullRelease; const chainID = Math.round(Math.random() * 1000000); // oldest audio nodes will be destroyed if maximum polyphony is exceeded @@ -497,8 +530,6 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) activeSoundSources.delete(chainID); } - let audioNodes = []; - if (['-', '~', '_'].includes(s)) { return; } @@ -511,17 +542,27 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) let sourceNode; if (source) { sourceNode = source(t, value, hapDuration, cps); + nodes.main['source'] = [sourceNode]; } else if (getSound(s)) { const { onTrigger } = getSound(s); - const onEnded = () => { - audioNodes.forEach((n) => releaseAudioNode(n)); - activeSoundSources.delete(chainID); - }; + // We have to use onEnded because some sources (e.g. `sampler`) have + // an internal duration which is longer than `value.duration` + const onEnded = () => + webAudioTimeout( + ac, + () => { + chain.releaseNodes(); + activeSoundSources.delete(chainID); + }, + 0, + endWithRelease, + ); const soundHandle = await onTrigger(t, value, onEnded, cps); if (soundHandle) { sourceNode = soundHandle.node; activeSoundSources.set(chainID, new WeakRef(soundHandle)); // allow GC + nodes.main = { ...nodes.main, ...soundHandle.nodes }; } } else { throw new Error(`sound ${s} not found! Is it loaded?`); @@ -536,206 +577,341 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) logger('[webaudio] skip hap: still loading', ac.currentTime - t); return; } - const chain = []; // audio nodes that will be connected to each other sequentially - chain.push(sourceNode); - stretch !== undefined && chain.push(getWorklet(ac, 'phase-vocoder-processor', { pitchFactor: stretch })); - transient !== undefined && - chain.push( - getWorklet( + const chain = new Chain(sourceNode); // connection manager which tracks audio nodes for releasing + FX = [...FX, value]; // run through the FX chain and then run through all FX outside of it as well + for (let [idx, fx] of Object.entries(FX)) { + const key = idx == FX.length - 1 ? 'main' : idx; + nodes[key] ??= {}; + const fxNodes = nodes[key]; + let { + gain = getDefaultValue('gain'), + velocity = getDefaultValue('velocity'), + shapevol = getDefaultValue('shapevol'), + distorttype = getDefaultValue('distorttype'), + distortvol = getDefaultValue('distortvol'), + tremolodepth = getDefaultValue('tremolodepth'), + phaserdepth = getDefaultValue('phaserdepth'), + delay = getDefaultValue('delay'), + delayfeedback = getDefaultValue('delayfeedback'), + delaysync = getDefaultValue('delaysync'), + delaytime, + stretch = getDefaultValue('stretch'), + i = getDefaultValue('i'), + } = fx; + gain = applyGainCurve(nanFallback(gain, 1)); + shapevol = applyGainCurve(shapevol); + distortvol = applyGainCurve(distortvol); + velocity = applyGainCurve(velocity); + tremolodepth = applyGainCurve(tremolodepth); + gain *= velocity; // velocity currently only multiplies with gain. it might do other things in the future + delaytime = delaytime ?? cycleToSeconds(delaysync, cps); + + stretch !== undefined && chain.connect(getWorklet(ac, 'phase-vocoder-processor', { pitchFactor: stretch })); + + if (fx.transient !== undefined) { + const transProcessor = getWorklet( ac, 'transient-processor', {}, { processorOptions: { - attack: transient, - sustain: transsustain, + attack: fx.transient, + sustain: fx.transsustain, begin: t, end: endWithRelease, }, }, - ), - ); + ); + chain.connect(transProcessor); + fxNodes['transient'] = transProcessor; + } - // gain stage - chain.push(gainNode(gain)); + // gain stage + const initialGain = gainNode(gain); + fxNodes['gain'] = [initialGain]; + chain.connect(initialGain); - // filter - const ftype = getFilterType(value.ftype); + // filter + const ftype = getFilterType(value.ftype); - if (value.cutoff !== undefined) { - const lpMap = { - frequency: 'cutoff', - q: 'resonance', - attack: 'lpattack', - decay: 'lpdecay', - sustain: 'lpsustain', - release: 'lprelease', - env: 'lpenv', - anchor: 'fanchor', - model: 'ftype', - drive: 'drive', - rate: 'lprate', - sync: 'lpsync', - depth: 'lpdepth', - depthfrequency: 'lpdepthfrequency', - shape: 'lpshape', - dcoffset: 'lpdc', - skew: 'lpskew', - }; - const lpParams = pickAndRename(value, lpMap); - lpParams.type = 'lowpass'; - const lp = () => createFilter(ac, t, end, lpParams, cps, cycle); - const { filter: lpf1, lfo: lfo1 } = lp(); - chain.push(lpf1); - lfo1 && audioNodes.push(lfo1); - if (ftype === '24db') { - const { filter: lpf2, lfo: lfo2 } = lp(); - chain.push(lpf2); - lfo2 && audioNodes.push(lfo2); + const filt = (params) => createFilter(ac, t, end, params, cps, cycle); + if (fx.cutoff !== undefined) { + const lpMap = { + frequency: 'cutoff', + q: 'resonance', + attack: 'lpattack', + decay: 'lpdecay', + sustain: 'lpsustain', + release: 'lprelease', + env: 'lpenv', + anchor: 'fanchor', + model: 'ftype', + drive: 'drive', + rate: 'lprate', + sync: 'lpsync', + depth: 'lpdepth', + depthfrequency: 'lpdepthfrequency', + shape: 'lpshape', + dcoffset: 'lpdc', + skew: 'lpskew', + }; + const lpParams = pickAndRename(fx, lpMap); + lpParams.type = 'lowpass'; + const { filter: lpf1, lfo: lfo1 } = filt(lpParams); + fxNodes['lpf'] = [lpf1]; + fxNodes['lpf_lfo'] = [lfo1]; + chain.connect(lpf1); + lfo1 && chain.audioNodes.push(lfo1); + if (ftype === '24db') { + const { filter: lpf2, lfo: lfo2 } = filt(lpParams); + fxNodes['lpf'].push(lpf2); + fxNodes['lpf_lfo'].push(lfo2); + chain.connect(lpf2); + lfo2 && chain.audioNodes.push(lfo2); + } + } + + if (fx.hcutoff !== undefined) { + const hpMap = { + frequency: 'hcutoff', + q: 'hresonance', + attack: 'hpattack', + decay: 'hpdecay', + sustain: 'hpsustain', + release: 'hprelease', + env: 'hpenv', + anchor: 'fanchor', + model: 'ftype', + drive: 'drive', + rate: 'hprate', + sync: 'hpsync', + depth: 'hpdepth', + depthfrequency: 'hpdepthfrequency', + shape: 'hpshape', + dcoffset: 'hpdc', + skew: 'hpskew', + }; + const hpParams = pickAndRename(fx, hpMap); + hpParams.type = 'highpass'; + const { filter: hpf1, lfo: lfo1 } = filt(hpParams); + fxNodes['hpf'] = [hpf1]; + fxNodes['hpf_lfo'] = [lfo1]; + lfo1 && chain.audioNodes.push(lfo1); + chain.connect(hpf1); + if (ftype === '24db') { + const { filter: hpf2, lfo: lfo2 } = filt(hpParams); + fxNodes['hpf'].push(hpf2); + fxNodes['hpf_lfo'].push(lfo2); + chain.connect(hpf2); + lfo2 && chain.audioNodes.push(lfo2); + } + } + + if (fx.bandf !== undefined) { + const bpMap = { + frequency: 'bandf', + q: 'bandq', + attack: 'bpattack', + decay: 'bpdecay', + sustain: 'bpsustain', + release: 'bprelease', + env: 'bpenv', + anchor: 'fanchor', + model: 'ftype', + drive: 'drive', + rate: 'bprate', + sync: 'bpsync', + depth: 'bpdepth', + depthfrequency: 'bpdepthfrequency', + shape: 'bpshape', + dcoffset: 'bpdc', + skew: 'bpskew', + }; + const bpParams = pickAndRename(fx, bpMap); + bpParams.type = 'bandpass'; + const { filter: bpf1, lfo: lfo1 } = filt(bpParams); + fxNodes['bpf'] = [bpf1]; + fxNodes['bpf_lfo'] = [lfo1]; + chain.connect(bpf1); + lfo1 && chain.audioNodes.push(lfo1); + if (ftype === '24db') { + const { filter: bpf2, lfo: lfo2 } = filt(bpParams); + fxNodes['bpf'].push(bpf2); + fxNodes['bpf_lfo'].push(lfo2); + chain.connect(bpf2); + lfo2 && chain.audioNodes.push(lfo2); + } + } + + if (fx.vowel !== undefined) { + const vowelNode = ac.createVowelFilter(fx.vowel); + fxNodes['vowel'] = vowelNode.filters; + chain.connect(vowelNode); + } + + // effects + if (fx.coarse !== undefined) { + const coarseNode = getWorklet(ac, 'coarse-processor', { coarse: fx.coarse }); + fxNodes['coarse'] = [coarseNode]; + chain.connect(coarseNode); + } + if (fx.crush !== undefined) { + const crushNode = getWorklet(ac, 'crush-processor', { crush: fx.crush }); + fxNodes['crush'] = [crushNode]; + chain.connect(crushNode); + } + if (fx.shape !== undefined) { + const shapeNode = getWorklet(ac, 'shape-processor', { shape: fx.shape, postgain: shapevol }); + fxNodes['shape'] = [shapeNode]; + chain.connect(shapeNode); + } + if (fx.distort !== undefined) { + const distortNode = getDistortion(fx.distort, distortvol, distorttype); + fxNodes['distort'] = [distortNode]; + chain.connect(distortNode); + } + + let tremolo = fx.tremolo; + if (fx.tremolosync != null) { + tremolo = cps * fx.tremolosync; + } + + if (tremolo !== undefined) { + // Allow clipping of modulator for more dynamic possiblities, and to prevent speaker overload + // EX: a triangle waveform will clip like this /-\ when the depth is above 1 + const gain = Math.max(1 - tremolodepth, 0); + const amGain = new GainNode(ac, { gain }); + + const time = cycle / cps; + const lfo = getLfo(ac, { + skew: fx.tremoloskew ?? (fx.tremoloshape != null ? 0.5 : 1), + frequency: tremolo, + depth: tremolodepth, + time, + dcoffset: 0, + shape: fx.tremoloshape, + phaseoffset: fx.tremolophase, + min: 0, + max: 1, + curve: 1.5, + begin: t, + end: endWithRelease, + }); + fxNodes['tremolo'] = [lfo]; + fxNodes['tremolo_gain'] = [amGain]; + lfo.connect(amGain.gain); + chain.audioNodes.push(lfo); + chain.connect(amGain); + } + + if (fx.compressor !== undefined) { + const compressorNode = getCompressor( + ac, + fx.compressor, + fx.compressorRatio, + fx.compressorKnee, + fx.compressorAttack, + fx.compressorRelease, + ); + fxNodes['compressor'] = [compressorNode]; + chain.connect(compressorNode); + } + + // panning + if (fx.pan !== undefined) { + const panner = ac.createStereoPanner(); + fxNodes['pan'] = [panner]; + panner.pan.value = 2 * fx.pan - 1; + chain.connect(panner); + } + // phaser + if (fx.phaserrate !== undefined && phaserdepth > 0) { + const { filterChain, lfo } = getPhaser( + t, + endWithRelease, + fx.phaserrate, + phaserdepth, + fx.phasercenter, + fx.phasersweep, + ); + fxNodes['phaser'] = [...filterChain]; + fxNodes['phaser_lfo'] = [lfo]; + filterChain.forEach((f) => chain.connect(f)); + chain.audioNodes.push(lfo); + } + // delay + if (key !== 'main' && delay > 0 && delaytime > 0 && delayfeedback > 0) { + const dry = gainNode(1); + delayfeedback = clamp(delayfeedback, 0, 0.98); + const delayNode = ac.createFeedbackDelay(1, delaytime, delayfeedback); + const wetDelay = gainNode(delay); + const dryDelay = gainNode(fx.dry ?? 1); + const sum = new GainNode(ac, { gain: 1, channelCount: 2, channelCountMode: 'explicit' }); + chain + .connect(dry) + .connect(dryDelay, delayNode) + .connectOne(1, wetDelay) // connect delayNode -> wetDelay + .connect(sum); + chain.audioNodes.push(delayNode.feedbackGain, delayNode.delayGain); + fxNodes['delay'] = [delayNode]; + fxNodes['delay_mix'] = [wetDelay]; + } + // reverb + if (key !== 'main' && fx.room > 0) { + let roomIR; + if (fx.ir !== undefined) { + let url; + let sample = getSound(fx.ir); + if (Array.isArray(sample)) { + url = sample.data.samples[fx.i % sample.data.samples.length]; + } else if (typeof sample === 'object') { + url = Object.values(sample.data.samples).flat()[i % Object.values(sample.data.samples).length]; + } + roomIR = await loadBuffer(url, ac, fx.ir, 0); + } + const dry = gainNode(1); + const reverbNode = ac.createReverb( + fx.roomsize, + fx.roomfade, + fx.roomlp, + fx.roomdim, + roomIR, + fx.irspeed, + fx.irbegin, + ); + const wetReverb = gainNode(fx.room); + const dryReverb = gainNode(fx.dry ?? 1); + const sum = new GainNode(ac, { gain: 1, channelCount: 2, channelCountMode: 'explicit' }); + chain + .connect(dry) + .connect(dryReverb, reverbNode) + .connectOne(1, wetReverb) // connect reverbNode -> wetReverb + .connect(sum); + fxNodes['room'] = [reverbNode]; + fxNodes['room_mix'] = [wetReverb]; } } - if (value.hcutoff !== undefined) { - const hpMap = { - frequency: 'hcutoff', - q: 'hresonance', - attack: 'hpattack', - decay: 'hpdecay', - sustain: 'hpsustain', - release: 'hprelease', - env: 'hpenv', - anchor: 'fanchor', - model: 'ftype', - drive: 'drive', - rate: 'hprate', - sync: 'hpsync', - depth: 'hpdepth', - depthfrequency: 'hpdepthfrequency', - shape: 'hpshape', - dcoffset: 'hpdc', - skew: 'hpskew', - }; - const hpParams = pickAndRename(value, hpMap); - hpParams.type = 'highpass'; - const hp = () => createFilter(ac, t, end, hpParams, cps, cycle); - const { filter: hpf1, lfo: lfo1 } = hp(); - chain.push(hpf1); - lfo1 && audioNodes.push(lfo1); - if (ftype === '24db') { - const { filter: hpf2, lfo: lfo2 } = hp(); - chain.push(hpf2); - lfo2 && audioNodes.push(lfo2); - } - } - - if (value.bandf !== undefined) { - const bpMap = { - frequency: 'bandf', - q: 'bandq', - attack: 'bpattack', - decay: 'bpdecay', - sustain: 'bpsustain', - release: 'bprelease', - env: 'bpenv', - anchor: 'fanchor', - model: 'ftype', - drive: 'drive', - rate: 'bprate', - sync: 'bpsync', - depth: 'bpdepth', - depthfrequency: 'bpdepthfrequency', - shape: 'bpshape', - dcoffset: 'bpdc', - skew: 'bpskew', - }; - const bpParams = pickAndRename(value, bpMap); - bpParams.type = 'bandpass'; - const bp = () => createFilter(ac, t, end, bpParams, cps, cycle); - const { filter: bpf1, lfo: lfo1 } = bp(); - chain.push(bpf1); - lfo1 && audioNodes.push(lfo1); - if (ftype === '24db') { - const { filter: bpf2, lfo: lfo2 } = bp(); - chain.push(bpf2); - lfo2 && audioNodes.push(lfo2); - } - } - - if (vowel !== undefined) { - const vowelFilter = ac.createVowelFilter(vowel); - chain.push(vowelFilter); - } - - // effects - coarse !== undefined && chain.push(getWorklet(ac, 'coarse-processor', { coarse })); - crush !== undefined && chain.push(getWorklet(ac, 'crush-processor', { crush })); - shape !== undefined && chain.push(getWorklet(ac, 'shape-processor', { shape, postgain: shapevol })); - distort !== undefined && chain.push(getDistortion(distort, distortvol, distorttype)); - - if (tremolosync != null) { - tremolo = cps * tremolosync; - } - - if (value.wtPosSynced != null) { - value.wtPosRate /= cps; - } - - if (value.wtWarpSynced != null) { - value.wtWarpRate /= cps; - } - - if (tremolo !== undefined) { - // Allow clipping of modulator for more dynamic possiblities, and to prevent speaker overload - // EX: a triangle waveform will clip like this /-\ when the depth is above 1 - const gain = Math.max(1 - tremolodepth, 0); - const amGain = new GainNode(ac, { gain }); - - const time = cycle / cps; - const lfo = getLfo(ac, t, endWithRelease, { - skew: tremoloskew ?? (tremoloshape != null ? 0.5 : 1), - frequency: tremolo, - depth: tremolodepth, - time, - dcoffset: 0, - shape: tremoloshape, - phaseoffset: tremolophase, - min: 0, - max: 1, - curve: 1.5, - }); - lfo.connect(amGain.gain); - audioNodes.push(lfo); - chain.push(amGain); - } - - compressorThreshold !== undefined && - chain.push( - getCompressor(ac, compressorThreshold, compressorRatio, compressorKnee, compressorAttack, compressorRelease), - ); - - // panning - if (pan !== undefined) { - const panner = ac.createStereoPanner(); - panner.pan.value = 2 * pan - 1; - chain.push(panner); - } - // phaser - if (phaserrate !== undefined && phaserdepth > 0) { - const { filterChain, lfo } = getPhaser(t, endWithRelease, phaserrate, phaserdepth, phasercenter, phasersweep); - audioNodes.push(lfo); - chain.push(...filterChain); + if (FXrelease !== undefined && FXrelease > release) { + const releaseNode = gainNode(1); + releaseNode.gain.setValueAtTime(1, end + release); + releaseNode.gain.linearRampToValueAtTime(0, endWithRelease); + chain.connect(releaseNode); } // last gain const post = new GainNode(ac, { gain: postgain }); - chain.push(post); + nodes.main['post'] = [post]; + chain.connect(post); // delay if (delay > 0 && delaytime > 0 && delayfeedback > 0) { - orbitBus.getDelay(delaytime, delayfeedback, t); - const send = orbitBus.sendDelay(post, delay); - audioNodes.push(send); + const delayNode = orbitBus.getDelay(delaytime, delayfeedback, t); + nodes.main['delay'] = [delayNode]; + const delaySend = orbitBus.sendDelay(post, delay); + nodes.main['delay_mix'] = [delaySend]; + chain.audioNodes.push(delaySend); } // reverb if (room > 0) { @@ -750,33 +926,84 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) } roomIR = await loadBuffer(url, ac, ir, 0); } - orbitBus.getReverb(roomsize, roomfade, roomlp, roomdim, roomIR, irspeed, irbegin); - const send = orbitBus.sendReverb(post, room); - audioNodes.push(send); + const roomNode = orbitBus.getReverb(roomsize, roomfade, roomlp, roomdim, roomIR, irspeed, irbegin); + nodes.main['room'] = [roomNode]; + const reverbSend = orbitBus.sendReverb(post, room); + nodes.main['room_mix'] = [reverbSend]; + chain.audioNodes.push(reverbSend); + } + if (bus != null) { + const busNode = audioController.getBus(bus); + const busSend = effectSend(post, busNode, busgain); + chain.audioNodes.push(busSend); } if (djf != null) { - orbitBus.getDjf(djf, t); + const djfNode = orbitBus.getDjf(djf, t); + nodes.main['djf'] = [djfNode]; } // analyser - if (analyze) { + if (analyze && !(ac instanceof OfflineAudioContext)) { const analyserNode = getAnalyserById(analyze, 2 ** (fft + 5)); const analyserSend = effectSend(post, analyserNode, 1); - audioNodes.push(analyserSend); + chain.audioNodes.push(analyserSend); } if (dry != null) { dry = applyGainCurve(dry); const dryGain = new GainNode(ac, { gain: dry }); - chain.push(dryGain); + chain.connect(dryGain); orbitBus.connectToOutput(dryGain); } else { orbitBus.connectToOutput(post); } - // connect chain elements together - chain.slice(1).reduce((last, current) => last.connect(current), chain[0]); - audioNodes = audioNodes.concat(chain); + // finally, now that `nodes` is populated, set up modulators + FX.forEach((fx, idx) => { + const key = idx === FX.length - 1 ? 'main' : idx; + if (fx.lfo) { + for (const id of fx.lfo.__ids) { + const params = fx.lfo[id]; + params.fxi ??= key; + const lfo = connectLFO( + id, + { + ...params, + cps, + cycle, + begin: t, + end: endWithRelease, + }, + nodes, + ); + lfo && chain.audioNodes.push(lfo); + } + } + if (fx.env) { + for (const id of fx.env.__ids) { + const params = fx.env[id]; + params.fxi ??= key; + const env = connectEnvelope( + id, + { + ...params, + begin: t, + end: endWithRelease, + }, + nodes, + ); + env && chain.audioNodes.push(env); + } + } + if (fx.bmod) { + for (const id of fx.bmod.__ids) { + const params = fx.bmod[id]; + params.fxi ??= key; + const { toCleanup } = connectBusModulator({ ...params, begin: t, end: endWithRelease }, nodes, controller); + chain.audioNodes.push(...toCleanup); + } + } + }); }; export const superdoughTrigger = (t, hap, ct, cps) => { diff --git a/packages/superdough/superdoughdata.mjs b/packages/superdough/superdoughdata.mjs new file mode 100644 index 000000000..e43df9eef --- /dev/null +++ b/packages/superdough/superdoughdata.mjs @@ -0,0 +1,150 @@ +/* +superdoughdata.mjs - Data needed for running superdough (defaults, mappings, etc.) +Copyright (C) 2025 Strudel contributors - see +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 . +*/ + +// Mapping from control name to webaudio node and parameter +const CONTROL_TARGETS = { + stretch: { node: 'stretch', param: 'pitchFactor' }, + gain: { node: 'gain', param: 'gain' }, + postgain: { node: 'post', param: 'gain' }, + pan: { node: 'pan', param: 'pan' }, + tremolo: { node: 'tremolo', param: 'frequency' }, + tremolosync: { node: 'tremolo', param: 'frequency' }, + tremolodepth: { node: 'tremolo_gain', param: 'gain' }, + tremoloskew: { node: 'tremolo', param: 'skew' }, + tremolophase: { node: 'tremolo', param: 'phase' }, + tremoloshape: { node: 'tremolo', param: 'shape' }, + + // MODULATORS + lfo: { node: 'lfo', param: 'frequency' }, + lfo_rate: { node: 'lfo', param: 'frequency' }, + lfo_sync: { node: 'lfo', param: 'frequency' }, + lfo_depth: { node: 'lfo', param: 'depth' }, + lfo_depthabs: { node: 'lfo', param: 'depth' }, + lfo_skew: { node: 'lfo', param: 'skew' }, + lfo_curve: { node: 'lfo', param: 'curve' }, + lfo_dcoffset: { node: 'lfo', param: 'dcoffset' }, + env: { node: 'env', param: 'depth' }, + env_attack: { node: 'env', param: 'attack' }, + env_decay: { node: 'env', param: 'decay' }, + env_sustain: { node: 'env', param: 'sustain' }, + env_release: { node: 'env', param: 'release' }, + bmod: { node: 'bmod', param: 'depth' }, + bmod_depth: { node: 'bmod', param: 'depth' }, + bmod_depthabs: { node: 'bmod', param: 'depth' }, + + // LPF + cutoff: { node: 'lpf', param: 'frequency' }, + resonance: { node: 'lpf', param: 'Q' }, + lprate: { node: 'lpf_lfo', param: 'rate' }, + lpsync: { node: 'lpf_lfo', param: 'sync' }, + lpdepth: { node: 'lpf_lfo', param: 'depth' }, + lpdepthfrequency: { node: 'lpf_lfo', param: 'depth' }, + lpshape: { node: 'lpf_lfo', param: 'shape' }, + lpdc: { node: 'lpf_lfo', param: 'dcoffset' }, + lpskew: { node: 'lpf_lfo', param: 'skew' }, + + // HPF + hcutoff: { node: 'hpf', param: 'frequency' }, + hresonance: { node: 'hpf', param: 'Q' }, + hprate: { node: 'hpf_lfo', param: 'rate' }, + hpsync: { node: 'hpf_lfo', param: 'sync' }, + hpdepth: { node: 'hpf_lfo', param: 'depth' }, + hpdepthfrequency: { node: 'hpf_lfo', param: 'depth' }, + hpshape: { node: 'hpf_lfo', param: 'shape' }, + hpdc: { node: 'hpf_lfo', param: 'dcoffset' }, + hpskew: { node: 'hpf_lfo', param: 'skew' }, + + // BPF + bandf: { node: 'bpf', param: 'frequency' }, + bandq: { node: 'bpf', param: 'Q' }, + bprate: { node: 'bpf_lfo', param: 'rate' }, + bpsync: { node: 'bpf_lfo', param: 'sync' }, + bpdepth: { node: 'bpf_lfo', param: 'depth' }, + bpdepthfrequency: { node: 'bpf_lfo', param: 'depth' }, + bpshape: { node: 'bpf_lfo', param: 'shape' }, + bpdc: { node: 'bpf_lfo', param: 'dcoffset' }, + bpskew: { node: 'bpf_lfo', param: 'skew' }, + + vowel: { node: 'vowel', param: 'frequency' }, + + // DISTORTION + coarse: { node: 'coarse', param: 'coarse' }, + crush: { node: 'crush', param: 'crush' }, + shape: { node: 'shape', param: 'shape' }, + shapevol: { node: 'shape', param: 'postgain' }, + distort: { node: 'distort', param: 'distort' }, + distortvol: { node: 'distort', param: 'postgain' }, + distorttype: { node: 'distort', param: 'distort' }, + + // COMPRESSOR + compressor: { node: 'compressor', param: 'threshold' }, + compressorRatio: { node: 'compressor', param: 'ratio' }, + compressorKnee: { node: 'compressor', param: 'knee' }, + compressorAttack: { node: 'compressor', param: 'attack' }, + compressorRelease: { node: 'compressor', param: 'release' }, + + // PHASER + phaserrate: { node: 'phaser_lfo', param: 'frequency' }, + phasersweep: { node: 'phaser_lfo', param: 'depth' }, + phasercenter: { node: 'phaser', param: 'frequency' }, + phaserdepth: { node: 'phaser', param: 'Q' }, + + // ORBIT EFFECTS + delay: { node: 'delay_mix', param: 'gain' }, + delaytime: { node: 'delay', param: 'delayTime' }, + delayfeedback: { node: 'delay', param: 'feedback' }, + delaysync: { node: 'delay', param: 'delayTime' }, + dry: { node: 'dry', param: 'gain' }, + room: { node: 'room_mix', param: 'gain' }, + djf: { node: 'djf', param: 'value' }, + busgain: { node: 'bus', param: 'gain' }, + + // SYNTHS + s: { node: 'source', param: 'frequency' }, + detune: { node: 'source', param: 'freqspread' }, + wt: { node: 'source', param: 'position' }, + warp: { node: 'source', param: 'warp' }, + freq: { node: 'source', param: 'frequency' }, + note: { node: 'source', param: 'frequency' }, + wtdc: { node: 'wt_lfo', param: 'dc' }, + wtskew: { node: 'wt_lfo', param: 'skew' }, + wtrate: { node: 'wt_lfo', param: 'frequency' }, + wtsync: { node: 'wt_lfo', param: 'frequency' }, + wtdepth: { node: 'wt_lfo', param: 'depth' }, + warpdc: { node: 'warp_lfo', param: 'dc' }, + warpskew: { node: 'warp_lfo', param: 'skew' }, + warprate: { node: 'warp_lfo', param: 'frequency' }, + warpsync: { node: 'warp_lfo', param: 'frequency' }, + warpdepth: { node: 'warp_lfo', param: 'depth' }, + fmi: { node: 'fm_1_gain', param: 'gain' }, + fmi2: { node: 'fm_2_gain', param: 'gain' }, + fmi3: { node: 'fm_3_gain', param: 'gain' }, + fmi4: { node: 'fm_4_gain', param: 'gain' }, + fmi5: { node: 'fm_5_gain', param: 'gain' }, + fmi6: { node: 'fm_6_gain', param: 'gain' }, + fmi7: { node: 'fm_7_gain', param: 'gain' }, + fmi8: { node: 'fm_8_gain', param: 'gain' }, + fmh: { node: 'fm_1', param: 'frequency' }, + fmh2: { node: 'fm_2', param: 'frequency' }, + fmh3: { node: 'fm_3', param: 'frequency' }, + fmh4: { node: 'fm_4', param: 'frequency' }, + fmh5: { node: 'fm_5', param: 'frequency' }, + fmh6: { node: 'fm_6', param: 'frequency' }, + fmh7: { node: 'fm_7', param: 'frequency' }, + fmh8: { node: 'fm_8', param: 'frequency' }, + pw: { node: 'source', param: 'pulsewidth' }, + pwrate: { node: 'pw_lfo', param: 'frequency' }, + pwsweep: { node: 'pw_lfo', param: 'depth' }, + vib: { node: 'vib', param: 'frequency' }, + vibmod: { node: 'vib_gain', param: 'gain' }, + byteBeatStartTime: { node: 'source', param: 'byteBeatStartTime' }, + spread: { node: 'source', param: 'panspread' }, + transient: { node: 'transient', param: 'attack' }, +}; + +export function getSuperdoughControlTargets() { + return CONTROL_TARGETS; +} diff --git a/packages/superdough/superdoughoutput.mjs b/packages/superdough/superdoughoutput.mjs index d8ead7d06..d3936646a 100644 --- a/packages/superdough/superdoughoutput.mjs +++ b/packages/superdough/superdoughoutput.mjs @@ -2,7 +2,10 @@ import { effectSend, getWorklet, webAudioTimeout } from './helpers.mjs'; import { errorLogger } from './logger.mjs'; import { clamp } from './util.mjs'; -let hasChanged = (now, before) => now !== undefined && now !== before; +const hasChanged = (now, before) => now !== undefined && now !== before; +// Node with fixed stereo channel count to prevent clicking when the input signal +// switches from mono to stereo +const getStereoNode = (ac) => new GainNode(ac, { gain: 1, channelCount: 2, channelCountMode: 'explicit' }); export class Orbit { reverbNode; @@ -11,10 +14,11 @@ export class Orbit { summingNode; djfNode; audioContext; + constructor(audioContext) { this.audioContext = audioContext; - this.output = new GainNode(audioContext, { gain: 1, channelCount: 2, channelCountMode: 'explicit' }); - this.summingNode = new GainNode(audioContext, { gain: 1, channelCount: 2, channelCountMode: 'explicit' }); + this.output = getStereoNode(audioContext); + this.summingNode = getStereoNode(audioContext); this.summingNode.connect(this.output); } @@ -34,6 +38,7 @@ export class Orbit { } const val = this.djfNode.parameters.get('value'); val.setValueAtTime(value, t); + return this.djfNode; } getDelay(delaytime = 0, feedback = 0.5, t) { @@ -164,6 +169,7 @@ export class SuperdoughAudioController { audioContext; output; nodes = {}; + buses = {}; constructor(audioContext) { this.audioContext = audioContext; @@ -171,10 +177,14 @@ export class SuperdoughAudioController { } reset() { - Array.from(this.nodes).forEach((node) => { + Object.values(this.nodes).forEach((node) => { node.disconnect(); }); + Object.values(this.buses).forEach((bus) => { + bus.disconnect(); + }); this.nodes = {}; + this.buses = {}; this.output.reset(); } @@ -206,4 +216,11 @@ export class SuperdoughAudioController { } return this.nodes[orbitNum]; } + + getBus(busNum) { + if (this.buses[busNum] == null) { + this.buses[busNum] = getStereoNode(this.audioContext); + } + return this.buses[busNum]; + } } diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs index d9ee0d936..6c8d1fea9 100644 --- a/packages/superdough/synth.mjs +++ b/packages/superdough/synth.mjs @@ -1,5 +1,5 @@ import { clamp } from './util.mjs'; -import { registerSound, soundMap } from './superdough.mjs'; +import { getSuperdoughAudioController, registerSound, soundMap } from './superdough.mjs'; import { getAudioContext } from './audioContext.mjs'; import { applyFM, @@ -19,7 +19,7 @@ import { import { logger } from './logger.mjs'; import { getNoiseMix, getNoiseOscillator } from './noise.mjs'; -const waveforms = ['triangle', 'square', 'sawtooth', 'sine', 'user']; +const waveforms = ['triangle', 'square', 'sawtooth', 'sine', 'user', 'one']; const waveformAliases = [ ['tri', 'triangle'], ['sqr', 'square'], @@ -52,17 +52,17 @@ export function registerSynthSounds() { // turn down const g = gainNode(0.3); - let sound = getOscillator(s, t, value, () => { + const sound = getOscillator(s, t, value, () => { releaseAudioNode(g); onended(); }); - let { node: o, stop, triggerRelease } = sound; + const { node: o, nodes, stop, triggerRelease } = sound; const { duration } = value; const envGain = gainNode(1); - let node = o.connect(g).connect(envGain); + const node = o.connect(g).connect(envGain); const holdEnd = t + duration; getParamADSR(node.gain, attack, decay, sustain, release, 0, 1, t, holdEnd, 'linear'); const envEnd = holdEnd + release + 0.01; @@ -70,6 +70,7 @@ export function registerSynthSounds() { stop(envEnd); return { node, + nodes, stop: (endTime) => { stop(endTime); }, @@ -139,6 +140,7 @@ export function registerSynthSounds() { return { node, + nodes: { source: [o] }, stop: (endTime) => { o.stop(endTime); }, @@ -183,8 +185,8 @@ export function registerSynthSounds() { const gainAdjustment = 1 / Math.sqrt(voices); getPitchEnvelope(o.parameters.get('detune'), value, begin, holdend); - const vibratoOscillator = getVibratoOscillator(o.parameters.get('detune'), value, begin); - const fm = applyFM(o.parameters.get('frequency'), value, begin); + const vibratoHandle = getVibratoOscillator(o.parameters.get('detune'), value, begin); + const fmHandle = applyFM(o.parameters.get('frequency'), value, begin); let envGain = gainNode(1); envGain = o.connect(envGain); @@ -195,8 +197,8 @@ export function registerSynthSounds() { () => { releaseAudioNode(o); onended(); - fm?.stop(); - vibratoOscillator?.stop(); + fmHandle?.stop(); + vibratoHandle?.stop(); }, begin, end, @@ -204,6 +206,7 @@ export function registerSynthSounds() { return { node: envGain, + nodes: { source: [o], ...fmHandle?.nodes, ...vibratoHandle?.nodes }, stop: (time) => { timeoutNode.stop(time); }, @@ -279,6 +282,7 @@ export function registerSynthSounds() { return { node: envGain, + source: o, stop: (time) => { timeoutNode.stop(time); }, @@ -329,25 +333,25 @@ export function registerSynthSounds() { ); getPitchEnvelope(o.parameters.get('detune'), value, begin, holdend); - const vibratoOscillator = getVibratoOscillator(o.parameters.get('detune'), value, begin); - const fm = applyFM(o.parameters.get('frequency'), value, begin); + const vibratoHandle = getVibratoOscillator(o.parameters.get('detune'), value, begin); + const fmHandle = applyFM(o.parameters.get('frequency'), value, begin); let envGain = gainNode(1); envGain = o.connect(envGain); getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 1, begin, holdend, 'linear'); - let lfo; + let pw_lfo; if (pwsweep != 0) { - lfo = getLfo(ac, begin, end, { frequency: pwrate, depth: pwsweep }); - lfo.connect(o.parameters.get('pulsewidth')); + pw_lfo = getLfo(ac, { frequency: pwrate, depth: pwsweep, begin, end }); + pw_lfo.connect(o.parameters.get('pulsewidth')); } let timeoutNode = webAudioTimeout( ac, () => { releaseAudioNode(o); - releaseAudioNode(lfo); + releaseAudioNode(pw_lfo); onended(); - fm?.stop(); - vibratoOscillator?.stop(); + fmHandle?.stop(); + vibratoHandle?.stop(); }, begin, end, @@ -355,6 +359,7 @@ export function registerSynthSounds() { return { node: envGain, + nodes: { source: [o], pw_lfo: [pw_lfo], ...fmHandle?.nodes, ...vibratoHandle?.nodes }, stop: (time) => { timeoutNode.stop(time); }, @@ -363,6 +368,41 @@ export function registerSynthSounds() { { prebake: true, type: 'synth' }, ); + registerSound( + 'bus', + (begin, value, onended) => { + const ac = getAudioContext(); + const [attack, decay, sustain, release] = getADSRValues( + [value.attack, value.decay, value.sustain, value.release], + 'linear', + [0.001, 0.05, 1, 0.01], + ); + const holdend = begin + value.duration; + const end = holdend + release + 0.01; + const bus = getSuperdoughAudioController().getBus(value.n ?? 0); + const envGain = bus.connect(gainNode(0)); + getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 1, begin, holdend, 'linear'); + const timeoutNode = webAudioTimeout( + ac, + () => { + bus.disconnect(envGain); + onended(); + }, + begin, + end, + ); + + return { + node: envGain, + nodes: { source: [bus] }, + stop: (time) => { + timeoutNode.stop(time); + }, + }; + }, + { prebake: true, type: 'input' }, + ); + [...noises].forEach((s) => { registerSound( s, @@ -400,6 +440,7 @@ export function registerSynthSounds() { stop(envEnd); return { node, + nodes: { source: [o] }, stop: (endTime) => { stop(endTime); }, @@ -467,8 +508,17 @@ export function getOscillator(s, t, value, onended) { s = 'triangle'; } s = s === 'user' && !partials ? 'triangle' : s; - // If no partials are given, use stock waveforms - if (!partials || partials?.length === 0 || s === 'sine') { + if (s === 'one') { + // Constant 1 oscillator (used for modulation) + o = new ConstantSourceNode(getAudioContext(), { offset: 1 }); + o.start(t); + return { + node: o, + nodes: { source: o }, + stop: (time) => o?.stop(time), + }; + } else if (!partials || partials?.length === 0 || s === 'sine') { + // If no partials are given, use stock waveforms o = getAudioContext().createOscillator(); o.type = s || 'triangle'; } @@ -479,11 +529,11 @@ export function getOscillator(s, t, value, onended) { // set frequency o.frequency.value = getFrequencyFromValue(value); - let vibratoOscillator = getVibratoOscillator(o.detune, value, t); + const vibratoHandle = getVibratoOscillator(o.detune, value, t); // pitch envelope getPitchEnvelope(o.detune, value, t, t + duration); - const fmModulator = applyFM(o.frequency, value, t); + const fmHandle = applyFM(o.frequency, value, t); let noiseMix; if (noise) { @@ -500,9 +550,10 @@ export function getOscillator(s, t, value, onended) { return { node: noiseMix?.node || o, + nodes: { source: [o], ...vibratoHandle?.nodes, ...fmHandle?.nodes }, stop: (time) => { - fmModulator.stop(time); - vibratoOscillator?.stop(time); + fmHandle.stop(time); + vibratoHandle?.stop(time); noiseMix?.stop(time); o.stop(time); }, diff --git a/packages/superdough/vowel.mjs b/packages/superdough/vowel.mjs index fda0c6479..8ecbf9d5c 100644 --- a/packages/superdough/vowel.mjs +++ b/packages/superdough/vowel.mjs @@ -1,3 +1,5 @@ +import { releaseAudioNode } from './helpers.mjs'; + // credits to webdirt: https://github.com/dktr0/WebDirt/blob/41342e81d6ad694a2310d491fef7b7e8b0929efe/js-src/Graph.js#L597 export var vowelFormant = { a: { freqs: [660, 1120, 2750, 3000, 3350], gains: [1, 0.5012, 0.0708, 0.0631, 0.0126], qs: [80, 90, 120, 130, 140] }, @@ -46,7 +48,8 @@ if (typeof GainNode !== 'undefined') { } const { gains, qs, freqs } = vowelFormant[letter]; this.makeupGain = ac.createGain(); - this.audioNodes = []; + this.filters = []; + this.gains = []; for (let i = 0; i < 5; i++) { const gain = ac.createGain(); gain.gain.value = gains[i]; @@ -56,9 +59,9 @@ if (typeof GainNode !== 'undefined') { filter.frequency.value = freqs[i]; super.connect(filter); filter.connect(gain); - this.audioNodes.push(filter); + this.filters.push(filter); gain.connect(this.makeupGain); - this.audioNodes.push(gain); + this.gains.push(gain); } this.makeupGain.gain.value = 8; // how much makeup gain to add? return this; @@ -67,15 +70,17 @@ if (typeof GainNode !== 'undefined') { this.makeupGain.connect(target); } disconnect() { - this.makeupGain.disconnect(); - this.audioNodes.forEach((n) => n.disconnect()); + releaseAudioNode(this.makeupGain); + this.filters.forEach(releaseAudioNode); + this.gains.forEach(releaseAudioNode); super.disconnect(); this.makeupGain = null; - this.audioNodes = null; + this.filters = null; + this.gains = null; } } - AudioContext.prototype.createVowelFilter = function (letter) { + BaseAudioContext.prototype.createVowelFilter = function (letter) { return new VowelNode(this, letter); }; } diff --git a/packages/superdough/wavetable.mjs b/packages/superdough/wavetable.mjs index 4311c5cab..b4fc401f1 100644 --- a/packages/superdough/wavetable.mjs +++ b/packages/superdough/wavetable.mjs @@ -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)) { @@ -309,19 +314,28 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) { dcoffset: value.warpdc ?? 0, }, ); - const vibratoOscillator = getVibratoOscillator(source.parameters.get('detune'), value, t); - const fm = applyFM(source.parameters.get('frequency'), value, t); + const vibratoHandle = getVibratoOscillator(source.parameters.get('detune'), value, t); + const fmHandle = applyFM(source.parameters.get('frequency'), value, t); const envGain = ac.createGain(); const node = source.connect(envGain); getParamADSR(node.gain, attack, decay, sustain, release, 0, 0.3, t, holdEnd, 'linear'); getPitchEnvelope(source.parameters.get('detune'), value, t, holdEnd); - const handle = { node, source }; + const handle = { + node, + nodes: { + source: [source], + wt_lfo: [wtPosModulators], + warp_lfo: [wtWarpModulators], + ...fmHandle?.nodes, + ...vibratoHandle?.nodes, + }, + }; const timeoutNode = webAudioTimeout( ac, () => { releaseAudioNode(source); - releaseAudioNode(vibratoOscillator); - fm?.stop(); + vibratoHandle?.stop(); + fmHandle?.stop(); releaseAudioNode(wtPosModulators); releaseAudioNode(wtWarpModulators); onended(); diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index a59aeb756..8001c7da8 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -124,8 +124,8 @@ class LFOProcessor extends AudioWorkletProcessor { { name: 'shape', defaultValue: 0 }, { name: 'curve', defaultValue: 1 }, { name: 'dcoffset', defaultValue: 0 }, - { name: 'min', defaultValue: 0 }, - { name: 'max', defaultValue: 1 }, + { name: 'min', defaultValue: -1e9 }, + { name: 'max', defaultValue: 1e9 }, ]; } @@ -143,7 +143,8 @@ class LFOProcessor extends AudioWorkletProcessor { process(_inputs, outputs, parameters) { const begin = parameters['begin'][0]; - if (currentTime >= parameters.end[0]) { + const end = parameters['end'][0]; + if (currentTime >= end) { return false; } if (currentTime <= begin) { @@ -161,6 +162,7 @@ class LFOProcessor extends AudioWorkletProcessor { const curve = parameters['curve'][0]; const dcoffset = parameters['dcoffset'][0]; + const min = parameters['min'][0]; const max = parameters['max'][0]; const shape = waveShapeNames[parameters['shape'][0]]; @@ -967,7 +969,9 @@ class EnvelopeProcessor extends AudioWorkletProcessor { { name: 'attackCurve', defaultValue: 0, minValue: -1, maxValue: 1 }, { name: 'decayCurve', defaultValue: 0, minValue: -1, maxValue: 1 }, { name: 'releaseCurve', defaultValue: 0, minValue: -1, maxValue: 1 }, - { name: 'peak', defaultValue: 1 }, + { name: 'depth', defaultValue: 1 }, + { name: 'min', defaultValue: -1e9 }, + { name: 'max', defaultValue: 1e9 }, { name: 'retrigger', defaultValue: 1, minValue: 0, maxValue: 1 }, ]; } @@ -1009,9 +1013,15 @@ class EnvelopeProcessor extends AudioWorkletProcessor { } process(_inputs, outputs, params) { + const begin = params['begin'][0]; + const end = params['end'][0]; + if (currentTime >= end) { + return false; + } + if (currentTime <= begin) { + return true; + } const out = outputs[0][0]; - if (!out) return true; - const begin = pv(params.begin, 0); const retrigger = pv(params.retrigger, 0) >= 0.5; // convert to bool if (begin !== this.beginTime && (this.state === 0 || retrigger)) { // triggered @@ -1029,7 +1039,9 @@ class EnvelopeProcessor extends AudioWorkletProcessor { const aCurve = pv(params.attackCurve, i); const dCurve = pv(params.decayCurve, i); const rCurve = pv(params.releaseCurve, i); - const peak = pv(params.peak, i); + const depth = pv(params.depth, i); + const min = pv(params.min, i); + const max = pv(params.max, i); const states = [ { time: Number.POSITIVE_INFINITY, start: 0, target: 0 }, // idle { time: attack, start: this.attackStart, target: 1, curve: aCurve }, @@ -1043,7 +1055,7 @@ class EnvelopeProcessor extends AudioWorkletProcessor { this.state = (this.state + 1) % states.length; time = states[this.state].time; } - out[i] = this.val * peak; + out[i] = clamp(this.val * depth, min, max); } return true; } diff --git a/packages/superdough/zzfx.mjs b/packages/superdough/zzfx.mjs index 4a4c58c9c..3d6b8e9f5 100644 --- a/packages/superdough/zzfx.mjs +++ b/packages/superdough/zzfx.mjs @@ -90,6 +90,7 @@ export function registerZZFXSounds() { }); return { node: o, + nodes: { source: [o] }, stop: () => {}, }; }, diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap index 17dcb08ba..f6547bc61 100644 --- a/test/__snapshots__/examples.test.mjs.snap +++ b/test/__snapshots__/examples.test.mjs.snap @@ -565,6 +565,52 @@ exports[`runs examples > example "_euclidRot" example index 20 1`] = ` ] `; +exports[`runs examples > example "FX" example index 0 1`] = ` +[ + "[ 0/1 → 1/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 1/8 → 1/4 | s:hh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 1/4 → 3/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 3/8 → 1/2 | s:bd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 1/2 → 5/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 5/8 → 3/4 | s:hh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 3/4 → 7/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 7/8 → 1/1 | s:lt decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 1/1 → 9/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 9/8 → 5/4 | s:hh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 5/4 → 11/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 11/8 → 3/2 | s:oh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 3/2 → 13/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 13/8 → 7/4 | s:hh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 7/4 → 15/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 15/8 → 2/1 | s:bd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 2/1 → 17/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 17/8 → 9/4 | s:hh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 9/4 → 19/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 19/8 → 5/2 | s:bd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 5/2 → 21/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 21/8 → 11/4 | s:hh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 11/4 → 23/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 23/8 → 3/1 | s:bd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 3/1 → 25/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 25/8 → 13/4 | s:hh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 13/4 → 27/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 27/8 → 7/2 | s:lt decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 7/2 → 29/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 29/8 → 15/4 | s:hh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 15/4 → 31/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 31/8 → 4/1 | s:bd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", +] +`; + +exports[`runs examples > example "FX" example index 1 1`] = ` +[ + "[ 0/1 → 1/1 | s:saw fmi:0.5 delay:0.3 FX:[{coarse:4} {cutoff:500 lpenv:4 lpattack:1 lpdecay:2} {distort:1}] ]", + "[ 1/1 → 2/1 | s:saw fmi:0.5 delay:0.3 FX:[{coarse:4} {cutoff:500 lpenv:4 lpattack:1 lpdecay:2} {distort:1}] ]", + "[ 2/1 → 3/1 | s:saw fmi:0.5 delay:0.3 FX:[{coarse:4} {cutoff:500 lpenv:4 lpattack:1 lpdecay:2} {distort:1}] ]", + "[ 3/1 → 4/1 | s:saw fmi:0.5 delay:0.3 FX:[{coarse:4} {cutoff:500 lpenv:4 lpattack:1 lpdecay:2} {distort:1}] ]", +] +`; + exports[`runs examples > example "accelerate" example index 0 1`] = ` [ "[ 0/1 → 2/1 | s:sax accelerate:0 ]", @@ -2539,6 +2585,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 +2908,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 ]", @@ -3778,6 +3849,51 @@ exports[`runs examples > example "end" example index 0 1`] = ` ] `; +exports[`runs examples > example "env" example index 0 1`] = ` +[ + "[ 0/1 → 1/1 | s:saw note:F1 cutoff:500 env:{0:{control:cutoff attack:1} __ids:{}} ]", + "[ 1/1 → 2/1 | s:saw note:F1 cutoff:500 env:{0:{control:cutoff attack:1} __ids:{}} ]", + "[ 2/1 → 3/1 | s:saw note:F1 cutoff:500 env:{0:{control:cutoff attack:1} __ids:{}} ]", + "[ 3/1 → 4/1 | s:saw note:F1 cutoff:500 env:{0:{control:cutoff attack:1} __ids:{}} ]", +] +`; + +exports[`runs examples > example "env" example index 1 1`] = ` +[ + "[ 0/1 → 1/1 | s:saw env:{0:{control:s decay:1} 1:{control:cutoff attack:0.1 decay:1 acurve:0.8 dcurve:0.3 depth:50} __ids:{}} note:F1 resonance:4 cutoff:50 ]", + "[ 1/1 → 2/1 | s:saw env:{0:{control:s decay:1} 1:{control:cutoff attack:0.1 decay:1 acurve:0.8 dcurve:0.3 depth:50} __ids:{}} note:F1 resonance:4 cutoff:50 ]", + "[ 2/1 → 3/1 | s:saw env:{0:{control:s decay:1} 1:{control:cutoff attack:0.1 decay:1 acurve:0.8 dcurve:0.3 depth:50} __ids:{}} note:F1 resonance:4 cutoff:50 ]", + "[ 3/1 → 4/1 | s:saw env:{0:{control:s decay:1} 1:{control:cutoff attack:0.1 decay:1 acurve:0.8 dcurve:0.3 depth:50} __ids:{}} note:F1 resonance:4 cutoff:50 ]", +] +`; + +exports[`runs examples > example "env" example index 2 1`] = ` +[ + "[ 0/1 → 1/1 | s:saw cutoff:500 distort:0.3 distortvol:1 distorttype:diode env:{0:{control:cutoff attack:0.5 decay:0.5} __ids:{}} ]", + "[ 1/1 → 2/1 | s:saw cutoff:500 distort:0.3 distortvol:1 distorttype:diode env:{0:{control:cutoff attack:0.5 decay:0.5} __ids:{}} ]", + "[ 2/1 → 3/1 | s:saw cutoff:500 distort:0.3 distortvol:1 distorttype:diode env:{0:{control:cutoff attack:0.5 decay:0.5} __ids:{}} ]", + "[ 3/1 → 4/1 | s:saw cutoff:500 distort:0.3 distortvol:1 distorttype:diode env:{0:{control:cutoff attack:0.5 decay:0.5} __ids:{}} ]", +] +`; + +exports[`runs examples > example "env" example index 3 1`] = ` +[ + "[ 0/1 → 1/1 | s:pulse cutoff:500 env:{0:{control:cutoff attack:1} 1:{control:s attack:0.5} __ids:{}} distort:0.3 distortvol:1 distorttype:diode ]", + "[ 1/1 → 2/1 | s:pulse cutoff:500 env:{0:{control:cutoff attack:1} 1:{control:s attack:1} __ids:{}} distort:0.3 distortvol:1 distorttype:diode ]", + "[ 2/1 → 3/1 | s:pulse cutoff:500 env:{0:{control:cutoff attack:1} 1:{control:s attack:1} __ids:{}} distort:0.3 distortvol:1 distorttype:diode ]", + "[ 3/1 → 4/1 | s:pulse cutoff:500 env:{0:{control:cutoff attack:1} 1:{control:s attack:0.5} __ids:{}} distort:0.3 distortvol:1 distorttype:diode ]", +] +`; + +exports[`runs examples > example "env" example index 4 1`] = ` +[ + "[ 0/1 → 1/1 | s:pulse cutoff:500 env:{1:{control:s attack:1} __ids:{} lpf_mod:{control:cutoff attack:0.5}} distort:0.3 distortvol:1 distorttype:diode ]", + "[ 1/1 → 2/1 | s:pulse cutoff:500 env:{1:{control:s attack:1} __ids:{} lpf_mod:{control:cutoff attack:1}} distort:0.3 distortvol:1 distorttype:diode ]", + "[ 2/1 → 3/1 | s:pulse cutoff:500 env:{1:{control:s attack:1} __ids:{} lpf_mod:{control:cutoff attack:1}} distort:0.3 distortvol:1 distorttype:diode ]", + "[ 3/1 → 4/1 | s:pulse cutoff:500 env:{1:{control:s attack:1} __ids:{} lpf_mod:{control:cutoff attack:0.5}} distort:0.3 distortvol:1 distorttype:diode ]", +] +`; + exports[`runs examples > example "euclid" example index 0 1`] = ` [ "[ 0/1 → 1/8 | note:c3 ]", @@ -6088,6 +6204,51 @@ exports[`runs examples > example "leslie" example index 0 1`] = ` ] `; +exports[`runs examples > example "lfo" example index 0 1`] = ` +[ + "[ 0/1 → 1/1 | s:saw note:F1 cutoff:500 lfo:{0:{control:cutoff} __ids:{}} ]", + "[ 1/1 → 2/1 | s:saw note:F1 cutoff:500 lfo:{0:{control:cutoff} __ids:{}} ]", + "[ 2/1 → 3/1 | s:saw note:F1 cutoff:500 lfo:{0:{control:cutoff} __ids:{}} ]", + "[ 3/1 → 4/1 | s:saw note:F1 cutoff:500 lfo:{0:{control:cutoff} __ids:{}} ]", +] +`; + +exports[`runs examples > example "lfo" example index 1 1`] = ` +[ + "[ 0/1 → 1/1 | s:saw lfo:{0:{control:s} 1:{control:cutoff sync:0.3} __ids:{}} cutoff:500 ]", + "[ 1/1 → 2/1 | s:saw lfo:{0:{control:s} 1:{control:cutoff sync:0.3} __ids:{}} cutoff:500 ]", + "[ 2/1 → 3/1 | s:saw lfo:{0:{control:s} 1:{control:cutoff sync:0.3} __ids:{}} cutoff:500 ]", + "[ 3/1 → 4/1 | s:saw lfo:{0:{control:s} 1:{control:cutoff sync:0.3} __ids:{}} cutoff:500 ]", +] +`; + +exports[`runs examples > example "lfo" example index 2 1`] = ` +[ + "[ 0/1 → 1/1 | s:saw cutoff:500 distort:0.3 distortvol:1 distorttype:diode lfo:{0:{control:cutoff} __ids:{}} ]", + "[ 1/1 → 2/1 | s:saw cutoff:500 distort:0.3 distortvol:1 distorttype:diode lfo:{0:{control:cutoff} __ids:{}} ]", + "[ 2/1 → 3/1 | s:saw cutoff:500 distort:0.3 distortvol:1 distorttype:diode lfo:{0:{control:cutoff} __ids:{}} ]", + "[ 3/1 → 4/1 | s:saw cutoff:500 distort:0.3 distortvol:1 distorttype:diode lfo:{0:{control:cutoff} __ids:{}} ]", +] +`; + +exports[`runs examples > example "lfo" example index 3 1`] = ` +[ + "[ 0/1 → 1/1 | s:pulse cutoff:500 lfo:{0:{control:cutoff} 1:{control:s sync:8} __ids:{}} distort:0.3 distortvol:1 distorttype:diode ]", + "[ 1/1 → 2/1 | s:pulse cutoff:500 lfo:{0:{control:cutoff} 1:{control:s} __ids:{}} distort:0.3 distortvol:1 distorttype:diode ]", + "[ 2/1 → 3/1 | s:pulse cutoff:500 lfo:{0:{control:cutoff} 1:{control:s} __ids:{}} distort:0.3 distortvol:1 distorttype:diode ]", + "[ 3/1 → 4/1 | s:pulse cutoff:500 lfo:{0:{control:cutoff} 1:{control:s sync:8} __ids:{}} distort:0.3 distortvol:1 distorttype:diode ]", +] +`; + +exports[`runs examples > example "lfo" example index 4 1`] = ` +[ + "[ 0/1 → 1/1 | s:pulse cutoff:500 lfo:{1:{control:s} __ids:{} lpf_mod:{control:cutoff depth:4 sync:8}} distort:0.3 distortvol:1 distorttype:diode ]", + "[ 1/1 → 2/1 | s:pulse cutoff:500 lfo:{1:{control:s} __ids:{} lpf_mod:{control:cutoff depth:4}} distort:0.3 distortvol:1 distorttype:diode ]", + "[ 2/1 → 3/1 | s:pulse cutoff:500 lfo:{1:{control:s} __ids:{} lpf_mod:{control:cutoff depth:4}} distort:0.3 distortvol:1 distorttype:diode ]", + "[ 3/1 → 4/1 | s:pulse cutoff:500 lfo:{1:{control:s} __ids:{} lpf_mod:{control:cutoff depth:4 sync:8}} distort:0.3 distortvol:1 distorttype:diode ]", +] +`; + exports[`runs examples > example "linger" example index 0 1`] = ` [ "[ 0/1 → 1/4 | s:lt ]", @@ -7966,6 +8127,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 +10549,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 +10744,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 +13147,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 ]", diff --git a/test/examples.test.mjs b/test/examples.test.mjs index 896a02f8a..ebec2252e 100644 --- a/test/examples.test.mjs +++ b/test/examples.test.mjs @@ -20,6 +20,7 @@ const skippedExamples = [ 'accelerationX', 'defaultmidimap', 'midimaps', + 'bmod', ]; describe('runs examples', () => { diff --git a/vitest.config.mjs b/vitest.config.mjs index dcdde1133..47f624771 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -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', }, }); diff --git a/vitest.setup.mjs b/vitest.setup.mjs new file mode 100644 index 000000000..ea965df6d --- /dev/null +++ b/vitest.setup.mjs @@ -0,0 +1,7 @@ +import { afterEach } from 'vitest'; +import { useRNG } from './packages/core/signal.mjs'; + +afterEach(() => { + // Avoid bleed between tests + useRNG('legacy'); +}); diff --git a/website/src/components/Dough/Dough.astro b/website/src/components/Dough/Dough.astro new file mode 100644 index 000000000..91b50e07b --- /dev/null +++ b/website/src/components/Dough/Dough.astro @@ -0,0 +1,17 @@ +--- +import '../../repl/Repl.css'; +--- + +
+ + + diff --git a/website/src/components/Dough/dough-mirror.mjs b/website/src/components/Dough/dough-mirror.mjs new file mode 100644 index 000000000..7784402e9 --- /dev/null +++ b/website/src/components/Dough/dough-mirror.mjs @@ -0,0 +1,153 @@ +import { + initEditor, + codemirrorSettings, + flash, + compartments, + extensions, + parseBooleans, + activateTheme, + updateMiniLocations, + highlightMiniLocations, +} from '@strudel/codemirror'; +import { evalScope, hash2code, code2hash } from '@strudel/core'; +import { Framer } from '@strudel/draw'; +import { persistentAtom } from '@nanostores/persistent'; +import { DoughRepl } from './dough-repl.mjs'; + +let initialCode = '$: note("c a f e")'; + +export const code = persistentAtom('vanilla-repl-code', initialCode, { + encode: JSON.stringify, + decode: JSON.parse, +}); + +if (typeof window !== 'undefined') { + try { + const codeParam = window.location.href.split('#')[1] || ''; + if (codeParam) { + const codeFromHash = hash2code(codeParam); + code.set(codeFromHash); + } + } catch (err) { + console.error('could not init code from url'); + } +} + +export class DoughMirror { + constructor(options) { + const { root, initialCode = code.get(), bgFill = true } = options; + this.root = root; + this.code = initialCode; + this.repl = new DoughRepl(); + this.prebaked = this.prebake(); + + // init codemirror + this.editor = initEditor({ + root, + initialCode: this.code, + onChange: (v) => { + if (v.docChanged) { + this.code = v.state.doc.toString(); + code.set(this.code); + } + }, + onEvaluate: this.evaluate.bind(this), + onStop: () => this.stop(), + mondo: false, + }); + const settings = codemirrorSettings.get(); + this.setFontSize(settings.fontSize); + this.setFontFamily(settings.fontFamily); + + // init event highlighting + this.framer = new Framer( + (time) => { + const frameHaps = this.repl.processHaps(); + highlightMiniLocations(this.editor, time, frameHaps); + }, + (err) => console.log('Framer error', err), + ); + } + prebake() { + const modulesLoading = evalScope(import('@strudel/core'), import('@strudel/tonal'), import('@strudel/mini')); + return Promise.all([modulesLoading, this.repl.prebake()]); + } + async evaluate() { + this.framer.start(); + this.flash(); + await this.prebaked; + const { miniLocations } = await this.repl.evaluate(this.code); + window.location.hash = '#' + code2hash(this.code); + updateMiniLocations(this.editor, miniLocations); + } + stop() { + this.repl.stop(); + this.framer.stop(); + highlightMiniLocations(this.editor, 0, []); + } + // added synonym (compared to StrudelMirror) + settings(settings = {}) { + this.updateSettings(settings); + } + + // the rest is copy pasted from StrudelMirror: + + updateSettings(settings = {}) { + settings.fontSize && this.setFontSize(settings.fontSize); + settings.fontFamily && this.setFontFamily(settings.fontFamily); + for (let key in extensions) { + if (key in settings) { + this.reconfigureExtension(key, settings[key]); + } + } + const updated = { ...codemirrorSettings.get(), ...settings }; + // console.log(updated); + codemirrorSettings.set(updated); + } + reconfigureExtension(key, value) { + if (!extensions[key]) { + console.warn(`extension ${key} is not known`); + return; + } + value = parseBooleans(value); + const newValue = extensions[key](value, this); + this.editor.dispatch({ + effects: compartments[key].reconfigure(newValue), + }); + if (key === 'theme') { + activateTheme(value); + } + } + flash(ms) { + flash(this.editor, ms); + } + setFontSize(size) { + this.root.style.fontSize = size + 'px'; + } + setFontFamily(family) { + this.root.style.fontFamily = family; + const scroller = this.root.querySelector('.cm-scroller'); + if (scroller) { + scroller.style.fontFamily = family; + } + } + setCode(code, offset = 0) { + const changes = { + from: 0, + to: this.editor.state.doc.length + offset, + insert: code, + }; + this.editor.dispatch({ changes }); + } + getCursorLocation() { + return this.editor.state.selection.main.head; + } + setCursorLocation(col) { + return this.editor.dispatch({ selection: { anchor: col } }); + } + appendCode(code) { + const cursor = this.getCursorLocation(); + this.setCode(this.code + code); + this.setCursorLocation(cursor); + } +} diff --git a/website/src/components/Dough/dough-repl.mjs b/website/src/components/Dough/dough-repl.mjs new file mode 100644 index 000000000..c8b47a3e8 --- /dev/null +++ b/website/src/components/Dough/dough-repl.mjs @@ -0,0 +1,109 @@ +// import { Dough, doughsamples } from 'dough-synth'; +import { Dough, doughsamples } from 'https://unpkg.com/dough-synth@0.1.9/dough.js'; +import { Pattern, noteToMidi, evaluate, stack } from '@strudel/core'; +// import doughUrl from 'dough-synth?url'; +import { transpiler } from '@strudel/transpiler'; +//const doughBaseUrl = doughUrl.split('/').slice(0, -1).join('/') + '/'; +const doughBaseUrl = 'https://unpkg.com/dough-synth@0.1.9/'; + +Object.assign(globalThis, { doughsamples }); + +export class DoughRepl { + pattern; + latency = 0.1; + cps = 0.5; + origin; + t0; + lasttime; + strudel; + q = []; + constructor() { + this.ready = this.init(); + } + async init() { + // init dough immediately, so that it can attach the document click event to initAudio immediately + this.dough = new Dough({ + base: doughBaseUrl, + //base: "../", // local dev + onTick: ({ t0, t1 }) => { + if (!this.pattern) { + return; + } + this.origin ??= t0; + this.t0 = t0; + this.lasttime = performance.now(); + const a = (t0 - this.origin) * this.cps; + const b = (t1 - this.origin) * this.cps; + + const haps = this.pattern.queryArc(a, b).filter((hap) => hap.hasOnset()); + if (!haps.length) { + return; + } + haps.forEach((hap) => { + const time = hap.whole.begin.valueOf() / this.cps + this.origin + this.latency; + const duration = hap.duration.valueOf() / this.cps; + const event = { + dough: 'play', + ...hap.value, + time, + duration, + }; + if (event.note && typeof event.note === 'string') { + event.note = noteToMidi(event.note); + } + //console.log("event", JSON.stringify(Object.entries(event))); + this.dough.evaluate(event); + this.q.push({ event, hap }); + }); + }, + }); + // miniAllStrings(); + const setcps = (cps) => (this.cps = cps); + const setcpm = (cpm) => setcps(cpm / 60); + const replScope = { setcps, setcpm }; + Object.assign(globalThis, replScope); + } + async evaluate(code) { + await this.ready; + let patterns = []; + Pattern.prototype.p = function (id) { + if (!id.startsWith('_')) { + patterns.push(this); + } + }; + + let { meta } = await evaluate(code, transpiler, { addReturn: false, wrapAsync: true, emitWidgets: false }); + const { miniLocations } = meta; + + this.pattern = stack(...patterns); + return { miniLocations, pattern: this.pattern }; + } + stop() { + this.pattern = undefined; + this.origin = undefined; + } + prebake() { + return Promise.all([ + // doughsamples('github:eddyflux/crate') + ]); + } + // tbd: move this to dough-synth + get time() { + return this.t0 + (performance.now() - this.lasttime) / 1000 + this.latency; + } + processHaps() { + const currentHaps = []; + const time = this.time; + this.q = this.q.filter(({ event, hap }) => { + const end = event.time + event.duration; + const isActive = time >= event.time && time <= end; + if (isActive) { + currentHaps.push(hap); + } + return end > time; // delete old events + // we do NOT return !isActive, because a frame might miss an event, which would cause a leak + }); + // console.log(this.q.length); // to check for leaks + return currentHaps; + } +} diff --git a/website/src/pages/dough/index.astro b/website/src/pages/dough/index.astro new file mode 100644 index 000000000..9909fc6da --- /dev/null +++ b/website/src/pages/dough/index.astro @@ -0,0 +1,15 @@ +--- +import HeadCommon from '../../components/HeadCommon.astro'; +import Dough from '../../components/Dough/Dough.astro'; +--- + + + + + Strudel Dough REPL + + + + + + diff --git a/website/src/pages/learn/metadata.mdx b/website/src/pages/learn/metadata.mdx index 61db16f0e..c94257925 100644 --- a/website/src/pages/learn/metadata.mdx +++ b/website/src/pages/learn/metadata.mdx @@ -11,9 +11,9 @@ import { JsDoc } from '../../docs/JsDoc'; You can optionally add some music metadata in your Strudel code, by using tags in code comments: ```js -// @title Hey Hoo -// @by Sam Tagada -// @license CC BY-NC-SA +// @title My Cool Song +// @by John Doe +// @license CC-BY-SA-4.0 ``` Like other comments, those are ignored by Strudel, but it can be used by other tools to retrieve some information about the music. @@ -24,22 +24,22 @@ You can also use comment blocks: ```js /* -@title Hey Hoo -@by Sam Tagada -@license CC BY-NC-SA +@title My Cool Song +@by John Doe +@license CC-BY-SA-4.0 */ ``` Or define multiple tags in one line: ```js -// @title Hey Hoo @by Sam Tagada @license CC BY-NC-SA +// @title My Cool Song @by John Doe @license CC-BY-SA-4.0 ``` The `title` tag has an alternative syntax using quotes (must be defined at the very begining): ```js -// "Hey Hoo" @by Sam Tagada +// "My Cool Song" @by John Doe ``` ## Tags list @@ -48,20 +48,22 @@ Available tags are: - `@title`: music title - `@by`: music author(s), separated by comma, eventually followed with a link in `<>` (ex: `@by John Doe `) -- `@license`: music license(s), e.g. CC BY-NC-SA. Unsure? [Choose a creative commons license here](https://creativecommons.org/choose/) +- `@license`: music license(s), separated by comma. Each license should be specified by using the correct identifier in the [https://spdx.org/licenses/](SPDX License List). Example: CC-BY-SA-4.0. Unsure? [Choose a Creative Commons license here](https://creativecommons.org/choose/). - `@details`: some additional information about the music -- `@url`: web page(s) related to the music (git repo, soundcloud link, etc.) -- `@genre`: music genre(s) (pop, jazz, etc) +- `@url`: web page(s) related to the music (git repository, Soundcloud link, etc.) +- `@genre`: music genre(s) (pop, jazz, etc.) - `@album`: music album name +Note to tool authors: _Never_ trust that a song has filled those fields with syntactically correct values; make sure your software is robust enough it doesn't break if it encounters bad values + ## Multiple values Some of them accepts several values, using the comma or new line separator, or duplicating the tag: ```js /* -@by Sam Tagada - Jimmy +@by John Doe + Jane Doe @genre pop, jazz @url https://example.com @url https://example.org @@ -72,11 +74,11 @@ You can also add optional prefixes and use tags where you want: ```js /* -song @by Sam Tagada -samples @by Jimmy +song @by John Doe +samples @by Jane Doe */ ... -note("a3 c#4 e4 a4") // @by Sandy +note("a3 c#4 e4 a4") // @by Sandy Sue ``` ## Multiline diff --git a/website/src/pages/learn/mini-notation.mdx b/website/src/pages/learn/mini-notation.mdx index 02ead7434..8b09f12f4 100644 --- a/website/src/pages/learn/mini-notation.mdx +++ b/website/src/pages/learn/mini-notation.mdx @@ -142,6 +142,8 @@ The "~" represents a rest, and will create silence between other events: +Alternatively, "-" can be used instead of "~". It means the same thing. + ## Parallel / polyphony Using commas, we can play chords. diff --git a/website/src/pages/technical-manual/repl.mdx b/website/src/pages/technical-manual/repl.mdx index f74a4fb8b..d8a22cf55 100644 --- a/website/src/pages/technical-manual/repl.mdx +++ b/website/src/pages/technical-manual/repl.mdx @@ -41,7 +41,7 @@ note("c3 [e3 g3]*2") is transpiled to: ```strudel -note(m('c3 [e3 g3]', 5)) +note(m('c3 [e3 g3]*2', 5)) ``` Here, the string is wrapped in `m`, which will create a pattern from a mini-notation string. As the second parameter, it gets passed source code location of the string, which enables highlighting active events later. diff --git a/website/src/pages/technical-manual/vim.mdx b/website/src/pages/technical-manual/vim.mdx index fa53aa550..6ec41d4a2 100644 --- a/website/src/pages/technical-manual/vim.mdx +++ b/website/src/pages/technical-manual/vim.mdx @@ -35,3 +35,24 @@ Troubleshooting - If :w logs but evaluation doesn't apply, ensure Vim keybindings are active and try again. You can also use Ctrl+Enter as a fallback. - For :q / gc, ensure focus is inside the editor. If an error occurs, reload the page to reset editor state and try again. + +## Adding custom keybindings + +To add custom keybindings the `Vim` object can be used (either from within a pattern or in a prebake script) + +Example: + +```javascript +// Map 'jk' to Escape in normal mode +Vim.map('jk', '', 'insert'); +// Map 'U' to :w (Evaluate the current code) +Vim.map('U', ':w', 'normal'); +// Map 'Q' to :q — Stop/pause playback +Vim.map('Q', ':q', 'normal'); +// Map 'J' to find next '$' (jump to next label) +Vim.map('J', '/\\$', 'normal'); +// Map 'K' to find previous '$' (jump to previous label) +Vim.map('K', '?\\$', 'normal'); +``` + +For more information on how to use the `Vim` object see [CodeMirror Vim](https://github.com/replit/codemirror-vim) diff --git a/website/src/pages/workshop/first-sounds.mdx b/website/src/pages/workshop/first-sounds.mdx index 1d659972b..84deb0551 100644 --- a/website/src/pages/workshop/first-sounds.mdx +++ b/website/src/pages/workshop/first-sounds.mdx @@ -377,4 +377,4 @@ insect [crow metal] - -, punchcard /> -Now that we know the basics of how to make beats, let's look at how we can play [notes](/workshop/first-notes) +Now that we know the basics of how to make beats, let's look at how we can play [notes](/workshop/first-notes). diff --git a/website/src/repl/audiograph.mjs b/website/src/repl/audiograph.mjs new file mode 100644 index 000000000..2876388a8 --- /dev/null +++ b/website/src/repl/audiograph.mjs @@ -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 +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 . +*/ + +// 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(' ', ' ') + ']\n'; + + let { svg } = await mermaid.render('strudelSvgId', gd); + svg = svg.replace(/max-width:\s[0-9.]*px;/i, 'height: 100%'); + svg = svg.replaceAll('&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 + '
').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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + }; + 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 = ''; + // prepare svg + const content = document + .querySelector(selector) + .innerHTML.replaceAll('
', '
') + // remove useless tags + .replace(/ { + 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 = 'ExportDiagram'; + 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(); + }), + ); +}; diff --git a/website/src/repl/components/panel/ExportTab.jsx b/website/src/repl/components/panel/ExportTab.jsx new file mode 100644 index 000000000..9671ff1a3 --- /dev/null +++ b/website/src/repl/components/panel/ExportTab.jsx @@ -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 ( + + ); +} + +function FormItem({ label, children, disabled }) { + return ( +
+ + {children} +
+ ); +} + +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 ( + <> +
+ + { + 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 ?? ''} + /> + +
+ + { + 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 ?? ''} + /> + + + { + 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 ?? ''} + /> + +
+
+ + { + 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 ?? ''} + /> + + + { + 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 ?? ''} + /> + +
+
+ { + const val = cbEvent.target.checked; + setMultiChannelOrbits(val); + }} + disabled={exporting} + value={multiChannelOrbits} + /> +
+ +
+ + + ); +} diff --git a/website/src/repl/components/panel/Panel.jsx b/website/src/repl/components/panel/Panel.jsx index b26c2edef..cfe90ae30 100644 --- a/website/src/repl/components/panel/Panel.jsx +++ b/website/src/repl/components/panel/Panel.jsx @@ -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 ; case tabNames.reference: return ; + case tabNames.export: + return ; case tabNames.settings: return ; case tabNames.files: diff --git a/website/src/repl/tunes.mjs b/website/src/repl/tunes.mjs index f1c72b12d..697e83c9d 100644 --- a/website/src/repl/tunes.mjs +++ b/website/src/repl/tunes.mjs @@ -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,[~ ],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) diff --git a/website/src/repl/useReplContext.jsx b/website/src/repl/useReplContext.jsx index 8dcb6b754..0fbd89e38 100644 --- a/website/src/repl/useReplContext.jsx +++ b/website/src/repl/useReplContext.jsx @@ -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,