Compare commits

..

6 Commits

Author SHA1 Message Date
Aria 1c08bd1014 Correct ids for subgraphs 2025-12-08 14:54:14 -06:00
Aria e1fc5eec5b Move disconnect/release methods into Edge class 2025-12-08 14:51:05 -06:00
Aria 07c1a9eb23 Formatting 2025-12-08 14:48:14 -06:00
Aria c0423c1757 Unify subgraph and graph classes; add comments 2025-12-08 14:46:26 -06:00
Aria acd1f9e691 Typo 2025-12-08 14:24:18 -06:00
Aria 6429b0cb96 Proof of concept for graph management 2025-12-08 14:22:02 -06:00
38 changed files with 309 additions and 2055 deletions
+1 -4
View File
@@ -1,9 +1,6 @@
name: Build and Deploy to beta (warm.strudel.cc)
on:
push:
branches:
- main
on: [workflow_dispatch]
# Allow one concurrent deployment
concurrency:
+17 -22
View File
@@ -4,13 +4,20 @@ Thanks for wanting to contribute!!! There are many ways you can add value to thi
## Move to codeberg
Along with many other live coding projects, we have moved from Microsoft's Github platform to Codeberg for ethical reasons. Please don't fork the project back to github.
We are currently in the process of moving from github to codeberg -- not everything is working, please bear with us.
To update your local clone, you can run this command:
```
git remote set-url origin git@codeberg.org:uzu/strudel.git
```
## Communication Channels
To get in touch with the community, either
To get in touch with the contributors, either
- [join the Uzulang Discord Server](https://discord.com/invite/HGEdXmRkzT) and go to the strudel channels (Uzulangs are a family of live coding languages inspired by each other, including TidalCycles as well as Strudel)
- [join the Tidal Discord Channel](https://discord.com/invite/HGEdXmRkzT) and go to the #strudel channel
- Find related discussions on the [tidal club forum](https://club.tidalcycles.org/)
## Ask a Question
@@ -29,35 +36,22 @@ Use one of the Communication Channels listed above and drop us a line or two!
## Share Music
If you made some music with strudel, you can give back some love and share what you've done!
Your creation could also be part of the random selection in the REPL if you want.
Use one of the Communication Channels listed above.
(There used to be a random selection of contributed patterns in the REPL, but that is unfortunately disabled for now due to abuse)
## Improve the Docs
If you find some weak spots in the [docs](https://strudel.cc/workshop/getting-started/), you can edit each file directly on codeberg. There are "Edit this page" links in the right sidebar that take you to the right place.
If you find some weak spots in the [docs](https://strudel.cc/workshop/getting-started/),
you can edit each file directly on codeburg. (we are currently fixing the "Edit this page" links in the right sidebar)
## Propose a Feature
If you want a specific feature that is not part of strudel yet, feel free to use one of the communication channels above. Please bear in mind that this is a free/open source project, oriented around collaborative discussion. Maybe you even want to help with the implementation of that feature!
## Contribute a feature
Pull requests welcome! Consider starting with discussion or proof-of-concept first, rather than do loads of work on something and then find it doesn't fit the collective goals of the project, or something like that.
At the time of writing we have a PR backlog, and generally prioritise bugfixes and contributions that have arisen through community discussion.
### AI/LLM policy
Strudel is a project handmade by humans, with thought and nuance.
If you have used LLMs (so called 'AI'), please detail that in the pull request. We are still developing our response to the onslaught of LLM technology, but for practical and legal reasons are currently not accepting wholly LLM-generated code. We are also not accepting PRs that add LLM features to strudel itself.
There are #llm-chat and #llm-share channels on our discord. Please do not discuss or share LLM-related things outside of those channels.
If you want a specific feature that is not part of strudel yet, feel free to use one of the communication channels above.
Maybe you even want to help with the implementation of that feature!
## Report a Bug
If you've found a bug, or some behaviour that does not seem right, you are welcome to file an [issue](https://codeberg.org/uzu/strudel/issues).
Please check that it has not been reported before.
## Fix a Bug
@@ -124,7 +118,8 @@ There are also eslint extensions / plugins for most editors.
## Running all CI Checks
When opening a PR, the CI runner will (once approved by a human) check the code style and eslint, as well as run all tests. You can run the same check with `pnpm check`.
When opening a PR, the CI runner will automatically check the code style and eslint, as well as run all tests.
You can run the same check with `pnpm check`
## Package Workflow
+2 -2
View File
@@ -293,9 +293,9 @@ export class StrudelMirror {
console.warn('first frame could not be painted');
}
}
async evaluate(autostart = true) {
async evaluate() {
this.flash();
await this.repl.evaluate(this.code, autostart);
await this.repl.evaluate(this.code);
}
async stop() {
this.repl.scheduler.stop();
+2 -6
View File
@@ -1,5 +1,5 @@
import { defaultKeymap } from '@codemirror/commands';
import { Prec, EditorState } from '@codemirror/state';
import { Prec } from '@codemirror/state';
import { keymap, ViewPlugin } from '@codemirror/view';
// import { searchKeymap } from '@codemirror/search';
import { emacs } from '@replit/codemirror-emacs';
@@ -133,9 +133,5 @@ const keymaps = {
export function keybindings(name) {
const active = keymaps[name];
const extensions = active ? [Prec.high(active())] : [];
if (name === 'vim') {
extensions.push(EditorState.allowMultipleSelections.of(true));
}
return extensions;
return [active ? Prec.high(active()) : []];
}
-49
View File
@@ -1,49 +0,0 @@
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);
+2 -7
View File
@@ -2753,13 +2753,8 @@ export const as = register('as', (mapping, pat) => {
mapping = Array.isArray(mapping) ? mapping : [mapping];
return pat.fmap((v) => {
v = Array.isArray(v) ? v : [v];
const entries = [];
for (let i = 0; i < mapping.length; ++i) {
if (v[i] !== undefined) {
entries.push([getControlName(mapping[i]), v[i]]);
}
}
return Object.fromEntries(entries);
v = Object.fromEntries(mapping.map((prop, i) => [getControlName(prop), v[i]]));
return v;
});
});
-90
View File
@@ -1,90 +0,0 @@
/*
stateful.mjs - File of shame for stateful, impure and otherwise illegal pattern methods
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/index.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { register, reify, Pattern } from './pattern.mjs';
let timelines = {};
export const reset_state = function () {
reset_timelines();
};
export const reset_timelines = function () {
timelines = {};
};
/***
* Allows you to switch a pattern between different 'timelines'. This is particularly useful when
* live coding, for example when you want to cue a pattern up to play from its start.
*
* Timelines are specified by number, so that if you had a pattern like
* `n("<0 1 2 3>").s("num").timeline(1)` playing, then changed the '1'
* to '2', it would always align '0' to the nearest cycle. You will likely want to trigger
* an evaluation a little bit before the cycle starts, to avoid missing events.
*
* After the first use, a timeline will continue with the same 'offset'. That is, if you change
* a pattern without changing its timeline number, it will stay on that timeline without resetting.
*
* Rather than incrementing a timeline to reset it, it's easier to negate it, e.g. by switching between `-2`
* and `2`. This is because when you negate a timeline it will always reset.
*
* You can also pattern the timeline if you want, to create strange resetting patterns.
* @param {number | Pattern} timeline The timeline that the pattern should play on.
* @example
* n("<0 1 2 3>(3,8)")
* .sound("num")
* // resets the timeline every two cycles, by negating the timeline.
* // in a lot of cases this will be edited by a human live coder
* // rather than patterned!
* .timeline("<2 -2>".slow(2))
*/
export const timeline = register(
'timeline',
function (tpat, pat) {
tpat = reify(tpat);
const f = function (state) {
// Is this called from the scheduler? (rather than from e.g. the visualiser)
const scheduler = !!state.controls.cyclist;
const timehaps = tpat.query(state);
const result = [];
for (const timehap of timehaps) {
const tlid = timehap.value;
let offset;
if (tlid === 0) {
offset = 0;
} else if (tlid in timelines) {
offset = timelines[tlid];
} else {
const timearc = timehap.wholeOrPart();
if (!scheduler || state.span.begin.lt(timearc.midpoint())) {
offset = timearc.begin;
} else {
// Sync to end of timearc if we first see it over halfway into its
// timespan. Allows 'cuing up' next timeline when live coding.
offset = timearc.end;
}
}
if (scheduler) {
// update state
timelines[tlid] = offset;
if (tlid !== 0) {
delete timelines[-tlid];
}
}
const pathaps = pat
.late(offset)
.query(state.setSpan(timehap.part))
.map((h) => h.setContext(h.combineContext(timehap)));
result.push(...pathaps);
}
return result;
};
return new Pattern(f, pat._steps);
},
false,
);
+9 -10
View File
@@ -1,6 +1,6 @@
/*
index.mjs - <short description TODO>
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/index.mjs>
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/index.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@@ -11,21 +11,20 @@ import createClock from './zyklus.mjs';
import { logger } from './logger.mjs';
export { Fraction, controls, createClock };
export * from './controls.mjs';
export * from './cyclist.mjs';
export * from './evaluate.mjs';
export * from './hap.mjs';
export * from './impure.mjs';
export * from './logger.mjs';
export * from './pattern.mjs';
export * from './pick.mjs';
export * from './repl.mjs';
export * from './signal.mjs';
export * from './speak.mjs';
export * from './pick.mjs';
export * from './state.mjs';
export * from './time.mjs';
export * from './timespan.mjs';
export * from './ui.mjs';
export * from './util.mjs';
export * from './speak.mjs';
export * from './evaluate.mjs';
export * from './repl.mjs';
export * from './cyclist.mjs';
export * from './logger.mjs';
export * from './time.mjs';
export * from './ui.mjs';
export { default as drawLine } from './drawLine.mjs';
// below won't work with runtime.mjs (json import fails)
/* import * as p from './package.json';
-4
View File
@@ -5,7 +5,6 @@ import { errorLogger, logger } from './logger.mjs';
import { setTime } from './time.mjs';
import { evalScope } from './evaluate.mjs';
import { register, Pattern, isPattern, silence, stack } from './pattern.mjs';
import { reset_state } from './impure.mjs';
export function repl({
defaultOutput,
@@ -53,9 +52,6 @@ export function repl({
onToggle: (started) => {
updateState({ started });
onToggle?.(started);
if (!started) {
reset_state();
}
},
setInterval,
clearInterval,
+38 -171
View File
@@ -16,7 +16,7 @@ export function steady(value) {
}
export const signal = (func) => {
const query = (state) => [new Hap(undefined, state.span, func(state.span.begin, state.controls))];
const query = (state) => [new Hap(undefined, state.span, func(state.span.begin))];
return new Pattern(query);
};
@@ -186,97 +186,38 @@ export const mouseY = signal(() => _mouseY);
export const mousex = signal(() => _mouseX);
export const mouseX = signal(() => _mouseX);
// Random number generators
// random signals
// 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 xorwise = (x) => {
const a = (x << 13) ^ x;
const b = (a >> 17) ^ a;
return (b << 5) ^ b;
};
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));
}
// 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 result = [];
for (let i = 0; i < n; i++) {
result.push(__intSeedToRand(seed));
seed = __xorwise(seed);
// eslint-disable-next-line
for (let i = 0; i < n; ++i) {
result.push(intSeedToRand(seed));
seed = xorwise(seed);
}
return result;
};
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);
};
const timeToRands = (t, n) => timeToRandsPrime(timeToIntSeed(t), n);
/**
* 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
@@ -359,13 +300,13 @@ export const binaryNL = (n, nBits = 16) => {
* .partials(randL(8))
*/
export const randL = (n) => {
return signal((t) => (nVal) => getRandsAtTime(t, nVal).map(Math.abs)).appLeft(reify(n));
return signal((t) => (nVal) => timeToRands(t, nVal).map(Math.abs)).appLeft(reify(n));
};
export const randrun = (n) => {
return signal((t, controls) => {
return signal((t) => {
// Without adding 0.5, the first cycle is always 0,1,2,3,...
const rands = getRandsAtTime(t.floor().add(0.5), n, controls.randSeed);
const rands = timeToRands(t.floor().add(0.5), n);
const nums = rands
.map((n, i) => [n, i])
.sort((a, b) => (a[0] > b[0]) - (a[0] < b[0]))
@@ -406,37 +347,6 @@ 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.
*
@@ -446,7 +356,7 @@ export const seed = register('seed', (n, pat) => {
* s("bd*4,hh*8").cutoff(rand.range(500,8000))
*
*/
export const rand = signal((t, controls) => getRandsAtTime(t, 1, controls.randSeed));
export const rand = signal(timeToRand);
/**
* A continuous pattern of random numbers, between -1 and 1
*/
@@ -623,32 +533,36 @@ export const wchooseCycles = (...pairs) => _wchooseWith(rand.segment(1), ...pair
export const wrandcat = wchooseCycles;
function _perlin(t, seed = 0) {
function _perlin(t) {
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 ra = getRandsAtTime(ta, 1, seed);
const rb = getRandsAtTime(tb, 1, seed);
const v = interp(t - ta)(ra)(rb);
const v = interp(t - ta)(timeToRand(ta))(timeToRand(tb));
return v;
}
export const perlinWith = (tpat) => {
return tpat.fmap(_perlin);
};
function _berlin(t, seed = 0) {
function _berlin(t) {
const prevRidgeStartIndex = Math.floor(t);
const nextRidgeStartIndex = prevRidgeStartIndex + 1;
const prevRidgeBottomPoint = getRandsAtTime(prevRidgeStartIndex, 1, seed);
const height = getRandsAtTime(nextRidgeStartIndex, 1, seed);
const nextRidgeTopPoint = prevRidgeBottomPoint + height;
const prevRidgeBottomPoint = timeToRand(prevRidgeStartIndex);
const nextRidgeTopPoint = timeToRand(nextRidgeStartIndex) + prevRidgeBottomPoint;
const currentPercent = (t - prevRidgeStartIndex) / (nextRidgeStartIndex - prevRidgeStartIndex);
const interp = (a, b, t) => {
return a + t * (b - a);
return a + (b - a) * t;
};
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.
*
@@ -658,7 +572,7 @@ function _berlin(t, seed = 0) {
* s("bd*4,hh*8").cutoff(perlin.range(500,8000))
*
*/
export const perlin = signal((t, controls) => _perlin(t, controls.randSeed));
export const perlin = perlinWith(time.fmap((v) => Number(v)));
/**
* 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,
@@ -670,7 +584,7 @@ export const perlin = signal((t, controls) => _perlin(t, controls.randSeed));
* n("0!16".add(berlin.fast(4).mul(14))).scale("d:minor")
*
*/
export const berlin = signal((t, controls) => _berlin(t, controls.randSeed));
export const berlin = berlinWith(time.fmap((v) => Number(v)));
export const degradeByWith = register(
'degradeByWith',
@@ -968,50 +882,3 @@ export const whenKey = register('whenKey', function (input, func, pat) {
export const keyDown = register('keyDown', function (pat) {
return pat.fmap(_keyDown);
});
/**
* 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
* // 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 cyclesPer = new Pattern(function (state) {
return [new Hap(undefined, state.span, state.span.duration)];
});
/**
* A pattern measuring the 'shortness' of events, or in other words, the duration of pattern events,
* in events per cycle. `per` doesn't have structure itself, but takes structure, and therefore
* event durations, from the pattern that it is combined with.
* For example `per.struct("1 1 [1 1] 1")` would give the same as `"4 4 [8 8] 4"`.
* See also its reciprocal, `cyclesPer`.
* @synonyms perCycle
* @example
* // Shorter events are more distorted
* n("0 0*2 0 0*2 0 [0 0 0]@2").sound("bd")
* .distort(per.div(2))
*/
export const per = new Pattern(function (state) {
return [new Hap(undefined, state.span, Fraction(1).div(state.span.duration))];
});
export const perCycle = per;
/**
* Like `per` but measures the shortness of events according to an exponential curve. In
* particular, where the event duration halves, the
* returned value increases by one. `perx.struct("1 1 [1 [1 1]] 1")` would therefore be
* the same as `"3 3 [4 [5 5]] 3"`.
*/
export const perx = new Pattern(function (state) {
const n = Fraction(1).div(state.span.duration);
return [new Hap(undefined, state.span, Math.log(n) / Math.log(2) + 1)];
});
+14
View File
@@ -740,6 +740,20 @@ describe('Pattern', () => {
);
});
});
describe('signal()', () => {
it('Can make saw/saw2', () => {
expect(saw.struct(true, true, true, true).firstCycle()).toStrictEqual(
sequence(0, 1 / 4, 1 / 2, 3 / 4).firstCycle(),
);
expect(saw2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(-1, -0.5, 0, 0.5).firstCycle());
});
it('Can make isaw/isaw2', () => {
expect(isaw.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.75, 0.5, 0.25).firstCycle());
expect(isaw2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.5, 0, -0.5).firstCycle());
});
});
describe('_setContext()', () => {
it('Can set the hap context', () => {
expect(
-61
View File
@@ -1,61 +0,0 @@
/*
signal.test.mjs - <short description TODO>
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/test/pattern.test.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Fraction from 'fraction.js';
import { describe, it, expect, vi } from 'vitest';
import { saw, saw2, isaw, isaw2, per, perx, cyclesPer } from '../signal.mjs';
import { fastcat, sequence, State, TimeSpan, Hap } from '../index.mjs';
const st = (begin, end) => new State(ts(begin, end));
const ts = (begin, end) => new TimeSpan(Fraction(begin), Fraction(end));
const hap = (whole, part, value, context = {}) => new Hap(whole, part, value, context);
const third = Fraction(1, 3);
const twothirds = Fraction(2, 3);
const sameFirst = (a, b) => {
return expect(a.sortHapsByPart().firstCycle()).toStrictEqual(b.sortHapsByPart().firstCycle());
};
describe('signal()', () => {
it('Can make saw/saw2', () => {
expect(saw.struct(true, true, true, true).firstCycle()).toStrictEqual(
sequence(0, 1 / 4, 1 / 2, 3 / 4).firstCycle(),
);
expect(saw2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(-1, -0.5, 0, 0.5).firstCycle());
});
it('Can make isaw/isaw2', () => {
expect(isaw.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.75, 0.5, 0.25).firstCycle());
expect(isaw2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.5, 0, -0.5).firstCycle());
});
});
describe('cyclesPer', () => {
it('gives cycles per hap', () => {
sameFirst(
cyclesPer.struct(true, true, true, fastcat(true, true)),
sequence(0.25, 0.25, 0.25, fastcat(0.125, 0.125)).fmap(Fraction),
);
});
});
describe('per', () => {
it('gives haps per cycle', () => {
sameFirst(per.struct(true, true, true, fastcat(true, true)), sequence(4, 4, 4, fastcat(8, 8)).fmap(Fraction));
});
});
describe('perx', () => {
it('gives exponential haps per cycle', () => {
sameFirst(
perx.struct(true, true, true, fastcat(true, fastcat(true, true))),
sequence(3, 3, 3, fastcat(4, fastcat(5, 5))),
);
});
});
-5
View File
@@ -5,11 +5,6 @@ export const setDefaultAudioContext = () => {
return audioContext;
};
export const setAudioContext = (context) => {
audioContext = context;
return audioContext;
};
export const getAudioContext = () => {
if (!audioContext) {
return setDefaultAudioContext();
+143
View File
@@ -0,0 +1,143 @@
/*
audioGraph.mjs - Shadow web audio graph used for managing connections
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/audioGraph.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { logger } from './logger.mjs';
// This helper should be used instead of the `node.onended = callback` pattern
// It adds a mechanism to help minimize gc retention
export const onceEnded = (node, callback) => {
const onended = callback;
node.onended = function cleanup() {
onended && onended();
this.onended = null;
};
};
export const releaseAudioNode = (node) => {
if (node == null) return;
// check we received an AudioNode
if (!(node instanceof AudioNode)) {
throw new Error('releaseAudioNode can only release an AudioNode');
}
// https://developer.mozilla.org/en-US/docs/Web/API/AudioNode/disconnect
node.disconnect();
// 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') {
logger(
`[superdough] Deprecation warning: it seems your code path is setting 'node.onended = callback' instead of using the onceEnded helper`,
);
}
try {
node.stop();
} catch (e) {
// At the stage, `start` was not called on the node
// but an `onended` callback releasing resources may exist
// and we want it to fire :
// - we force a start/stop cycle so that `onended` gets called
// - we `lock` the node so that no-one can start it
node.start(node.context.currentTime + 5); // will never happen
node.stop();
}
}
// https://www.w3.org/TR/webaudio-1.1/#AudioNode-actively-processing
// An AudioWorkletNode is actively processing when its AudioWorkletProcessor's [[callable process]]
// returns true and either its active source flag is true or
// any AudioNode connected to one of its inputs is actively processing.
if (node instanceof AudioWorkletNode) {
// while `end` is not native to the web audio API, it is common practice in superdough
// to use that param in the worklets to trigger returning false from the processor
node.parameters.get('end')?.setValueAtTime(0, 0);
}
};
// Once the `anchor` node has ended, release all nodes in `toCleanup`
export const cleanupOnEnd = (anchor, toCleanup) => {
onceEnded(anchor, () => toCleanup.forEach((n) => releaseAudioNode(n)));
};
class Edge {
constructor(from, to) {
this.from = new WeakRef(from);
this.to = new WeakRef(to);
this.subGraphs = new Set();
}
disconnect() {
const from = this.from.deref();
const to = this.to.deref();
from && to && from.disconnect(to);
}
release() {
const from = this.from.deref();
if (from instanceof AudioNode) {
releaseAudioNode(from);
}
}
}
let audioGraph;
class AudioGraph {
constructor(id) {
this.id = id;
this.activeSubGraphs = [];
this.subGraphs = {};
this.edges = [];
this.subGraphCounter = 0;
}
connect(from, to) {
const edge = new Edge(from, to);
for (const subGraph of this.activeSubGraphs) {
// Track which subgraphs it's in
edge.subGraphs.add(subGraph.id);
// Add to the subgraph's `edges`
subGraph.edges.push(edge);
// Add to this' `edges`
this.edges.push(edge);
}
// Make the actual connection
return from.connect(to);
}
// Introduces a context wherein all connections will be added to both this graph
// and the subgraph and all edges will be tagged with the subgraph for tracking
asSubGraph(fn) {
const subGraphID = `${this.id}_${this.subGraphCounter}`;
this.subGraphCounter++;
const subGraph = new AudioGraph(subGraphID);
this.subGraphs[subGraphID] = subGraph;
this.activeSubGraphs.push(subGraph);
try {
return { subGraph, output: fn() };
} finally {
this.activeSubGraphs.pop();
}
}
// Disconnects all from-to connections (rather than naked `from.disconnect()`)
disconnect() {
this.edges.forEach((edge) => edge.disconnect());
this.edges = [];
}
// Release this entire graph (nodes will be fully disconnected, stopped, etc)
release() {
this.edges.forEach((edge) => edge.release());
this.edges = [];
}
}
export const getAudioGraph = () => {
if (audioGraph === undefined) {
audioGraph = new AudioGraph(0);
}
return audioGraph;
};
+1 -1
View File
@@ -25,7 +25,7 @@ if (typeof DelayNode !== 'undefined') {
}
}
BaseAudioContext.prototype.createFeedbackDelay = function (wet, time, feedback) {
AudioContext.prototype.createFeedbackDelay = function (wet, time, feedback) {
return new FeedbackDelayNode(this, wet, time, feedback);
};
}
+21 -97
View File
@@ -1,4 +1,5 @@
import { getAudioContext } from './audioContext.mjs';
import { getAudioGraph, onceEnded, releaseAudioNode } from './audioGraph.mjs';
import { logger } from './logger.mjs';
import { getNoiseBuffer } from './noise.mjs';
import { clamp, nanFallback, midiToFreq, noteToMidi } from './util.mjs';
@@ -42,7 +43,6 @@ 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,
@@ -60,15 +60,17 @@ 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) {
val = time * getSlope(min, max, 0, attack) + min;
let slope = getSlope(min, peak, 0, attack);
val = time * slope + (min > peak ? min : 0);
} else {
val = (time - attack) * getSlope(max, sustainVal, 0, decay) + max;
val = (time - attack) * getSlope(peak, sustainVal, 0, decay) + peak;
}
if (curve === 'exponential') {
val = val || 0.001;
@@ -268,30 +270,20 @@ let wetfade = (d) => (d < 0.5 ? 1 : 1 - (d - 0.5) / 0.5);
// still not too sure about how this could be used more generally...
export function drywet(dry, wet, wetAmount = 0) {
const ac = getAudioContext();
const ag = getAudioGraph();
if (!wetAmount) {
return dry;
}
let dry_gain = ac.createGain();
let wet_gain = ac.createGain();
dry.connect(dry_gain);
wet.connect(wet_gain);
ag.connect(dry, dry_gain);
ag.connect(wet, wet_gain);
dry_gain.gain.value = wetfade(wetAmount);
wet_gain.gain.value = wetfade(1 - wetAmount);
let mix = ac.createGain();
dry_gain.connect(mix);
wet_gain.connect(mix);
return {
node: mix,
teardown: () => {
releaseAudioNode(dry_gain);
releaseAudioNode(wet_gain);
// it is not the responsability of drywet
// to call `releaseAudioNode` on
// the 2 external args dry and wet
dry.disconnect(dry_gain);
wet.disconnect(wet_gain);
},
};
const mix = ac.createGain();
ag.connect(dry_gain, mix);
ag.connect(wet_gain, mix);
return { node: mix };
}
let curves = ['linear', 'exponential'];
@@ -319,17 +311,14 @@ export function getVibratoOscillator(param, value, t) {
const { vibmod = 0.5, vib } = value;
let vibratoOscillator;
if (vib > 0) {
const ag = getAudioGraph();
vibratoOscillator = getAudioContext().createOscillator();
vibratoOscillator.frequency.value = vib;
const gain = getAudioContext().createGain();
// Vibmod is the amount of vibrato, in semitones
gain.gain.value = vibmod * 100;
vibratoOscillator.connect(gain);
gain.connect(param);
onceEnded(vibratoOscillator, () => {
releaseAudioNode(gain);
releaseAudioNode(vibratoOscillator);
});
ag.connect(vibratoOscillator, gain);
ag.connect(gain, param);
vibratoOscillator.start(t);
return vibratoOscillator;
}
@@ -385,7 +374,7 @@ const fm = (frequencyparam, harmonicityRatio, wave = 'sine') => {
export function applyFM(param, value, begin) {
const ac = getAudioContext();
const toStop = []; // fm oscillators we will expose `stop` for
const ag = getAudioGraph();
const fms = {};
// Matrix
for (let i = 1; i <= 8; i++) {
@@ -412,8 +401,6 @@ export function applyFM(param, value, begin) {
if (!fms[idx]) {
const idxS = idx === 1 ? '' : idx;
const { osc, freq } = fm(param, value[`fmh${idxS}`] ?? 1, value[`fmwave${idxS}`] ?? 'sine');
toStop.push(osc);
const toCleanup = [osc]; // nodes we want to cleanup after oscillator `stop`
const adsr = ['attack', 'decay', 'sustain', 'release'].map((s) => value[`fm${s}${idxS}`]);
let output = osc;
if (adsr.some((v) => v !== undefined)) {
@@ -433,15 +420,13 @@ export function applyFM(param, value, begin) {
holdEnd,
fmEnvelopeType === 'exp' ? 'exponential' : 'linear',
);
toCleanup.push(envGain);
output = osc.connect(envGain);
output = ag.connect(osc, envGain);
}
fms[idx] = { input: osc.frequency, output, freq, osc, toCleanup };
fms[idx] = { input: osc.frequency, output, freq };
}
const { input, output, freq, osc, toCleanup } = fms[idx];
const { input, output, freq } = fms[idx];
const g = gainNode(amt * freq);
io.push(isMod ? output.connect(g) : input);
cleanupOnEnd(osc, [...toCleanup, g]);
io.push(isMod ? ag.connect(output, g) : input);
}
if (!io[1]) {
logger(
@@ -450,12 +435,9 @@ export function applyFM(param, value, begin) {
);
continue;
}
io[0].connect(io[1]);
ag.connect(io[0], io[1]);
}
}
return {
stop: (t) => toStop.forEach((m) => m?.stop(t)),
};
}
// Saturation curves
@@ -567,61 +549,3 @@ export const getFrequencyFromValue = (value, defaultNote = 36) => {
freq *= Math.pow(2, octave);
return Number(freq);
};
// This helper should be used instead of the `node.onended = callback` pattern
// It adds a mechanism to help minimize gc retention
export const onceEnded = (node, callback) => {
const onended = callback;
node.onended = function cleanup() {
onended && onended();
this.onended = null;
};
};
export const releaseAudioNode = (node) => {
if (node == null) return;
// check we received an AudioNode
if (!(node instanceof AudioNode)) {
throw new Error('releaseAudioNode can only release an AudioNode');
}
// https://developer.mozilla.org/en-US/docs/Web/API/AudioNode/disconnect
node.disconnect();
// make sure all AudioScheduledSourceNodes are in a stopped state
// https://developer.mozilla.org/en-US/docs/Web/API/AudioScheduledSourceNode
if (node instanceof AudioScheduledSourceNode) {
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`,
);
}
try {
node.stop();
} catch (e) {
// At the stage, `start` was not called on the node
// but an `onended` callback releasing resources may exist
// and we want it to fire :
// - we force a start/stop cycle so that `onended` gets called
// - we `lock` the node so that no-one can start it
node.start(node.context.currentTime + 5); // will never happen
node.stop();
}
}
// https://www.w3.org/TR/webaudio-1.1/#AudioNode-actively-processing
// An AudioWorkletNode is actively processing when its AudioWorkletProcessor's [[callable process]]
// returns true and either its active source flag is true or
// any AudioNode connected to one of its inputs is actively processing.
if (node instanceof AudioWorkletNode) {
// while `end` is not native to the web audio API, it is common practice in superdough
// to use that param in the worklets to trigger returning false from the processor
node.parameters.get('end')?.setValueAtTime(0, 0);
}
};
// Once the `anchor` node has ended, release all nodes in `toCleanup`
export const cleanupOnEnd = (anchor, toCleanup) => {
onceEnded(anchor, () => toCleanup.forEach((n) => releaseAudioNode(n)));
};
+8 -7
View File
@@ -4,12 +4,13 @@ Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
export * from './superdough.mjs';
export * from './sampler.mjs';
export * from './helpers.mjs';
export * from './synth.mjs';
export * from './zzfx.mjs';
export * from './logger.mjs';
export * from './dspworklet.mjs';
export * from './audioContext.mjs';
export * from './audioGraph.mjs';
export * from './dspworklet.mjs';
export * from './helpers.mjs';
export * from './logger.mjs';
export * from './sampler.mjs';
export * from './superdough.mjs';
export * from './synth.mjs';
export * from './wavetable.mjs';
export * from './zzfx.mjs';
+8 -4
View File
@@ -1,5 +1,6 @@
import { drywet, onceEnded, releaseAudioNode } from './helpers.mjs';
import { getAudioContext } from './audioContext.mjs';
import { getAudioGraph, onceEnded, releaseAudioNode } from './audioGraph.mjs';
import { drywet } from './helpers.mjs';
let noiseCache = {};
@@ -63,14 +64,17 @@ export function getNoiseOscillator(type = 'white', t, density = 0.02) {
}
export function getNoiseMix(inputNode, wet, t) {
const ag = getAudioGraph();
const noiseOscillator = getNoiseOscillator('pink', t);
const noiseMix = drywet(inputNode, noiseOscillator.node, wet);
const { subGraph, output } = ag.asSubGraph(() => {
return drywet(inputNode, noiseOscillator.node, wet);
});
onceEnded(noiseOscillator.node, () => {
releaseAudioNode(noiseOscillator.node);
});
return {
node: noiseMix.node,
node: output.node,
stop: (time) => noiseOscillator?.stop(time),
teardown: noiseMix.teardown,
teardown: subGraph.release,
};
}
+2 -2
View File
@@ -2,7 +2,7 @@ import reverbGen from './reverbGen.mjs';
import { clamp } from './util.mjs';
if (typeof AudioContext !== 'undefined') {
BaseAudioContext.prototype.adjustLength = function (duration, buffer, speed = 1, offsetAmount = 0) {
AudioContext.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;
};
BaseAudioContext.prototype.createReverb = function (duration, fade, lp, dim, ir, irspeed, irbegin) {
AudioContext.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;
+3 -9
View File
@@ -1,14 +1,8 @@
import { getAudioContext } from './audioContext.mjs';
import { onceEnded, releaseAudioNode } from './audioGraph.mjs';
import { getBaseURL, getCommonSampleInfo } from './util.mjs';
import { registerSound, registerWaveTable } from './index.mjs';
import { getAudioContext } from './audioContext.mjs';
import {
getADSRValues,
getParamADSR,
getPitchEnvelope,
getVibratoOscillator,
onceEnded,
releaseAudioNode,
} from './helpers.mjs';
import { getADSRValues, getParamADSR, getPitchEnvelope, getVibratoOscillator } from './helpers.mjs';
import { logger } from './logger.mjs';
const bufferCache = {}; // string: Promise<ArrayBuffer>
+12 -52
View File
@@ -9,44 +9,24 @@ import './reverb.mjs';
import './vowel.mjs';
import { nanFallback, _mod, cycleToSeconds, pickAndRename } from './util.mjs';
import workletsUrl from './worklets.mjs?audioworklet';
import {
createFilter,
gainNode,
getCompressor,
getDistortion,
getLfo,
getWorklet,
effectSend,
releaseAudioNode,
} from './helpers.mjs';
import { createFilter, gainNode, getCompressor, getDistortion, getLfo, getWorklet, effectSend } from './helpers.mjs';
import { map } from 'nanostores';
import { logger } from './logger.mjs';
import { loadBuffer } from './sampler.mjs';
import { getAudioContext, setAudioContext } from './audioContext.mjs';
import { getAudioContext } from './audioContext.mjs';
import { releaseAudioNode } from './audioGraph.mjs';
import { SuperdoughAudioController } from './superdoughoutput.mjs';
import { resetSeenKeys } from './wavetable.mjs';
export const DEFAULT_MAX_POLYPHONY = 128;
const DEFAULT_AUDIO_DEVICE_NAME = 'System Standard';
export let maxPolyphony = DEFAULT_MAX_POLYPHONY;
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;
}
export let multiChannelOrbits = false;
let multiChannelOrbits = false;
export function setMultiChannelOrbits(bool) {
multiChannelOrbits = bool == true;
}
@@ -64,17 +44,6 @@ 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;
}
@@ -235,13 +204,11 @@ export function registerWorklet(url) {
}
let workletsLoading;
export function loadWorklets() {
function loadWorklets() {
if (!workletsLoading) {
const audioCtx = getAudioContext();
const allWorkletURLs = externalWorklets.concat([workletsUrl]);
workletsLoading = Promise.all(allWorkletURLs.map((workletURL) => audioCtx.audioWorklet.addModule(workletURL))).then(
() => (workletsLoading = undefined),
);
workletsLoading = Promise.all(allWorkletURLs.map((workletURL) => audioCtx.audioWorklet.addModule(workletURL)));
}
return workletsLoading;
@@ -258,7 +225,6 @@ export async function initAudio(options = {}) {
setMaxPolyphony(maxPolyphony);
setMultiChannelOrbits(multiChannelOrbits);
resetSeenKeys();
if (typeof window === 'undefined') {
return;
}
@@ -280,9 +246,8 @@ export async function initAudio(options = {}) {
logger('[superdough] failed to set audio interface', 'warning');
}
}
if ((!audioCtx) instanceof OfflineAudioContext) {
await audioCtx.resume();
}
await audioCtx.resume();
if (disableWorklets) {
logger('[superdough]: AudioWorklets disabled with disableWorklets');
return;
@@ -316,12 +281,6 @@ 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);
@@ -359,7 +318,7 @@ export let analysers = {},
analysersData = {};
export function getAnalyserById(id, fftSize = 1024, smoothingTimeConstant = 0.5) {
if (!analysers[id] || analysers[id].audioContext != getAudioContext()) {
if (!analysers[id]) {
// make sure this doesn't happen too often as it piles up garbage
const analyserNode = getAudioContext().createAnalyser();
analyserNode.fftSize = fftSize;
@@ -421,6 +380,7 @@ 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)})`,
@@ -792,7 +752,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
}
// analyser
if (analyze && !(ac instanceof OfflineAudioContext)) {
if (analyze) {
const analyserNode = getAnalyserById(analyze, 2 ** (fft + 5));
const analyserSend = effectSend(post, analyserNode, 1);
audioNodes.push(analyserSend);
+16 -26
View File
@@ -1,6 +1,7 @@
import { clamp } from './util.mjs';
import { registerSound, soundMap } from './superdough.mjs';
import { getAudioContext } from './audioContext.mjs';
import { getAudioGraph, onceEnded, releaseAudioNode } from './audioGraph.mjs';
import {
applyFM,
gainNode,
@@ -12,8 +13,6 @@ import {
getVibratoOscillator,
getWorklet,
noises,
onceEnded,
releaseAudioNode,
webAudioTimeout,
} from './helpers.mjs';
import { logger } from './logger.mjs';
@@ -478,34 +477,25 @@ export function getOscillator(s, t, value, onended) {
}
// set frequency
o.frequency.value = getFrequencyFromValue(value);
let vibratoOscillator = getVibratoOscillator(o.detune, value, t);
// pitch envelope
getPitchEnvelope(o.detune, value, t, t + duration);
const fmModulator = applyFM(o.frequency, value, t);
let noiseMix;
if (noise) {
noiseMix = getNoiseMix(o, noise, t);
}
onceEnded(o, () => {
noiseMix?.teardown();
releaseAudioNode(o);
releaseAudioNode(noiseMix?.node);
onended();
const ag = getAudioGraph();
const { subGraph, output } = ag.asSubGraph(() => {
getVibratoOscillator(o.detune, value, t);
// pitch envelope
getPitchEnvelope(o.detune, value, t, t + duration);
applyFM(o.frequency, value, t);
let noiseMix;
if (noise) {
noiseMix = getNoiseMix(o, noise, t);
}
return { node: noiseMix?.node || o };
});
onceEnded(o, () => subGraph.release());
o.start(t);
return {
node: noiseMix?.node || o,
stop: (time) => {
fmModulator.stop(time);
vibratoOscillator?.stop(time);
noiseMix?.stop(time);
o.stop(time);
},
node: output.node,
stop: (time) => o.stop(time),
triggerRelease: (time) => {
// envGain?.stop(time);
},
+1 -1
View File
@@ -75,7 +75,7 @@ if (typeof GainNode !== 'undefined') {
}
}
BaseAudioContext.prototype.createVowelFilter = function (letter) {
AudioContext.prototype.createVowelFilter = function (letter) {
return new VowelNode(this, letter);
};
}
+1 -6
View File
@@ -1,3 +1,4 @@
import { releaseAudioNode } from './audioGraph.mjs';
import { getAudioContext, registerSound } from './index.mjs';
import { getBaseURL, getCommonSampleInfo } from './util.mjs';
import {
@@ -9,7 +10,6 @@ import {
getPitchEnvelope,
getVibratoOscillator,
getWorklet,
releaseAudioNode,
webAudioTimeout,
} from './helpers.mjs';
import { logger } from './logger.mjs';
@@ -40,11 +40,6 @@ 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)) {
+3 -3
View File
@@ -1,9 +1,9 @@
//import { ZZFX } from 'zzfx';
import { midiToFreq, noteToMidi } from './util.mjs';
import { registerSound } from './superdough.mjs';
import { getAudioContext } from './audioContext.mjs';
import { onceEnded, releaseAudioNode } from './audioGraph.mjs';
import { registerSound } from './superdough.mjs';
import { midiToFreq, noteToMidi } from './util.mjs';
import { buildSamples } from './zzfx_fork.mjs';
import { onceEnded, releaseAudioNode } from './helpers.mjs';
export const getZZFX = (value, t) => {
let {
+2 -173
View File
@@ -5,21 +5,10 @@ This program is free software: you can redistribute it and/or modify it under th
*/
import * as strudel from '@strudel/core';
import {
superdough,
getAudioContext,
setLogger,
doughTrigger,
registerWorklet,
setAudioContext,
initAudio,
setSuperdoughAudioController,
resetGlobalEffects,
errorLogger,
} from 'superdough';
import { superdough, getAudioContext, setLogger, doughTrigger, registerWorklet } from 'superdough';
import './supradough.mjs';
import { workletUrl } from 'supradough';
import { SuperdoughAudioController } from 'superdough/superdoughoutput.mjs';
registerWorklet(workletUrl);
const { Pattern, logger, repl } = strudel;
@@ -37,71 +26,6 @@ export const webaudioOutput = (hap, _deadline, hapDuration, cps, t) => {
return superdough(hap2value(hap), t, hapDuration, cps, hap.whole?.begin.valueOf());
};
export async function renderPatternAudio(
pattern,
cps,
begin,
end,
sampleRate,
maxPolyphony,
multiChannelOrbits,
downloadName = undefined,
) {
let audioContext = getAudioContext();
await audioContext.close();
audioContext = new OfflineAudioContext(2, ((end - begin) / cps) * sampleRate, sampleRate);
setAudioContext(audioContext);
setSuperdoughAudioController(new SuperdoughAudioController(audioContext));
await initAudio({
maxPolyphony,
multiChannelOrbits,
});
logger('[webaudio] preloading');
// Calling superdough(...) in ascending onset time order is important
// for controls that depend on the audio graph state like `cut`
let haps = pattern
.queryArc(begin, end, { _cps: cps })
.sort((a, b) => a.whole.begin.valueOf() - b.whole.begin.valueOf());
for (const hap of haps) {
if (hap.hasOnset()) {
try {
await superdough(
hap2value(hap),
(hap.whole.begin.valueOf() - begin) / cps,
hap.duration / cps,
cps,
(hap.whole?.begin.valueOf() - begin) / cps,
);
} catch (err) {
errorLogger(err, 'webaudio');
}
}
}
logger('[webaudio] start rendering');
return audioContext
.startRendering()
.then((renderedBuffer) => {
const wavBuffer = audioBufferToWav(renderedBuffer);
const blob = new Blob([wavBuffer], { type: 'audio/wav' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
downloadName = downloadName ? `${downloadName}.wav` : `${new Date().toISOString()}.wav`;
a.download = `${downloadName}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
})
.finally(async () => {
setAudioContext(null);
setSuperdoughAudioController(null);
resetGlobalEffects();
});
}
export function webaudioRepl(options = {}) {
options = {
getTime: () => getAudioContext().currentTime,
@@ -114,98 +38,3 @@ export function webaudioRepl(options = {}) {
Pattern.prototype.dough = function () {
return this.onTrigger(doughTrigger, 1);
};
function audioBufferToWav(buffer, opt) {
opt = opt || {};
var numChannels = buffer.numberOfChannels;
var sampleRate = buffer.sampleRate;
var format = opt.float32 ? 3 : 1;
var bitDepth = format === 3 ? 32 : 16;
var result;
if (numChannels === 2) {
result = interleave(buffer.getChannelData(0), buffer.getChannelData(1));
} else {
result = buffer.getChannelData(0);
}
return encodeWAV(result, format, sampleRate, numChannels, bitDepth);
}
function encodeWAV(samples, format, sampleRate, numChannels, bitDepth) {
var bytesPerSample = bitDepth / 8;
var blockAlign = numChannels * bytesPerSample;
var buffer = new ArrayBuffer(44 + samples.length * bytesPerSample);
var view = new DataView(buffer);
/* RIFF identifier */
writeString(view, 0, 'RIFF');
/* RIFF chunk length */
view.setUint32(4, 36 + samples.length * bytesPerSample, true);
/* RIFF type */
writeString(view, 8, 'WAVE');
/* format chunk identifier */
writeString(view, 12, 'fmt ');
/* format chunk length */
view.setUint32(16, 16, true);
/* sample format (raw) */
view.setUint16(20, format, true);
/* channel count */
view.setUint16(22, numChannels, true);
/* sample rate */
view.setUint32(24, sampleRate, true);
/* byte rate (sample rate * block align) */
view.setUint32(28, sampleRate * blockAlign, true);
/* block align (channel count * bytes per sample) */
view.setUint16(32, blockAlign, true);
/* bits per sample */
view.setUint16(34, bitDepth, true);
/* data chunk identifier */
writeString(view, 36, 'data');
/* data chunk length */
view.setUint32(40, samples.length * bytesPerSample, true);
if (format === 1) {
// Raw PCM
floatTo16BitPCM(view, 44, samples);
} else {
writeFloat32(view, 44, samples);
}
return buffer;
}
function interleave(inputL, inputR) {
var length = inputL.length + inputR.length;
var result = new Float32Array(length);
var index = 0;
var inputIndex = 0;
while (index < length) {
result[index++] = inputL[inputIndex];
result[index++] = inputR[inputIndex];
inputIndex++;
}
return result;
}
function writeFloat32(output, offset, input) {
for (var i = 0; i < input.length; i++, offset += 4) {
output.setFloat32(offset, input[i], true);
}
}
function floatTo16BitPCM(output, offset, input) {
for (var i = 0; i < input.length; i++, offset += 2) {
var s = Math.max(-1, Math.min(1, input[i]));
output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
}
}
function writeString(view, offset, string) {
for (var i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}
+1 -18
View File
@@ -804,9 +804,6 @@ importers:
astro:
specifier: ^5.1.9
version: 5.1.9(@types/node@22.10.10)(jiti@2.4.2)(lightningcss@1.29.1)(rollup@2.79.2)(terser@5.37.0)(typescript@5.7.3)(yaml@2.7.0)
base64url-universal:
specifier: ^2.0.0
version: 2.0.0
claviature:
specifier: ^0.1.0
version: 0.1.0
@@ -3300,14 +3297,6 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
base64url-universal@2.0.0:
resolution: {integrity: sha512-6Hpg7EBf3t148C3+fMzjf+CHnADVDafWzlJUXAqqqbm4MKNXbsoPdOkWeRTjNlkYG7TpyjIpRO1Gk0SnsFD1rw==}
engines: {node: '>=14'}
base64url@3.0.1:
resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==}
engines: {node: '>=6.0.0'}
before-after-hook@2.2.3:
resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==}
@@ -11019,12 +11008,6 @@ snapshots:
base64-js@1.5.1: {}
base64url-universal@2.0.0:
dependencies:
base64url: 3.0.1
base64url@3.0.1: {}
before-after-hook@2.2.3: {}
bin-links@4.0.4:
@@ -12995,7 +12978,7 @@ snapshots:
jake@10.9.2:
dependencies:
async: 3.2.6
chalk: 4.1.2
chalk: 4.1.0
filelist: 1.0.4
minimatch: 3.1.2
-262
View File
@@ -2539,84 +2539,6 @@ 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 ]",
@@ -7991,51 +7913,6 @@ 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 ]",
@@ -10413,18 +10290,6 @@ 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 ]",
@@ -10608,64 +10473,6 @@ 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 ]",
@@ -13011,75 +12818,6 @@ exports[`runs examples > example "unit" example index 0 1`] = `
]
`;
exports[`runs examples > example "useRNG" example index 0 1`] = `
[
"[ 0/1 → 1/16 | note:D8 ]",
"[ 1/16 → 1/8 | note:Bb7 ]",
"[ 1/8 → 3/16 | note:Ab6 ]",
"[ 3/16 → 1/4 | note:D5 ]",
"[ 1/4 → 5/16 | note:Ab9 ]",
"[ 5/16 → 3/8 | note:D3 ]",
"[ 3/8 → 7/16 | note:G5 ]",
"[ 7/16 → 1/2 | note:G3 ]",
"[ 1/2 → 9/16 | note:Ab3 ]",
"[ 9/16 → 5/8 | note:Eb6 ]",
"[ 5/8 → 11/16 | note:Eb6 ]",
"[ 11/16 → 3/4 | note:Eb5 ]",
"[ 3/4 → 13/16 | note:G7 ]",
"[ 13/16 → 7/8 | note:Bb5 ]",
"[ 7/8 → 15/16 | note:D3 ]",
"[ 15/16 → 1/1 | note:Eb7 ]",
"[ 1/1 → 17/16 | note:Eb9 ]",
"[ 17/16 → 9/8 | note:G8 ]",
"[ 9/8 → 19/16 | note:Ab3 ]",
"[ 19/16 → 5/4 | note:Ab5 ]",
"[ 5/4 → 21/16 | note:C10 ]",
"[ 21/16 → 11/8 | note:C7 ]",
"[ 11/8 → 23/16 | note:G5 ]",
"[ 23/16 → 3/2 | note:Ab7 ]",
"[ 3/2 → 25/16 | note:G6 ]",
"[ 25/16 → 13/8 | note:Bb6 ]",
"[ 13/8 → 27/16 | note:Eb9 ]",
"[ 27/16 → 7/4 | note:G9 ]",
"[ 7/4 → 29/16 | note:G7 ]",
"[ 29/16 → 15/8 | note:C10 ]",
"[ 15/8 → 31/16 | note:Eb3 ]",
"[ 31/16 → 2/1 | note:Ab7 ]",
"[ 2/1 → 33/16 | note:F6 ]",
"[ 33/16 → 17/8 | note:C5 ]",
"[ 17/8 → 35/16 | note:Ab4 ]",
"[ 35/16 → 9/4 | note:G5 ]",
"[ 9/4 → 37/16 | note:C9 ]",
"[ 37/16 → 19/8 | note:Eb6 ]",
"[ 19/8 → 39/16 | note:C9 ]",
"[ 39/16 → 5/2 | note:Eb9 ]",
"[ 5/2 → 41/16 | note:D5 ]",
"[ 41/16 → 21/8 | note:F5 ]",
"[ 21/8 → 43/16 | note:F9 ]",
"[ 43/16 → 11/4 | note:Bb7 ]",
"[ 11/4 → 45/16 | note:Ab6 ]",
"[ 45/16 → 23/8 | note:Bb9 ]",
"[ 23/8 → 47/16 | note:C8 ]",
"[ 47/16 → 3/1 | note:Eb5 ]",
"[ 3/1 → 49/16 | note:F3 ]",
"[ 49/16 → 25/8 | note:G4 ]",
"[ 25/8 → 51/16 | note:D7 ]",
"[ 51/16 → 13/4 | note:D4 ]",
"[ 13/4 → 53/16 | note:F8 ]",
"[ 53/16 → 27/8 | note:C7 ]",
"[ 27/8 → 55/16 | note:Ab5 ]",
"[ 55/16 → 7/2 | note:Ab3 ]",
"[ 7/2 → 57/16 | note:F9 ]",
"[ 57/16 → 29/8 | note:D8 ]",
"[ 29/8 → 59/16 | note:F5 ]",
"[ 59/16 → 15/4 | note:Eb9 ]",
"[ 15/4 → 61/16 | note:Bb4 ]",
"[ 61/16 → 31/8 | note:C6 ]",
"[ 31/8 → 63/16 | note:C4 ]",
"[ 63/16 → 4/1 | note:G8 ]",
]
`;
exports[`runs examples > example "velocity" example index 0 1`] = `
[
"[ 0/1 → 1/8 | s:hh gain:0.4 velocity:0.4 ]",
-1
View File
@@ -16,6 +16,5 @@ export default defineConfig({
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress}.config.*',
'**/shared.test.mjs',
],
setupFiles: './vitest.setup.mjs',
},
});
-7
View File
@@ -1,7 +0,0 @@
import { afterEach } from 'vitest';
import { useRNG } from './packages/core/signal.mjs';
afterEach(() => {
// Avoid bleed between tests
useRNG('legacy');
});
-1
View File
@@ -53,7 +53,6 @@
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"astro": "^5.1.9",
"base64url-universal": "^2.0.0",
"claviature": "^0.1.0",
"date-fns": "^4.1.0",
"hs2js": "0.1.0",
@@ -142,8 +142,6 @@ The "~" represents a rest, and will create silence between other events:
<MiniRepl client:idle tune={`note("[b4 [~ c5] d5 e5]")`} punchcard />
Alternatively, "-" can be used instead of "~". It means the same thing.
## Parallel / polyphony
Using commas, we can play chords.
-651
View File
@@ -1,651 +0,0 @@
/*
audiograph.mjs - show a svg view of the web audio API graph built during a playback
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/website/src/repl/audiograph.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// main entry point is `debugAudiograph`
import { logger } from '@strudel/core';
import { getAudioContext, getSuperdoughAudioController, webaudioOutput } from '@strudel/webaudio';
let mermaid = null;
let svgPanZoom = null;
let running = false;
let hap_count = 0;
let cache = new Map();
const initCache = JSON.stringify({
connect: [],
where: [],
disconnectAll: 0,
disconnectOne: 0,
hasStop: false,
stopCount: 0,
ac: null,
creation: null,
});
let toggleOrig;
function stackTrace() {
var err = new Error();
const stacktrace = err.stack;
const lines = stacktrace.split('\n');
let lineIndex = lines.findIndex((line) => line !== 'Error' && !line.includes('audiograph.mjs'));
if (lines[lineIndex].includes('gainNode')) lineIndex++;
if (lines[lineIndex].includes('getWorklet')) lineIndex++;
const line = lines[lineIndex].replace(/\s*at\s/, '').replace('http', '@');
let match;
match = line.match(/([^@]*)@.*packages(\/[^:]+:\d+:\d+)/);
if (match) {
return match[1].replace(/[^.:/a-zA-Z0-9]/g, '') + '@' + match[2].replace(/[^.:/a-zA-Z0-9]/g, '');
}
return '@';
}
// This captures all AudioNodes lazily
// when an `.audioid` property is called
// no solution was found to hook
// AudioNode's constructor directly
let audioid = 0;
const lazyRegister = (o) => {
Object.defineProperty(o.prototype, 'audioid', {
get: function () {
if (!this._audioid) {
this._audioid = ++audioid;
const s = JSON.parse(initCache);
s.type = this.constructor.name === 'AudiographNode' ? this.constructor._parentClassName : this.constructor.name;
// special case for subclassed AudioNodes
// they are implemented in superdough but hard to get a reference on here
// they are not AudioScheduledSourceNodes anyway
if (['FeedbackDelayNode', 'VowelNode'].indexOf(s.type) === -1) {
s.hasStop = window[s.type].prototype instanceof AudioScheduledSourceNode;
}
s.ac = this.context?.constructor.name || 'AudioParam';
s.creation = s.creation || stackTrace();
cache.set(this._audioid, s);
}
return this._audioid;
},
enumerable: false,
configurable: true,
});
};
// extend a specific AudioNode's constructor
// necessary when creation is done direclty by
// calling the constructor
// eg: new GainNode(...)
const audioNodeHook = (node) => {
const name = node.prototype.constructor.name;
const PatchedNode = class AudiographNode extends node {
constructor(...args) {
super(...args);
// trigger the lazy register
this._audioid = this.audioid;
}
};
PatchedNode._parentClassName = name;
window[name] = PatchedNode;
};
const drawMessage = async function (message) {
const element = document.querySelector('.strudel-mermaid');
let gd = '';
gd += '---\n';
gd += 'config:\n';
gd += ' flowchart:\n';
gd += ' wrappingWidth: 600\n';
gd += '---\n';
gd += 'flowchart LR\n';
gd += 'id[' + message.replaceAll(' ', '&nbsp;') + ']\n';
let { svg } = await mermaid.render('strudelSvgId', gd);
svg = svg.replace(/max-width:\s[0-9.]*px;/i, 'height: 100%');
svg = svg.replaceAll('&amp;nbsp;', ' ');
element.innerHTML = svg;
};
const drawDiagram = async function () {
const element = document.querySelector('.strudel-mermaid');
let code = window.strudelMirror.code;
code = code.replace(/^await debugAudiograph.*\n?/gm, '');
code = '// date: ' + new Date().toISOString() + '\n\n' + code;
code = '// host: ' + document.location.hostname + '\n' + code;
const codeLines = code.split(/(?:\n|\r\n?)/);
const maxLineLength = codeLines.reduce((memo, line) => Math.max(memo, line.length), 0);
// https://mermaid.js.org/syntax/flowchart.html
let gd = '';
gd += '---\n';
gd += 'config:\n';
gd += ' flowchart:\n';
gd += ' wrappingWidth: ' + 14 * maxLineLength + '\n';
gd += '---\n';
gd += 'flowchart TB\n';
gd += '\tsubgraph AG[STRUDEL AUDIOGRAPH]\n';
// seed graph builder with all
// unconnected nodes
let lookup = [];
cache.forEach((v, k) => {
if (v.connect.length === 0) lookup.push(k);
});
const relations = [];
let curRelations;
const zombieCount = 0;
const sourceLoc = (stack) => {
if (stack === '@') return stack;
return stack.replace('@', '\n').replace('/superdough/', '/');
};
const label = (s) => {
const source = s.creation ? '\n' + sourceLoc(s.creation) : '';
let lb = '[' + '**' + s.type + '**' + source + ']';
if (s.ac === 'OfflineAudioContext') lb = '[' + lb + ']';
return lb;
};
const isConnectLeak = (s) => {
return (
['AudioDestinationNode', 'AudioParam'].indexOf(s.type) === -1 &&
s.disconnectAll === 0 &&
s.connect.length > s.disconnectOne
);
};
const isStopLeak = (s) => {
return s.hasStop && s.stopCount === 0;
};
do {
curRelations = relations.length;
lookup.slice().forEach((n) => {
cache.forEach((v, k) => {
if (v.connect.indexOf(n) !== -1) {
if (lookup.indexOf(k) === -1) lookup.push(k);
gd += v.connect
.map((i) => {
if (lookup.indexOf(i) === -1) lookup.push(i);
if (relations.indexOf(k + '-' + i) === -1) {
relations.push(k + '-' + i);
return (
'\t\tnode' +
k +
label(v) +
' -- ' +
sourceLoc(v.where[0]) +
' --> node' +
i +
label(cache.get(i)) +
'\n'
);
}
})
.join('');
}
if (k === n) {
gd += v.connect
.map((i) => {
if (lookup.indexOf(i) === -1) lookup.push(i);
if (relations.indexOf(k + '-' + i) === -1) {
relations.push(k + '-' + i);
return (
'\t\tnode' +
k +
label(v) +
' -- ' +
sourceLoc(v.where[0]) +
' --> node' +
i +
label(cache.get(i)) +
'\n'
);
}
})
.join('');
}
});
});
} while (relations.length > curRelations /*&& lookup.length < 100*/);
// add orphan nodes
const inRelation = '-' + relations.join('-') + '-';
cache.forEach((v, k) => {
if (!inRelation.includes('-' + k + '-')) {
gd += '\t\tnode' + k + label(v) + '\n';
}
});
const codePlaceholder = 'm'.repeat(maxLineLength);
gd += '\tsubgraph LEGEND\n';
gd += '\t\tlegend1[in AudioContext]\n';
gd += '\t\tlegend2[[in OfflineAudioContext]]\n';
gd += '\t\tlegend3[not disconnected]\n';
gd += '\t\tlegend4[AudioParam]\n';
gd += '\t\tlegend5[AudioDestinationNode]\n';
gd += '\t\tlegend6[not stopped]\n';
gd += '\tend\n';
gd += '\tsubgraph CODE[Strudel Code]\n';
// we use a codePlaceholder to
// - avoid problems with special chars
// - stop mermaid to split lines on space with multiple tspans
// - force mermaid to prepare a sufficiently sized zone
gd += '\ncode[' + (codePlaceholder + '<br>').repeat(codeLines.length) + ']\n';
gd += '\tend\n';
gd += '\tend\n';
gd += '\tclassDef audioparam fill:#6f6;\n';
gd += '\tclassDef destination fill:#99f;\n';
gd += '\tclassDef connectleak fill:#f96,stroke:#f00,stroke-width:2px;\n';
gd += '\tclassDef stopleak fill:#f55,stroke:#f00,stroke-width:2px;\n';
gd += '\tclass legend3 connectleak;\n';
gd += '\tclass legend4 audioparam;\n';
gd += '\tclass legend5 destination;\n';
gd += '\tclass legend6 stopleak;\n';
cache.forEach((v, k) => {
if (isConnectLeak(v)) {
gd += '\tclass node' + k + ' connectleak;\n';
} else if (isStopLeak(v)) {
gd += '\tclass node' + k + ' stopleak;\n';
}
if (v.type === 'AudioParam') {
gd += '\tclass node' + k + ' audioparam;\n';
}
if (v.type === 'AudioDestinationNode') {
gd += '\tclass node' + k + ' destination;\n';
}
});
let { svg } = await mermaid.render('strudelSvgId', gd);
// put real code in code zone
let idx = 0;
const escapeHtml = (unsafe) => {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
svg = svg.replaceAll(codePlaceholder, () => escapeHtml(codeLines[idx++]));
// improve sizing on web page
svg = svg.replace(/max-width:\s[0-9.]*px;/i, 'height: 100%');
element.innerHTML = svg;
// align the code lines
let svgText = document.querySelector('[id^=flowchart-code] text');
svgText.setAttributeNS(null, 'style', 'text-anchor: start;');
const svgElement = document.querySelector('svg');
const svgLabel = document.querySelector('svg [id^=flowchart-code] .label');
const transformList = svgLabel.transform.baseVal;
const svgTransform = svgElement.createSVGTransform();
const tspans = Array.from(document.querySelectorAll('[id^=flowchart-code] tspan.text-inner-tspan'));
let tspansMaxLength = tspans.reduce((memo, tspan) => Math.max(memo, tspan.getComputedTextLength()), 0);
svgTransform.setTranslate(-tspansMaxLength / 2, 0);
transformList.appendItem(svgTransform);
let doPan = false;
let eventsHandler;
let panZoom;
let mousepos;
eventsHandler = {
haltEventListeners: ['mousedown', 'mousemove', 'mouseup'],
mouseDownHandler: function (ev) {
if (event.target.className == '[object SVGAnimatedString]') {
doPan = true;
mousepos = {
x: ev.clientX,
y: ev.clientY,
};
}
},
mouseMoveHandler: function (ev) {
if (doPan) {
panZoom.panBy({
x: ev.clientX - mousepos.x,
y: ev.clientY - mousepos.y,
});
mousepos = {
x: ev.clientX,
y: ev.clientY,
};
window.getSelection().removeAllRanges();
}
},
mouseUpHandler: function (ev) {
doPan = false;
},
init: function (options) {
options.svgElement.addEventListener('mousedown', this.mouseDownHandler, false);
options.svgElement.addEventListener('mousemove', this.mouseMoveHandler, false);
options.svgElement.addEventListener('mouseup', this.mouseUpHandler, false);
},
destroy: function (options) {
options.svgElement.removeEventListener('mousedown', this.mouseDownHandler, false);
options.svgElement.removeEventListener('mousemove', this.mouseMoveHandler, false);
options.svgElement.removeEventListener('mouseup', this.mouseUpHandler, false);
},
};
panZoom = svgPanZoom('#strudelSvgId', {
zoomEnabled: true,
controlIconsEnabled: true,
fit: 1,
center: 1,
zoomScaleSensitivity: 0.4,
customEventsHandler: eventsHandler,
});
};
const svgExport = async () => {
const a = document.createElement('a');
document.body.appendChild(a);
a.style = 'display: none';
const selector = '.strudel-mermaid';
const bbox = document.querySelector('svg g').getBBox();
let transform, style;
// clean pan-zoom viewport
const pzViewport = document.querySelector('.svg-pan-zoom_viewport');
if (pzViewport) {
transform = pzViewport.transform;
style = pzViewport.style;
pzViewport.setAttribute('transform', '');
pzViewport.style = '';
}
const spzMin = await fetch('https://cdn.jsdelivr.net/npm/svg-pan-zoom@3.6.2/dist/svg-pan-zoom.min.js').then((res) =>
res.text(),
);
const scriptContent = '<![CDATA[' + spzMin + ';svgPanZoom("svg");]]>';
// prepare svg
const content = document
.querySelector(selector)
.innerHTML.replaceAll('<br>', '<br/>')
// remove useless tags
.replace(/<g id="svg-pan-zoom.*<\/g>/, '<script>' + scriptContent + '</script>')
.replace(/<defs>.*<\/defs>/, '')
// give inkscape true sizes
.replace('width="100%"', 'width="' + bbox.width + '" height="' + bbox.height + '"');
// restore pan-zoom viewport
if (pzViewport) {
pzViewport.setAttribute('transform', transform);
pzViewport.style = style;
}
// trigger download
var blob = new Blob([content], { type: 'image/svg+xml' }),
url = window.URL.createObjectURL(blob);
a.href = url;
a.download = 'audiograph.svg';
a.click();
window.URL.revokeObjectURL(url);
};
const resetAudioOutput = function (audioid) {
// calling reset on SuperdoughAudioController
// will discard output nodes AND recreate them
// so we keep the same `cache` to handle the
// `disconnects` knowing that new nodes will be
// stricly after the current audioid.
// then we purge the old nodes from the `cache`
// to have a clean state
// make sure destination will be recreated in the
// cache
const destination = getAudioContext().destination;
if (destination._audioid) delete destination._audioid;
const sac = getSuperdoughAudioController();
sac.reset();
Array.from(cache.keys()).map((k) => {
if (k <= audioid) cache.delete(k);
});
};
const postProcessing = async function () {
hap_count = 0;
await drawDiagram();
resetAudioOutput(audioid);
};
const defaultOptions = {
StopAfterHapCount: 10,
hapsBatch: 0,
maxEdges: 10000,
maxTextSize: 200000,
audioAPIBreathingRoomSec: 5,
};
// `StopAfterHapCount` :
// The player will auto-stop after hap count have
// been played. when StopAfterHapCount = 0, it will
// continue playing until 'stop' is clicked
// `audioAPIBreathingRoomSec` :
// how much time should we wait after 'stop' to let
// the audioAPI finish its tail of ondended calls
// `hapsBatch` :
// the AudioGraph will be displayed every hapsBatch haps
// when hapsBatch = 0, AudioGraph will only be displayed
// after and auto-stop or after 'stop' is clicked
// In hapsBatch mode you will probably see a trailing of
// non disconnected notes on the graph because the audio
// API may have some lag disconnecting them
// cf also audioAPIBreathingRoomSec
// `maxEdges`
// This is a mermaid.js config that forces a hard limit
// on the maximum number of Edges of a graph
// needs a reload to be taken into account
// `maxTextSize`
// This is a mermaid.js config that forces a hard limit
// on the maximum text size of a graph definition
// needs a reload to be taken into account
export const debugAudiograph = async (argOptions = {}) => {
const options = Object.assign({}, defaultOptions, argOptions);
const { StopAfterHapCount, hapsBatch, maxEdges, maxTextSize, audioAPIBreathingRoomSec } = options;
const sm = window.strudelMirror;
const code = sm.code;
if (!code.match(/await\s+debugAudiograph/)) {
throw new Error('you need to call `await debugAudiograph()` for audiograph to work');
}
const emptyOptions = /await\s+debugAudiograph\(\)/.exec(code);
if (emptyOptions) {
const cutCode = emptyOptions.index + emptyOptions[0].length - 1;
const codeOptions = JSON.stringify({ StopAfterHapCount: StopAfterHapCount }).replaceAll('"', '');
sm.setCode(code.slice(0, cutCode) + codeOptions + code.slice(cutCode));
}
if (window.audiograph === undefined) {
const ag = (window.audiograph = {});
toggleOrig = sm.toggle;
////////////////////////////////////////
// step 1: web audio api instrumentation
////////////////////////////////////////
// path AudioNode & AudioParam
// to give them lazy ids
// this captures both `ac.createGain`
// and `new GainNode(..)` patterns
lazyRegister(AudioNode);
lazyRegister(AudioParam);
lazyRegister(PeriodicWave);
const audioNodes = [
AudioBufferSourceNode,
AudioWorkletNode,
AnalyserNode,
BiquadFilterNode,
ChannelMergerNode,
ChannelSplitterNode,
ConstantSourceNode,
ConvolverNode,
DelayNode,
DynamicsCompressorNode,
GainNode,
IIRFilterNode,
OscillatorNode,
PannerNode,
StereoPannerNode,
WaveShaperNode,
];
audioNodes.map((n) => {
if (n.prototype instanceof AudioScheduledSourceNode) {
const stopOrig = n.prototype.stop;
n.prototype.stop = function (...args) {
// stop called
const result = stopOrig.call(this, ...args);
const s = cache.get(this.audioid);
s.stopCount++;
return result;
};
}
audioNodeHook(n);
});
// patch BaseAudioContext factory methods
// to capture the source reference
Object.getOwnPropertyNames(BaseAudioContext.prototype)
.filter((n) => n.startsWith('create') && ['createBuffer'].indexOf(n) === -1)
.map((name) => {
const orig = BaseAudioContext.prototype[name];
BaseAudioContext.prototype[name] = function (...args) {
const result = orig.call(this, ...args);
const s = cache.get(result.audioid);
s.creation = stackTrace();
return result;
};
});
const connectOrig = AudioNode.prototype.connect;
AudioNode.prototype.connect = function (destination, ...args) {
const result = connectOrig.call(this, destination, ...args);
const s = cache.get(this.audioid);
s.connect.push(destination.audioid);
s.where.push(stackTrace());
return result;
};
const disconnectOrig = AudioNode.prototype.disconnect;
AudioNode.prototype.disconnect = function (destination, ...args) {
const result = disconnectOrig.call(this, destination, ...args);
const s = cache.get(this.audioid);
if (s.connect.length) {
if (destination) {
s.disconnectOne++;
} else {
s.disconnectAll++;
}
} else {
logger('WEIRD: node ' + this.audioid + 'called disconnect before any call to connect !');
//logger(new Error().stack);
console.log(cache);
}
return result;
};
// call reset 2 times to handle reload + 'play'
// the first reset's disconnect adds audioid tags on previous outputs
// that were not tagged (wrong cutoff)
resetAudioOutput(audioid);
// the second reset has the correct audioid cutoff
resetAudioOutput(audioid);
////////////////////////////////////////
// step 2: Load external modules
////////////////////////////////////////
const { default: mermaidModule } = await import(
'https://cdn.jsdelivr.net/npm/mermaid@11.12.1/dist/mermaid.esm.mjs'
);
mermaid = mermaidModule;
mermaid.initialize({
startOnLoad: false,
themeCSS: '.flowchart { height: 100%; }',
maxEdges: maxEdges,
maxTextSize: maxTextSize,
htmlLabels: false,
flowchart: {
htmlLabels: false,
},
});
const { default: svgPanZoomModule } = await import('https://esm.sh/svg-pan-zoom');
svgPanZoom = svgPanZoomModule;
//////////////////////////////////////////
// step 3: UI modifications
//////////////////////////////////////////
// add audiograph panel
if (!document.querySelector('.strudel-mermaid')) {
const mermaidDiv = document.createElement('div');
mermaidDiv.className = 'strudel-mermaid';
mermaidDiv.style = 'min-height: 600px; width: 60%';
const referenceNode = document.querySelector('#code');
referenceNode.parentNode.insertBefore(mermaidDiv, referenceNode.nextSibling);
}
// add svg export button
if (!document.querySelector('button[title=svg]')) {
const exportButton = document.createElement('button');
exportButton.innerHTML = '<span>ExportDiagram</span>';
exportButton.title = 'svg';
exportButton.onclick = svgExport;
const updateButton = document.querySelector('button[title=update]');
updateButton.parentNode.insertBefore(exportButton, updateButton);
}
}
if (!running) {
running = true;
}
if (hapsBatch === 0 || hap_count < hapsBatch) {
let msg = '';
msg += 'Recording activity...';
msg += '\npress stop to build diagram';
if (StopAfterHapCount) {
msg += '\nwill stop automatically in ' + Math.max(StopAfterHapCount - hap_count, 0) + ' haps';
}
await drawMessage(msg);
}
sm.toggle = async () => {
running = false;
sm.toggle = toggleOrig;
// schedule `toggle` on the js main loop
// to avoid interfering with any on-flight onTick
// not doing this can lead to a phase > 0 which will
// break the next start
setTimeout(sm.toggle.bind(sm), 0);
await drawMessage('please wait ' + audioAPIBreathingRoomSec + ' seconds\n' + 'the audio API is finishing its work');
setTimeout(postProcessing, audioAPIBreathingRoomSec * 1000);
};
/*global all*/
all((pat) =>
pat.onTrigger(async (hap, duration, cps, t) => {
hap_count++;
const key = Object.entries(hap.value)
.map((param) => param.join('/'))
.join('/');
// if we reached StopAfterHapCount, click 'stop'
if (StopAfterHapCount && hap_count > StopAfterHapCount) {
if (running) {
await sm.toggle();
}
// stop sending haps to superdough(...)
return;
}
await webaudioOutput(hap, t, hap.duration / cps, cps, t);
if (hapsBatch && hap_count % hapsBatch === 0) drawDiagram();
}),
);
};
@@ -1,197 +0,0 @@
import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon';
import cx from '@src/cx.mjs';
import NumberInput from '@src/repl/components/NumberInput';
import { useEffect, useState } from 'react';
import { Textbox } from '../textbox/Textbox';
import { getAudioContext } from '@strudel/webaudio';
import XMarkIcon from '@heroicons/react/24/outline/XMarkIcon';
function Checkbox({ label, value, onChange, disabled = false }) {
return (
<label className={cx(disabled && 'opacity-50')}>
<input disabled={disabled} type="checkbox" checked={value} onChange={onChange} />
{' ' + label}
</label>
);
}
function FormItem({ label, children, disabled }) {
return (
<div className="grid gap-2 w-full">
<label className={cx(disabled && 'opacity-50')}>{label}</label>
{children}
</div>
);
}
export default function ExportTab(Props) {
const { handleExport } = Props;
const [downloadName, setDownloadName] = useState('');
const [startCycle, setStartCycle] = useState(0);
const [endCycle, setEndCycle] = useState(1);
const [sampleRate, setSampleRate] = useState(48000);
const [multiChannelOrbits, setMultiChannelOrbits] = useState(true);
const [maxPolyphony, setMaxPolyphony] = useState(1024);
const [exporting, setExporting] = useState(false);
const [progress, setProgress] = useState(0);
const [length, setLength] = useState(1);
const refreshProgress = () => {
const audioContext = getAudioContext();
if (audioContext instanceof OfflineAudioContext) {
setProgress(audioContext.currentTime);
setLength(audioContext.length / sampleRate);
setTimeout(refreshProgress, 100);
}
};
return (
<>
<div className="text-foreground w-full p-4 space-y-4">
<FormItem label="File name" disabled={exporting}>
<Textbox
onBlur={(e) => {
setDownloadName(e.target.value);
}}
onChange={(v) => {
setDownloadName(v);
}}
disabled={exporting}
placeholder="Leave empty to use current date"
className={cx('placeholder:opacity-50', exporting && 'opacity-50 border-opacity-50')}
value={downloadName ?? ''}
/>
</FormItem>
<div className="flex flex-row gap-4 w-full">
<FormItem label="Start cycle" disabled={exporting}>
<Textbox
min={1}
max={Infinity}
onBlur={(e) => {
let v = parseInt(e.target.value);
v = isNaN(v) ? 0 : Math.max(0, v);
setStartCycle(v);
}}
onChange={(v) => {
v = parseInt(v);
setStartCycle(v);
}}
type="number"
placeholder=""
disabled={exporting}
className={cx(exporting && 'opacity-50 border-opacity-50', 'w-full')}
value={startCycle ?? ''}
/>
</FormItem>
<FormItem label="End cycle" disabled={exporting}>
<Textbox
min={1}
max={Infinity}
onBlur={(e) => {
let v = parseInt(e.target.value);
v = isNaN(v) ? Math.max(startCycle + 1, parseInt(v)) : v;
setEndCycle(v);
}}
onChange={(v) => {
v = parseInt(v);
setEndCycle(v);
}}
type="number"
placeholder=""
disabled={exporting}
className={cx(exporting && 'opacity-50 border-opacity-50', 'w-full')}
value={endCycle ?? ''}
/>
</FormItem>
</div>
<div className="flex flex-row gap-4">
<FormItem label="Sample rate" disabled={exporting}>
<Textbox
min={1}
max={Infinity}
onBlur={(e) => {
let v = parseInt(e.target.value);
v = isNaN(v) ? 1 : Math.max(1, v);
setSampleRate(v);
}}
onChange={(v) => {
v = parseInt(v);
setSampleRate(v);
}}
type="number"
placeholder=""
disabled={exporting}
className={cx(exporting && 'opacity-50 border-opacity-50')}
value={sampleRate ?? ''}
/>
</FormItem>
<FormItem label="Maximum polyphony" disabled={exporting}>
<Textbox
min={1}
max={Infinity}
onBlur={(e) => {
let v = parseInt(e.target.value);
v = isNaN(v) ? Math.max(1, parseInt(v)) : v;
setMaxPolyphony(v);
}}
onChange={(v) => {
v = Math.max(1, parseInt(v));
setMaxPolyphony(v);
}}
type="number"
placeholder=""
disabled={exporting}
className={cx(exporting && 'opacity-50 border-opacity-50')}
value={maxPolyphony ?? ''}
/>
</FormItem>
</div>
<div>
<Checkbox
label="Multi Channel Orbits"
onChange={(cbEvent) => {
const val = cbEvent.target.checked;
setMultiChannelOrbits(val);
}}
disabled={exporting}
value={multiChannelOrbits}
/>
</div>
<button
className={cx('bg-background p-2 w-full rounded-md hover:opacity-75 relative', exporting && 'opacity-50')}
disabled={exporting}
onClick={async () => {
setExporting(true);
setTimeout(refreshProgress, 2000);
const modal = document.getElementById('exportProgressModal');
modal.showModal();
await handleExport(startCycle, endCycle, sampleRate, maxPolyphony, multiChannelOrbits, downloadName)
.then(() => {
const modal = document.getElementById('exportProgressModal');
modal.close();
})
.finally(() => {
setExporting(false);
setProgress(0);
setLength(1);
});
}}
>
<div
className="absolute top-0 left-0 right-0 bottom-0 backdrop-invert"
style={{
width: `${(exporting ? 1 : 0) + (progress / length) * 99}%`,
}}
/>
<span className="text-foreground">{exporting ? 'Exporting...' : 'Export to WAV'}</span>
</button>
</div>
<dialog
closedby={exporting ? 'none' : 'closerequest'}
id="exportProgressModal"
className="text-md bg-background text-foreground rounded-lg backdrop:bg-background backdrop:opacity-25"
/>
</>
);
}
@@ -9,7 +9,6 @@ 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__;
@@ -81,7 +80,6 @@ const tabNames = {
patterns: 'patterns',
sounds: 'sounds',
reference: 'reference',
export: 'export',
console: 'console',
settings: 'settings',
};
@@ -128,8 +126,6 @@ function PanelContent({ context, tab }) {
return <SoundsTab />;
case tabNames.reference:
return <Reference />;
case tabNames.export:
return <ExportTab handleExport={context.handleExport} />;
case tabNames.settings:
return <SettingsTab started={context.started} />;
case tabNames.files:
-19
View File
@@ -393,8 +393,6 @@ samples({
bass: { d2: 'https://cdn.freesound.org/previews/608/608286_13074022-lq.mp3' }
})
useRNG('legacy')
stack(
// bells
n("0").euclidLegato(3,8)
@@ -432,7 +430,6 @@ export const festivalOfFingers3 = `// "Festival of fingers 3"
// @by Felix Roos
setcps(1)
useRNG('legacy')
n("[-7*3],0,2,6,[8 7]")
.echoWith(
@@ -457,8 +454,6 @@ export const meltingsubmarine = `// "Melting submarine"
// @by Felix Roos
samples('github:tidalcycles/dirt-samples')
useRNG('legacy')
stack(
s("bd:5,[~ <sd:1!3 sd:1(3,4,3)>],hh27(3,4,1)") // drums
.speed(perlin.range(.7,.9)) // random sample speed variation
@@ -607,9 +602,6 @@ 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)))
@@ -646,7 +638,6 @@ 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'}})
@@ -675,8 +666,6 @@ 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")
@@ -695,8 +684,6 @@ 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),
@@ -712,8 +699,6 @@ 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'),
@@ -742,8 +727,6 @@ export const amensister = `// "Amensister"
samples('github:tidalcycles/dirt-samples')
useRNG('legacy')
stack(
// amen
n("0 1 2 3 4 5 6 7")
@@ -851,8 +834,6 @@ 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)
+2 -48
View File
@@ -9,13 +9,11 @@ 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';
@@ -38,8 +36,6 @@ import { getRandomTune, initCode, loadModules, shareCode } from './util.mjs';
import './Repl.css';
import { setInterval, clearInterval } from 'worker-timers';
import { getMetadata } from '../metadata_parser';
import { encode as base64urlencode } from 'base64url-universal';
import { debugAudiograph } from './audiograph';
const { latestCode, maxPolyphony, audioDeviceName, multiChannelOrbits } = settingsMap.get();
let modulesLoading, presets, drawContext, clearCanvas, audioReady;
@@ -102,29 +98,13 @@ export function useReplContext() {
}
},
beforeEval: () => audioReady,
afterEval: async (all) => {
afterEval: (all) => {
const { code } = all;
//post to iframe parent (like Udels) if it exists...
window.parent?.postMessage(code);
setLatestCode(code);
//window.location.hash = '#' + code2hash(code);
// Compress the script and encode it with base64url
const encoded = new TextEncoder().encode(code);
//console.log('encoded',encoded);
const cs = new CompressionStream('deflate');
const writer = cs.writable.getWriter();
writer.write(encoded);
writer.close();
const compressed = new Uint8Array(await new Response(cs.readable).arrayBuffer());
//console.log('compressed',compressed);
const baseurled = base64urlencode(compressed);
//console.log('baseurled',baseurled);
window.location.hash = '#~' + baseurled;
window.location.hash = '#' + code2hash(code);
setDocumentTitle(code);
const viewingPatternData = getViewingPatternData();
setVersionDefaultsFrom(code);
@@ -149,7 +129,6 @@ export function useReplContext() {
bgFill: false,
});
window.strudelMirror = editor;
window.debugAudiograph = debugAudiograph;
// init settings
initCode().then(async (decoded) => {
@@ -228,30 +207,6 @@ 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;
@@ -280,7 +235,6 @@ export function useReplContext() {
handleShuffle,
handleShare,
handleEvaluate,
handleExport,
init,
error,
editorRef,
-33
View File
@@ -7,7 +7,6 @@ import './Repl.css';
import { createClient } from '@supabase/supabase-js';
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { $featuredPatterns /* , loadDBPatterns */ } from '@src/user_pattern_utils.mjs';
import { decode as base64urldecode } from 'base64url-universal';
// Create a single supabase client for interacting with your database
export const supabase = createClient(
@@ -27,38 +26,6 @@ export async function initCode() {
const hash = initialUrl.split('?')[1]?.split('#')?.[0]?.split('&')[0];
const codeParam = window.location.href.split('#')[1] || '';
if (codeParam) {
if (codeParam[0] === '~') {
// Encoded using base64url and compressed
const baseurled = codeParam.substring(1);
//console.log('baseurled', baseurled);
const compressed = base64urldecode(baseurled);
//console.log('compressed', compressed);
const cs = new DecompressionStream('deflate');
const writer = cs.writable.getWriter();
writer.write(compressed);
writer.close();
const encoded = await new Response(cs.readable).arrayBuffer();
//console.log('encoded', encoded);
const decoded = new TextDecoder().decode(encoded);
//console.log('decoded', decoded);
return decoded;
}
if (codeParam[0] === '_') {
const url = decodeURIComponent(codeParam.substring(1));
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Response status: ${response.status}`);
}
console.log('Response', response);
return await response.text();
} catch (error) {
return `/* ERROR LOADING SCRIPT. ${error.message} */`;
}
}
// looking like https://strudel.cc/#ImMzIGUzIg%3D%3D (hash length depends on code length)
return hash2code(codeParam);
} else if (hash) {