mirror of
https://codeberg.org/uzu/strudel
synced 2026-07-14 14:53:45 -04:00
Compare commits
206 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf21c4cf02 | |||
| 741ff3f3f0 | |||
| 4476921069 | |||
| 8c5e4a5780 | |||
| 34ae1bb305 | |||
| 0cf11adc2c | |||
| 7a5020aa71 | |||
| a205f7d873 | |||
| 0e9c50bf0a | |||
| e837c516c5 | |||
| 3b2e9fa0b4 | |||
| 6a47139e0d | |||
| f7f8c56c0a | |||
| c7e88e1ff2 | |||
| f6a43f1b10 | |||
| a3b183d304 | |||
| f7175c0505 | |||
| e26040d154 | |||
| 17a1b75e5b | |||
| 7df9688bb9 | |||
| d557bb59f0 | |||
| 679e81eb0f | |||
| 37dd834398 | |||
| 959e8ac7bc | |||
| 16524ca805 | |||
| b4b0194455 | |||
| fd472a64d9 | |||
| 58094973b0 | |||
| db49b37780 | |||
| bf243c04b9 | |||
| bc05cffdbc | |||
| 7b1e02e8cf | |||
| 257f09da4f | |||
| ecd58fa501 | |||
| c4ece4932e | |||
| 5c9855bf12 | |||
| e54b9c3a32 | |||
| 6afe0dc714 | |||
| ebaf7624f9 | |||
| 882bcc513f | |||
| aec33710b7 | |||
| d41ebf190b | |||
| 7953fa61e4 | |||
| 6051b922c0 | |||
| 367e07da0c | |||
| 0edede106a | |||
| b6264a1513 | |||
| 680b7d78ec | |||
| e79b4b8ccb | |||
| 98367868ac | |||
| 616bc64e70 | |||
| c0f4f47150 | |||
| b4496fcc24 | |||
| 48f6a41683 | |||
| 7bce739068 | |||
| 06f9c2dfb5 | |||
| 30fe5b47e3 | |||
| 1823993e7c | |||
| 1935195e9a | |||
| 300a1bbfd5 | |||
| 2f43dcab0c | |||
| 125eece223 | |||
| 53e49952d8 | |||
| e286ab2830 | |||
| e4e2d60b28 | |||
| 99bec835f8 | |||
| d349d2a0cd | |||
| c7a2c2bb9c | |||
| 5a51b4ec71 | |||
| 3922defab9 | |||
| a9752fb2a4 | |||
| 0a2f2eaa3d | |||
| 0de212c12f | |||
| b376b2616a | |||
| 56ecb006fa | |||
| 2748189f18 | |||
| 63d254daf4 | |||
| 34991e9602 | |||
| 8df5c80d82 | |||
| e0344dbd67 | |||
| 931d684b19 | |||
| 4a7344595d | |||
| 18b3c66eb8 | |||
| 63de46ae96 | |||
| c598059dc7 | |||
| dec037bed5 | |||
| 29f7f5c1f8 | |||
| bae5571df0 | |||
| 4b08dd8447 | |||
| 11e3da0552 | |||
| ef2ee0969a | |||
| 12f9af227c | |||
| dc5f6827f9 | |||
| 8baecf78b9 | |||
| 415b6d5f1d | |||
| 4169b764c3 | |||
| e7d1613bfb | |||
| 45aa1f5384 | |||
| 3a17aeabf3 | |||
| 2aeba2e53f | |||
| a79491dd16 | |||
| 53c101005c | |||
| 82ca77be9d | |||
| 173e359a65 | |||
| ad43259353 | |||
| 32924b3fcf | |||
| 4c99f4866b | |||
| d50af71ba1 | |||
| 0664f90178 | |||
| 4321814d36 | |||
| 6610713018 | |||
| 600ab0a83e | |||
| d7e240e2dc | |||
| 25c20296a1 | |||
| 2db30a710b | |||
| 71d1fe99e4 | |||
| 05ed16e158 | |||
| 24b69ad1bc | |||
| 5bddb6f48d | |||
| c5250d7794 | |||
| 7d890156f4 | |||
| 8f9967c76d | |||
| 91ebdd5ea6 | |||
| fc74dcdb1a | |||
| 8529516c66 | |||
| 0e3840aff4 | |||
| a929b36770 | |||
| 2794541dad | |||
| 3e118ce0a8 | |||
| c3ad0d1789 | |||
| 19b710c401 | |||
| 0dbca05de0 | |||
| f8796d039f | |||
| 442f487973 | |||
| 7ccdefe1c6 | |||
| b73f624058 | |||
| e4466082eb | |||
| 29b6729246 | |||
| b3df7f438f | |||
| 3b1c75d3de | |||
| 48407c309c | |||
| 17af92b2df | |||
| e90aa5202d | |||
| fb7f686519 | |||
| aca500ba73 | |||
| 03d2f363f1 | |||
| ff370cf86f | |||
| 9cbeaef88d | |||
| ca936aada7 | |||
| fe70a2df43 | |||
| 6baca67cd3 | |||
| 85f6454340 | |||
| 16f885c010 | |||
| 5a70599aac | |||
| 7b74a902a2 | |||
| 95700e1196 | |||
| e7578be6c5 | |||
| 28df4259b8 | |||
| 5e27358fe8 | |||
| daea207aff | |||
| 92d3fe496d | |||
| 98031676d6 | |||
| 498624f6fe | |||
| bf702c3b2a | |||
| 6e4dae4dfa | |||
| a8ea9a1b63 | |||
| a2b76bde14 | |||
| 0f652e8b5b | |||
| 2a0635879c | |||
| f5fc0ff9d1 | |||
| 7774364ac7 | |||
| 329289fc35 | |||
| 8b3c0d2a4a | |||
| 120b46c4a5 | |||
| 4bdaf577c0 | |||
| 428a4b3dd7 | |||
| ff83994733 | |||
| 418bca79b2 | |||
| b3715dc5df | |||
| 9a47e0f11e | |||
| 908c0e44ff | |||
| 920776fdfe | |||
| 0fbde61b2d | |||
| 23aa8ef509 | |||
| 945b325336 | |||
| 502f0b5c23 | |||
| 2ccbb0596d | |||
| 862b88b6c4 | |||
| cd7865a66f | |||
| 9c8e39d83d | |||
| 0a4b6a01b3 | |||
| b3e433291c | |||
| 969c9663ef | |||
| 5e4e678800 | |||
| b8423f0ed1 | |||
| 5d5159a08f | |||
| 1e96d59ac8 | |||
| 6a9739bed5 | |||
| d8f0d70908 | |||
| e25eab11ad | |||
| efb337677c | |||
| 4a70ac5a99 | |||
| 554d6118c6 | |||
| 03271ec5fb | |||
| a0760f9468 | |||
| 058d950121 |
@@ -1,14 +1,17 @@
|
||||
# strudel
|
||||
|
||||
Live coding patterns on the web
|
||||
https://strudel.cc/
|
||||
|
||||
|
||||
- Try it here: <https://strudel.cc>
|
||||
- Docs: <https://strudel.cc/learn>
|
||||
- Source: https://codeberg.org/uzu/strudel/
|
||||
* 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**.
|
||||
- Technical Blog Post: <https://loophole-letters.vercel.app/strudel>
|
||||
- 1 Year of Strudel Blog Post: <https://loophole-letters.vercel.app/strudel1year>
|
||||
- 2 Years of Strudel Blog Post: <https://strudel.cc/blog/#year-2>
|
||||
|
||||
|
||||
## Running Locally
|
||||
|
||||
After cloning the project, you can run the REPL locally:
|
||||
|
||||
@@ -27,7 +27,7 @@ import { updateWidgets, widgetPlugin } from './widget.mjs';
|
||||
|
||||
export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands';
|
||||
|
||||
const extensions = {
|
||||
export const extensions = {
|
||||
isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []),
|
||||
isBracketMatchingEnabled: (on) => (on ? bracketMatching({ brackets: '()[]{}<>' }) : []),
|
||||
isBracketClosingEnabled: (on) => (on ? closeBrackets() : []),
|
||||
@@ -48,7 +48,7 @@ const extensions = {
|
||||
]
|
||||
: [],
|
||||
};
|
||||
const compartments = Object.fromEntries(Object.keys(extensions).map((key) => [key, new Compartment()]));
|
||||
export const compartments = Object.fromEntries(Object.keys(extensions).map((key) => [key, new Compartment()]));
|
||||
|
||||
export const defaultSettings = {
|
||||
keybindings: 'codemirror',
|
||||
@@ -293,9 +293,9 @@ export class StrudelMirror {
|
||||
console.warn('first frame could not be painted');
|
||||
}
|
||||
}
|
||||
async evaluate() {
|
||||
async evaluate(autostart = true) {
|
||||
this.flash();
|
||||
await this.repl.evaluate(this.code);
|
||||
await this.repl.evaluate(this.code, autostart);
|
||||
}
|
||||
async stop() {
|
||||
this.repl.scheduler.stop();
|
||||
@@ -411,7 +411,7 @@ export class StrudelMirror {
|
||||
}
|
||||
}
|
||||
|
||||
function parseBooleans(value) {
|
||||
export function parseBooleans(value) {
|
||||
return { true: true, false: false }[value] ?? value;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from './flash.mjs';
|
||||
export * from './slider.mjs';
|
||||
export * from './themes.mjs';
|
||||
export * from './widget.mjs';
|
||||
export { Vim } from './keybindings.mjs';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defaultKeymap } from '@codemirror/commands';
|
||||
import { Prec } from '@codemirror/state';
|
||||
import { Prec, EditorState } from '@codemirror/state';
|
||||
import { keymap, ViewPlugin } from '@codemirror/view';
|
||||
// import { searchKeymap } from '@codemirror/search';
|
||||
import { emacs } from '@replit/codemirror-emacs';
|
||||
@@ -131,7 +131,13 @@ const keymaps = {
|
||||
vscode: vscodeExtension,
|
||||
};
|
||||
|
||||
export { Vim } from '@replit/codemirror-vim';
|
||||
|
||||
export function keybindings(name) {
|
||||
const active = keymaps[name];
|
||||
return [active ? Prec.high(active()) : []];
|
||||
const extensions = active ? [Prec.high(active())] : [];
|
||||
if (name === 'vim') {
|
||||
extensions.push(EditorState.allowMultipleSelections.of(true));
|
||||
}
|
||||
return extensions;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, bench } from 'vitest';
|
||||
|
||||
import { calculateSteps, rand, useRNG } from '../index.mjs';
|
||||
|
||||
const testingResolution = 128;
|
||||
|
||||
const _generateRandomPattern = () => rand.iter(testingResolution).fast(testingResolution).firstCycle();
|
||||
|
||||
describe('old random', () => {
|
||||
calculateSteps(true);
|
||||
bench(
|
||||
'+tactus',
|
||||
() => {
|
||||
useRNG('legacy');
|
||||
_generateRandomPattern();
|
||||
},
|
||||
{
|
||||
time: 1000,
|
||||
teardown() {
|
||||
useRNG('legacy');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
calculateSteps(false);
|
||||
bench(
|
||||
'-tactus',
|
||||
() => {
|
||||
useRNG('precise');
|
||||
_generateRandomPattern();
|
||||
},
|
||||
{
|
||||
time: 1000,
|
||||
teardown() {
|
||||
useRNG('legacy');
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('random', () => {
|
||||
calculateSteps(true);
|
||||
bench('+tactus', _generateRandomPattern, { time: 1000 });
|
||||
|
||||
calculateSteps(false);
|
||||
bench('-tactus', _generateRandomPattern, { time: 1000 });
|
||||
});
|
||||
|
||||
calculateSteps(true);
|
||||
+275
-4
@@ -4,7 +4,8 @@ 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/>.
|
||||
*/
|
||||
|
||||
import { Pattern, register, reify } from './pattern.mjs';
|
||||
import { logger } from './logger.mjs';
|
||||
import { Pattern, pure, register, reify } from './pattern.mjs';
|
||||
|
||||
export function createParam(names) {
|
||||
let isMulti = Array.isArray(names);
|
||||
@@ -2044,6 +2045,28 @@ export const { octave, oct } = registerControl('octave', 'oct');
|
||||
* )
|
||||
*/
|
||||
export const { orbit } = registerControl('orbit', 'o');
|
||||
|
||||
/**
|
||||
* A `bus` is a send which can be used for mixing patterns. It combines with..
|
||||
* s("bus") to play that bus through another pattern (for, say, applying non-linear
|
||||
* effects like distortion to multiple signals)
|
||||
*
|
||||
* otherPat.bmod(..) (to modulate another pattern with the bus)
|
||||
*
|
||||
* @name bus
|
||||
* @param {number | Pattern} number
|
||||
*/
|
||||
export const { bus } = registerControl('bus');
|
||||
|
||||
/**
|
||||
* Postgain multiplier prior to sending the signal to the audio bus.
|
||||
*
|
||||
* @name busgain
|
||||
* @synonyms bgain
|
||||
* @param {number | Pattern} number
|
||||
*/
|
||||
export const { busgain, bgain } = registerControl('busgain', 'bgain');
|
||||
|
||||
// TODO: what is this? not found in tidal doc Answer: gain is limited to maximum of 2. This allows you to go over that
|
||||
export const { overgain } = registerControl('overgain');
|
||||
// TODO: what is this? not found in tidal doc. Similar to above, but limited to 1
|
||||
@@ -2088,8 +2111,7 @@ export const { panorient } = registerControl('panorient');
|
||||
// ['pitch2'],
|
||||
// ['pitch3'],
|
||||
// ['portamento'],
|
||||
// TODO: LFO rate see https://tidalcycles.org/docs/patternlib/tutorials/synthesizers/#supersquare
|
||||
export const { rate } = registerControl('rate');
|
||||
|
||||
// TODO: slide param for certain synths
|
||||
export const { slide } = registerControl('slide');
|
||||
// TODO: detune? https://tidalcycles.org/docs/patternlib/tutorials/synthesizers/#supersquare
|
||||
@@ -2515,7 +2537,6 @@ export const { curve } = registerControl('curve');
|
||||
export const { deltaSlide } = registerControl('deltaSlide');
|
||||
export const { pitchJump } = registerControl('pitchJump');
|
||||
export const { pitchJumpTime } = registerControl('pitchJumpTime');
|
||||
export const { lfo, repeatTime } = registerControl('lfo', 'repeatTime');
|
||||
// noise on the frequency or as bubo calls it "frequency fog" :)
|
||||
export const { znoise } = registerControl('znoise');
|
||||
export const { zmod } = registerControl('zmod');
|
||||
@@ -2792,6 +2813,254 @@ export const scrub = register(
|
||||
false,
|
||||
);
|
||||
|
||||
const subControlAliases = new Map();
|
||||
const registerSubControl = (control, subControl, ...aliases) => {
|
||||
const aliasMap = subControlAliases.get(control) ?? new Map();
|
||||
const allKeys = new Set([subControl, ...aliases]);
|
||||
for (const alias of allKeys) {
|
||||
aliasMap.set(String(alias).toLowerCase(), subControl);
|
||||
}
|
||||
subControlAliases.set(control, aliasMap);
|
||||
};
|
||||
|
||||
const registerSubControls = (control, subControlAliases = []) => {
|
||||
for (const [subControl, ...aliases] of subControlAliases) {
|
||||
registerSubControl(control, subControl, ...aliases);
|
||||
}
|
||||
};
|
||||
|
||||
const getMainSubcontrolName = (control, subKey) => {
|
||||
const aliasMap = subControlAliases.get(control);
|
||||
if (!aliasMap) return subKey;
|
||||
return aliasMap.get(String(subKey).toLowerCase()) ?? subKey;
|
||||
};
|
||||
|
||||
registerSubControls('lfo', [
|
||||
['control', 'c'],
|
||||
['subControl', 'sc'],
|
||||
['rate', 'r'],
|
||||
['depth', 'dep', 'dr'],
|
||||
['depthabs', 'da'],
|
||||
['dcoffset', 'dc'],
|
||||
['shape', 'sh'],
|
||||
['skew', 'sk'],
|
||||
['curve', 'cu'],
|
||||
['sync', 's'],
|
||||
['fxi'],
|
||||
]);
|
||||
registerSubControls('env', [
|
||||
['control', 'c'],
|
||||
['subControl', 'sc'],
|
||||
['attack', 'att', 'a'],
|
||||
['decay', 'dec', 'd'],
|
||||
['sustain', 'sus', 's'],
|
||||
['release', 'rel', 'r'],
|
||||
['depth', 'dep', 'dr'],
|
||||
['depthabs', 'da'],
|
||||
['acurve', 'ac'],
|
||||
['dcurve', 'dc'],
|
||||
['rcurve', 'rc'],
|
||||
['fxi'],
|
||||
]);
|
||||
registerSubControls('bmod', [
|
||||
['bus', 'b'],
|
||||
['control', 'c'],
|
||||
['subControl', 'sc'],
|
||||
['depth', 'dep', 'dr'],
|
||||
['depthabs', 'da'],
|
||||
['dc'],
|
||||
['fxi'],
|
||||
]);
|
||||
|
||||
Pattern.prototype.modulate = function (type, config, idPat) {
|
||||
config = { control: undefined, ...config };
|
||||
const modulatorKeys = ['lfo', 'env', 'bmod'];
|
||||
if (!modulatorKeys.includes(type)) {
|
||||
logger(`[core] Modulation type ${type} not found. Please use one of 'lfo', 'env', 'bmod'`);
|
||||
return this;
|
||||
}
|
||||
let output = this;
|
||||
let defaultValue = undefined;
|
||||
// Copy value into a temporary `v` container and attach a single `id` (to be shared across
|
||||
// each config entry). At the output we destructure and throw away the id
|
||||
output = output.fmap((v) => (id) => ({ v, id })).appLeft(reify(idPat));
|
||||
for (const [rawKey, value] of Object.entries(config)) {
|
||||
const key = getMainSubcontrolName(type, rawKey);
|
||||
const valuePat = reify(value);
|
||||
output = output
|
||||
.fmap(({ v, id }) => (c) => {
|
||||
if (defaultValue === undefined) {
|
||||
// default control to the control set just before this in the chain
|
||||
// e.g. pat.gain(0.5).lfo({..}) will be a gain-LFO
|
||||
let control = getControlName(Object.keys(v).at(-1));
|
||||
if (modulatorKeys.includes(control)) {
|
||||
control = `${control}_${[...v[control].__ids].at(-1)}`;
|
||||
}
|
||||
defaultValue = control;
|
||||
}
|
||||
v[type] ??= { __ids: new Set() };
|
||||
const t = v[type];
|
||||
id ??= t.__ids.size;
|
||||
t[id] ??= { control: defaultValue };
|
||||
t.__ids.add(id); // keeps track of insertion order
|
||||
if (c === undefined) return { v, id };
|
||||
if (key === 'control' || key === 'subControl') {
|
||||
t[id][key] = getControlName(c);
|
||||
} else {
|
||||
t[id][key] = c;
|
||||
}
|
||||
return { v, id };
|
||||
})
|
||||
.appLeft(valuePat);
|
||||
}
|
||||
return output.fmap(({ v }) => v);
|
||||
};
|
||||
|
||||
/**
|
||||
* Configures an LFO. Can be called in sequence like pat.lfo(...).lfo(...) to set up multiple LFOs.
|
||||
* There are two ways to declare which control will be modulated:
|
||||
* 1. Explicitly put `control` in the config (e.g. `lfo({ c: "lpf" })`)
|
||||
* 2. If the control parameter is absent, the control _immediately before_ the `lfo` call will be used
|
||||
* (e.g. `s("saw").lpf(500).lfo()` to modulate `lpf`)
|
||||
*
|
||||
* Modulators can be referred to by `id` so that they can be updated later e.g. inside
|
||||
* a `sometimes`. See example below.
|
||||
*
|
||||
* @name lfo
|
||||
* @param {Object} config LFO configuration.
|
||||
* @param {string | Pattern} [config.control] Node to modulate. Aliases: c
|
||||
* @param {string | Pattern} [config.subControl] Sub-control name to append to the control key. Aliases: sc
|
||||
* @param {number | Pattern} [config.rate] Modulation rate. Aliases: r
|
||||
* @param {number | Pattern} [config.depth] Relative modulation depth. Aliases: dep, dr
|
||||
* @param {number | Pattern} [config.depthabs] Absolute modulation depth. Aliases: da
|
||||
* @param {number | Pattern} [config.dcoffset] DC offset / bias for the waveform. Aliases: dc
|
||||
* @param {number | Pattern} [config.shape] Shape index. Aliases: sh
|
||||
* @param {number | Pattern} [config.skew] Skew amount. Aliases: sk
|
||||
* @param {number | Pattern} [config.curve] Exponential curve amount. Aliases: cu
|
||||
* @param {number | Pattern} [config.sync] Tempo-synced modulation rate. Aliases: s
|
||||
* @param {number | Pattern} [config.fxi] FX index to target
|
||||
* @param {string | Pattern} id ID to use for this modulator
|
||||
* @returns Pattern
|
||||
*
|
||||
* @example
|
||||
* s("saw").note("F1").lpf(500).lfo()
|
||||
*
|
||||
* @example
|
||||
* s("saw").lfo().lpf(500).lfo({ s: 0.3 })
|
||||
*
|
||||
* @example
|
||||
* s("saw").lpf(500).diode(0.3)
|
||||
* .lfo({ c: "lpf" })
|
||||
*
|
||||
* @example
|
||||
* s("pulse").lpf(500).lfo()
|
||||
* .lfo({ c: "s" })
|
||||
* .diode(0.3)
|
||||
* .sometimes(x => x.lfo({ s: "8" }, 1)) // lfo #1 (0-indexed)
|
||||
*
|
||||
* @example
|
||||
* s("pulse").lpf(500).lfo({ depth: 4 }, 'lpf_mod')
|
||||
* .lfo({ c: "s" })
|
||||
* .diode(0.3)
|
||||
* .sometimes(x => x.lfo({ s: "8" }, 'lpf_mod'))
|
||||
*/
|
||||
Pattern.prototype.lfo = function (config, id) {
|
||||
return this.modulate('lfo', config, id);
|
||||
};
|
||||
export const lfo = (config) => pure({}).lfo(config);
|
||||
|
||||
/**
|
||||
* Configures an envelope. Can be called in sequence like pat.env(...).env(...) to set up multiple envelopes
|
||||
* There are two ways to declare which control will be modulated:
|
||||
* 1. Explicitly put `control` in the config (e.g. `env({ c: "lpf" })`)
|
||||
* 2. If the control parameter is absent, the control _immediately before_ the `env` call will be used
|
||||
* (e.g. `s("saw").lpf(500).env({ a: 1 })` to modulate `lpf`)
|
||||
*
|
||||
* Modulators can be referred to by `id` so that they can be updated later e.g. inside
|
||||
* a `sometimes`. See example below.
|
||||
*
|
||||
* @name env
|
||||
* @param {Object} config Envelope configuration.
|
||||
* @param {string | Pattern} [config.control] Node to modulate. Aliases: c
|
||||
* @param {string | Pattern} [config.subControl] Sub-control name to append to the control key. Aliases: sc
|
||||
* @param {number | Pattern} [config.depth] Relative modulation depth. Aliases: dep, dr
|
||||
* @param {number | Pattern} [config.depthabs] Absolute modulation depth. Aliases: da
|
||||
* @param {number | Pattern} [config.attack] Time to reach depth. Aliases: att, a
|
||||
* @param {number | Pattern} [config.decay] Time to reach sustain. Aliases: dec, d
|
||||
* @param {number | Pattern} [config.sustain] Sustain depth. Aliases: sus, s
|
||||
* @param {number | Pattern} [config.release] Time to return to nominal value. Aliases: rel, r
|
||||
* @param {number | Pattern} [config.acurve] Snappiness of attack curve (-1 = relaxed, 1 = snappy). Aliases: ac
|
||||
* @param {number | Pattern} [config.dcurve] Snappiness of decay curve (-1 = relaxed, 1 = snappy). Aliases: dc
|
||||
* @param {number | Pattern} [config.rcurve] Snappiness of release curve (-1 = relaxed, 1 = snappy). Aliases: rc
|
||||
* @param {number | Pattern} [config.fxi] FX index to target
|
||||
* @param {string | Pattern} id ID to use for this modulator
|
||||
* @returns Pattern
|
||||
*
|
||||
* @example
|
||||
* s("saw").note("F1").lpf(500).env({ a: 1 })
|
||||
*
|
||||
* @example
|
||||
* s("saw").env({ d: 1 }).note("F1")
|
||||
* .lpq(4).lpf(50)
|
||||
* .env({ a: 0.1, d: 1, ac: 0.8, dc: 0.3, depth: 50 })
|
||||
*
|
||||
* @example
|
||||
* s("saw").lpf(500).diode(0.3)
|
||||
* .env({ c: "lpf", a: 0.5, d: 0.5 })
|
||||
*
|
||||
* @example
|
||||
* s("pulse").lpf(500).env({ a: 1 })
|
||||
* .env({ c: "s", a: 1 })
|
||||
* .diode(0.3)
|
||||
* .sometimes(x => x.env({ a: "0.5" }, 1)) // envelope #1 (0-indexed)
|
||||
*
|
||||
* @example
|
||||
* s("pulse").lpf(500).env({ a: 1 }, 'lpf_mod')
|
||||
* .env({ c: "s", a: 1 })
|
||||
* .diode(0.3)
|
||||
* .sometimes(x => x.env({ a: "0.5" }, 'lpf_mod'))
|
||||
*/
|
||||
Pattern.prototype.env = function (config, id) {
|
||||
return this.modulate('env', config, id);
|
||||
};
|
||||
export const env = (config) => pure({}).env(config);
|
||||
|
||||
/**
|
||||
* Modulates with the output from a given `bus`.
|
||||
* Can be called in sequence like pat.bmod(...).bmod(...) to set up multiple modulators
|
||||
*
|
||||
* Send to an audio bus with `otherPat.bus(..)`.
|
||||
*
|
||||
* There are two ways to declare which control will be modulated:
|
||||
* 1. Explicitly put `control` in the config (e.g. `bmod({ id: 2, c: "lpf" })`)
|
||||
* 2. If the control parameter is absent, the control _immediately before_ the `bmod` call will be used
|
||||
* (e.g. `s("saw").lpf(500).bmod({ id: 2 })` to modulate `lpf`)
|
||||
*
|
||||
* Modulators can be referred to by `id` so that they can be updated later e.g. inside
|
||||
* a `sometimes`. See example below.
|
||||
*
|
||||
* @name bmod
|
||||
* @param {Object} config Bus modulation configuration.
|
||||
* @param {string | Pattern} [config.bus] Bus to get modulation signal from
|
||||
* @param {string | Pattern} [config.control] Node to modulate. Aliases: c
|
||||
* @param {string | Pattern} [config.subControl] Sub-control name to append to the control key. Aliases: sc
|
||||
* @param {number | Pattern} [config.depth] Relative modulation depth. Aliases: dep, dr
|
||||
* @param {number | Pattern} [config.depthabs] Absolute modulation depth. Aliases: da
|
||||
* @param {number | Pattern} [config.dc] DC offset prior to application
|
||||
* @param {number | Pattern} [config.fxi] FX index to target
|
||||
* @param {string | Pattern} id ID to use for this modulator
|
||||
* @returns Pattern
|
||||
*
|
||||
* @example
|
||||
* modulator: s("one").seg(64).gain(slider(0, 0, 1)).bus(1).dry(0)
|
||||
* carrier: s("saw").bmod({ b: 1 })
|
||||
*
|
||||
*/
|
||||
Pattern.prototype.bmod = function (config, id) {
|
||||
return this.modulate('bmod', config, id);
|
||||
};
|
||||
export const bmod = (config) => pure({}).bmod(config);
|
||||
|
||||
/**
|
||||
* Transient shaper. Gives independent control over the emphasis on transients
|
||||
* and sustains
|
||||
@@ -2805,3 +3074,5 @@ export const scrub = register(
|
||||
* s("hh*16").bank("tr909").transient("<-1:1 1:-1>")
|
||||
*/
|
||||
export const { transient } = registerControl(['transient', 'transsustain']);
|
||||
|
||||
export const { FXrelease, FXrel, FXr, fxr } = registerControl('FXrelease', 'FXrel', 'FXr', 'fxr');
|
||||
|
||||
@@ -3586,6 +3586,20 @@ export const morph = (frompat, topat, bypat) => {
|
||||
return frompat.innerBind((from) => topat.innerBind((to) => bypat.innerBind((by) => _morph(from, to, by))));
|
||||
};
|
||||
|
||||
const _distortWithAlg = function (name) {
|
||||
const func = function (args, pat) {
|
||||
const argsPat = reify(args).fmap((v) => (Array.isArray(v) ? [...v, name] : [v, 1, name]));
|
||||
if (!pat) {
|
||||
return pure({}).distort(argsPat);
|
||||
}
|
||||
return pat.distort(argsPat);
|
||||
};
|
||||
Pattern.prototype[name] = function (args) {
|
||||
return func(args, this);
|
||||
};
|
||||
return func;
|
||||
};
|
||||
|
||||
/**
|
||||
* Soft-clipping distortion
|
||||
*
|
||||
@@ -3594,6 +3608,8 @@ export const morph = (frompat, topat, bypat) => {
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
*/
|
||||
export const soft = _distortWithAlg('soft');
|
||||
|
||||
/**
|
||||
* Hard-clipping distortion
|
||||
*
|
||||
@@ -3602,6 +3618,8 @@ export const morph = (frompat, topat, bypat) => {
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
*/
|
||||
export const hard = _distortWithAlg('hard');
|
||||
|
||||
/**
|
||||
* Cubic polynomial distortion
|
||||
*
|
||||
@@ -3610,6 +3628,8 @@ export const morph = (frompat, topat, bypat) => {
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
*/
|
||||
export const cubic = _distortWithAlg('cubic');
|
||||
|
||||
/**
|
||||
* Diode-emulating distortion
|
||||
*
|
||||
@@ -3618,6 +3638,8 @@ export const morph = (frompat, topat, bypat) => {
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
*/
|
||||
export const diode = _distortWithAlg('diode');
|
||||
|
||||
/**
|
||||
* Asymmetrical diode distortion
|
||||
*
|
||||
@@ -3626,6 +3648,8 @@ export const morph = (frompat, topat, bypat) => {
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
*/
|
||||
export const asym = _distortWithAlg('asym');
|
||||
|
||||
/**
|
||||
* Wavefolding distortion
|
||||
*
|
||||
@@ -3634,6 +3658,8 @@ export const morph = (frompat, topat, bypat) => {
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
*/
|
||||
export const fold = _distortWithAlg('fold');
|
||||
|
||||
/**
|
||||
* Wavefolding distortion composed with sinusoid
|
||||
*
|
||||
@@ -3642,6 +3668,8 @@ export const morph = (frompat, topat, bypat) => {
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
*/
|
||||
export const sinefold = _distortWithAlg('sinefold');
|
||||
|
||||
/**
|
||||
* Distortion via Chebyshev polynomials
|
||||
*
|
||||
@@ -3650,14 +3678,7 @@ export const morph = (frompat, topat, bypat) => {
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
*/
|
||||
const distAlgoNames = ['scurve', 'soft', 'hard', 'cubic', 'diode', 'asym', 'fold', 'sinefold', 'chebyshev'];
|
||||
for (const name of distAlgoNames) {
|
||||
// Add aliases for distortion algorithms
|
||||
Pattern.prototype[name] = function (args) {
|
||||
const argsPat = reify(args).fmap((v) => (Array.isArray(v) ? [...v, name] : [v, 1, name]));
|
||||
return this.distort(argsPat);
|
||||
};
|
||||
}
|
||||
export const chebyshev = _distortWithAlg('chebyshev');
|
||||
|
||||
/**
|
||||
* Turns a list of patterns into a single pattern which outputs list-values
|
||||
@@ -3721,3 +3742,35 @@ Pattern.prototype.phases = function (list) {
|
||||
export const phases = (list) => {
|
||||
return _ensureListPattern(list).as('phases');
|
||||
};
|
||||
|
||||
/**
|
||||
* Establishes an FX chain. Can be called by chaining .FX(fx1).FX(fx2)..
|
||||
* calls and/or in a single .FX(fx1, fx2, ..) call. The fx1, .. are _patterns_ which
|
||||
* establish the controls of the given effect. See examples.
|
||||
* @name FX
|
||||
* @memberof Pattern
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* $: s("[sbd <hh [bd | lt | oh]>]*4").dec(.4)
|
||||
* .FX(
|
||||
* phaser(0.5).gain(2),
|
||||
* bpf(800),
|
||||
* distort(1.3),
|
||||
* room(0.2),
|
||||
* delay(0.5).gain(1.25),
|
||||
* distort(0.3),
|
||||
* ).fxr(1.7) // sets release time of effects (like delay)
|
||||
* @example
|
||||
* $: s("saw").fm(0.5)
|
||||
* .delay(0.3) // outer effects are applied *last*
|
||||
* .FX(coarse(4)) // first coarse
|
||||
* .FX(lpf(500).lpe(4).lpa(1).lpd(2)) // then lpf
|
||||
* .FX(distort(1)) // then distort
|
||||
*/
|
||||
Pattern.prototype.FX = function (...effects) {
|
||||
effects = effects.map(reify);
|
||||
return this.withValue((v) => (vEff) => {
|
||||
const currFX = v.FX ?? [];
|
||||
return { ...v, FX: currFX.concat(vEff) };
|
||||
}).appLeft(parray(effects));
|
||||
};
|
||||
|
||||
+124
-38
@@ -16,7 +16,7 @@ export function steady(value) {
|
||||
}
|
||||
|
||||
export const signal = (func) => {
|
||||
const query = (state) => [new Hap(undefined, state.span, func(state.span.begin))];
|
||||
const query = (state) => [new Hap(undefined, state.span, func(state.span.begin, state.controls))];
|
||||
return new Pattern(query);
|
||||
};
|
||||
|
||||
@@ -186,38 +186,97 @@ export const mouseY = signal(() => _mouseY);
|
||||
export const mousex = signal(() => _mouseX);
|
||||
export const mouseX = signal(() => _mouseX);
|
||||
|
||||
// random signals
|
||||
// Random number generators
|
||||
|
||||
const xorwise = (x) => {
|
||||
// Produce "Avalanche effect" where flipping a single bit of x
|
||||
// results in all output bits flipping with probability 0.5
|
||||
// See e.g. https://github.com/aappleby/smhasher/blob/0ff96f7835817a27d0487325b6c16033e2992eb5/src/MurmurHash3.cpp#L68-L77
|
||||
const _murmurHashFinalizer = (x) => {
|
||||
x |= 0;
|
||||
x ^= x >>> 16;
|
||||
x = Math.imul(x, 0x85ebca6b);
|
||||
x ^= x >>> 13;
|
||||
x = Math.imul(x, 0xc2b2ae35);
|
||||
x ^= x >>> 16;
|
||||
return x >>> 0; // unsigned
|
||||
};
|
||||
|
||||
// Convert t to a 32 bit integer, preserving temporal resolution down to 1/2^29
|
||||
const _tToT = (t) => {
|
||||
return Math.floor(t * 536870912);
|
||||
};
|
||||
|
||||
// Used to decorrelate nearby T, i, and seed prior to hashing
|
||||
const _decorrelate = (T, i = 0, seed = 0) => {
|
||||
const lowBits = (T >>> 0) >>> 0;
|
||||
const highBits = Math.floor(T / 4294967296) >>> 0; // 2^32
|
||||
let key = lowBits ^ Math.imul(highBits ^ 0x85ebca6b, 0xc2b2ae35);
|
||||
key ^= Math.imul(i ^ 0x7f4a7c15, 0x9e3779b9);
|
||||
key ^= Math.imul(seed ^ 0x165667b1, 0x27d4eb2d);
|
||||
return key >>> 0;
|
||||
};
|
||||
|
||||
const randAt = (T, i = 0, seed = 0) => {
|
||||
return _murmurHashFinalizer(_decorrelate(T, i, seed)) / 4294967296; // 2^32
|
||||
};
|
||||
|
||||
// n samples at time t
|
||||
const timeToRands = (t, n, seed = 0) => {
|
||||
const T = _tToT(t);
|
||||
if (n === 1) {
|
||||
return randAt(T, 0, seed);
|
||||
}
|
||||
const out = new Array(n);
|
||||
for (let i = 0; i < n; i++) out[i] = randAt(T, i, seed);
|
||||
return out;
|
||||
};
|
||||
|
||||
// Old random signals. Currently the default, but can also be chosen via
|
||||
// `useRNG('legacy')`
|
||||
|
||||
// stretch 300 cycles over the range of [0,2**29 == 536870912) then apply the xorshift algorithm
|
||||
const __xorwise = (x) => {
|
||||
const a = (x << 13) ^ x;
|
||||
const b = (a >> 17) ^ a;
|
||||
return (b << 5) ^ b;
|
||||
};
|
||||
|
||||
// stretch 300 cycles over the range of [0,2**29 == 536870912) then apply the xorshift algorithm
|
||||
const _frac = (x) => x - Math.trunc(x);
|
||||
|
||||
const timeToIntSeed = (x) => xorwise(Math.trunc(_frac(x / 300) * 536870912));
|
||||
|
||||
const intSeedToRand = (x) => (x % 536870912) / 536870912;
|
||||
|
||||
const timeToRand = (x) => Math.abs(intSeedToRand(timeToIntSeed(x)));
|
||||
|
||||
const timeToRandsPrime = (seed, n) => {
|
||||
const __frac = (x) => x - Math.trunc(x);
|
||||
const __timeToIntSeed = (x) => __xorwise(Math.trunc(__frac(x / 300) * 536870912));
|
||||
const __intSeedToRand = (x) => (x % 536870912) / 536870912;
|
||||
const __timeToRandsPrime = (seed, n) => {
|
||||
if (n === 1) {
|
||||
return Math.abs(__intSeedToRand(seed));
|
||||
}
|
||||
const result = [];
|
||||
// eslint-disable-next-line
|
||||
for (let i = 0; i < n; ++i) {
|
||||
result.push(intSeedToRand(seed));
|
||||
seed = xorwise(seed);
|
||||
for (let i = 0; i < n; i++) {
|
||||
result.push(__intSeedToRand(seed));
|
||||
seed = __xorwise(seed);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const __timeToRands = (t, n) => __timeToRandsPrime(__timeToIntSeed(t), n);
|
||||
|
||||
const timeToRands = (t, n) => timeToRandsPrime(timeToIntSeed(t), n);
|
||||
// End old random
|
||||
|
||||
let RNG_MODE = 'legacy';
|
||||
export const getRandsAtTime = (t, n = 1, seed = 0) => {
|
||||
return RNG_MODE === 'legacy' ? __timeToRands(t + seed, n) : timeToRands(t, n, seed);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets which random number generator to use. Historically Strudel would
|
||||
* use `useRNG('legacy')`, which remains the default. To use a new more statistically
|
||||
* precise RNG, try `useRNG('precise')`.
|
||||
*
|
||||
* @name useRNG
|
||||
* @param {string} mod - Mode. One of 'legacy', 'precise'
|
||||
* @example
|
||||
* useRNG('legacy')
|
||||
* // Repeats every 300 cycles
|
||||
* $: n(irand(50)).seg(16).scale("C:minor").ribbon(88, 32)
|
||||
* $: n(irand(50)).seg(16).scale("C:minor").ribbon(388, 32)
|
||||
*/
|
||||
export const useRNG = (mode = 'legacy') => (RNG_MODE = mode);
|
||||
|
||||
/**
|
||||
* A discrete pattern of numbers from 0 to n-1
|
||||
@@ -300,13 +359,13 @@ export const binaryNL = (n, nBits = 16) => {
|
||||
* .partials(randL(8))
|
||||
*/
|
||||
export const randL = (n) => {
|
||||
return signal((t) => (nVal) => timeToRands(t, nVal).map(Math.abs)).appLeft(reify(n));
|
||||
return signal((t) => (nVal) => getRandsAtTime(t, nVal).map(Math.abs)).appLeft(reify(n));
|
||||
};
|
||||
|
||||
export const randrun = (n) => {
|
||||
return signal((t) => {
|
||||
return signal((t, controls) => {
|
||||
// Without adding 0.5, the first cycle is always 0,1,2,3,...
|
||||
const rands = timeToRands(t.floor().add(0.5), n);
|
||||
const rands = getRandsAtTime(t.floor().add(0.5), n, controls.randSeed);
|
||||
const nums = rands
|
||||
.map((n, i) => [n, i])
|
||||
.sort((a, b) => (a[0] > b[0]) - (a[0] < b[0]))
|
||||
@@ -347,6 +406,37 @@ export const scramble = register('scramble', (n, pat) => {
|
||||
return _rearrangeWith(_irand(n)._segment(n), n, pat);
|
||||
});
|
||||
|
||||
/**
|
||||
* Modify a pattern by applying a function to the `randomSeed` control if present
|
||||
*
|
||||
* @param {Function} func Function from seed (or undefined) to seed (or undefined)
|
||||
* @param {Pattern} pat Pattern to update
|
||||
* @returns Pattern
|
||||
*/
|
||||
export const withSeed = (func, pat) => {
|
||||
return new Pattern((state) => {
|
||||
let { randSeed, ...controls } = state.controls;
|
||||
randSeed = func(randSeed);
|
||||
return pat.query(state.setControls({ ...controls, randSeed }));
|
||||
}, pat._steps);
|
||||
};
|
||||
|
||||
/**
|
||||
* Change the seed for random signals. Normally, random signals depend on time,
|
||||
* so two patterns at the same time will have the same random values. Specifying
|
||||
* a new seed changes the signal output by `rand`. This also affects other functions
|
||||
* that use randomness, like `shuffle` and `sometimes`.
|
||||
*
|
||||
* @name seed
|
||||
* @param {number} n A new seed. Can be any number.
|
||||
* @example
|
||||
* $: s("hh*4").degrade();
|
||||
* $: s("bd*4").degrade().seed(1); // Will degrade different events from the hi-hat
|
||||
*/
|
||||
export const seed = register('seed', (n, pat) => {
|
||||
return withSeed(() => n, pat);
|
||||
});
|
||||
|
||||
/**
|
||||
* A continuous pattern of random numbers, between 0 and 1.
|
||||
*
|
||||
@@ -356,7 +446,7 @@ export const scramble = register('scramble', (n, pat) => {
|
||||
* s("bd*4,hh*8").cutoff(rand.range(500,8000))
|
||||
*
|
||||
*/
|
||||
export const rand = signal(timeToRand);
|
||||
export const rand = signal((t, controls) => getRandsAtTime(t, 1, controls.randSeed));
|
||||
/**
|
||||
* A continuous pattern of random numbers, between -1 and 1
|
||||
*/
|
||||
@@ -533,36 +623,32 @@ export const wchooseCycles = (...pairs) => _wchooseWith(rand.segment(1), ...pair
|
||||
|
||||
export const wrandcat = wchooseCycles;
|
||||
|
||||
function _perlin(t) {
|
||||
function _perlin(t, seed = 0) {
|
||||
let ta = Math.floor(t);
|
||||
let tb = ta + 1;
|
||||
const smootherStep = (x) => 6.0 * x ** 5 - 15.0 * x ** 4 + 10.0 * x ** 3;
|
||||
const interp = (x) => (a) => (b) => a + smootherStep(x) * (b - a);
|
||||
const v = interp(t - ta)(timeToRand(ta))(timeToRand(tb));
|
||||
const ra = getRandsAtTime(ta, 1, seed);
|
||||
const rb = getRandsAtTime(tb, 1, seed);
|
||||
const v = interp(t - ta)(ra)(rb);
|
||||
return v;
|
||||
}
|
||||
export const perlinWith = (tpat) => {
|
||||
return tpat.fmap(_perlin);
|
||||
};
|
||||
|
||||
function _berlin(t) {
|
||||
function _berlin(t, seed = 0) {
|
||||
const prevRidgeStartIndex = Math.floor(t);
|
||||
const nextRidgeStartIndex = prevRidgeStartIndex + 1;
|
||||
|
||||
const prevRidgeBottomPoint = timeToRand(prevRidgeStartIndex);
|
||||
const nextRidgeTopPoint = timeToRand(nextRidgeStartIndex) + prevRidgeBottomPoint;
|
||||
const prevRidgeBottomPoint = getRandsAtTime(prevRidgeStartIndex, 1, seed);
|
||||
const height = getRandsAtTime(nextRidgeStartIndex, 1, seed);
|
||||
const nextRidgeTopPoint = prevRidgeBottomPoint + height;
|
||||
|
||||
const currentPercent = (t - prevRidgeStartIndex) / (nextRidgeStartIndex - prevRidgeStartIndex);
|
||||
const interp = (a, b, t) => {
|
||||
return a + (b - a) * t;
|
||||
return a + t * (b - a);
|
||||
};
|
||||
return interp(prevRidgeBottomPoint, nextRidgeTopPoint, currentPercent) / 2;
|
||||
}
|
||||
|
||||
export const berlinWith = (tpat) => {
|
||||
return tpat.fmap(_berlin);
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a continuous pattern of [perlin noise](https://en.wikipedia.org/wiki/Perlin_noise), in the range 0..1.
|
||||
*
|
||||
@@ -572,7 +658,7 @@ export const berlinWith = (tpat) => {
|
||||
* s("bd*4,hh*8").cutoff(perlin.range(500,8000))
|
||||
*
|
||||
*/
|
||||
export const perlin = perlinWith(time.fmap((v) => Number(v)));
|
||||
export const perlin = signal((t, controls) => _perlin(t, controls.randSeed));
|
||||
|
||||
/**
|
||||
* Generates a continuous pattern of [berlin noise](conceived by Jame Coyne and Jade Rowland as a joke but turned out to be surprisingly cool and useful,
|
||||
@@ -584,7 +670,7 @@ export const perlin = perlinWith(time.fmap((v) => Number(v)));
|
||||
* n("0!16".add(berlin.fast(4).mul(14))).scale("d:minor")
|
||||
*
|
||||
*/
|
||||
export const berlin = berlinWith(time.fmap((v) => Number(v)));
|
||||
export const berlin = signal((t, controls) => _berlin(t, controls.randSeed));
|
||||
|
||||
export const degradeByWith = register(
|
||||
'degradeByWith',
|
||||
|
||||
@@ -46,4 +46,4 @@ all(osc)
|
||||
|
||||
[open in repl](https://strudel.cc/#JDogcygiYmQqNCIpCgphbGwob3NjKQ%3D%3D)
|
||||
|
||||
You can read more about [how to use Superdirt with Strudel the Tutorial](https://strudel.cc/learn/input-output/#superdirt-api)
|
||||
You can read more about [how to use Superdirt with Strudel](https://strudel.cc/learn/input-output/#oscsuperdirtstrudeldirt) in the tutorial.
|
||||
|
||||
@@ -166,7 +166,7 @@ export function registerSoundfonts() {
|
||||
let envEnd = holdEnd + release + 0.01;
|
||||
|
||||
// vibrato
|
||||
let vibratoOscillator = getVibratoOscillator(bufferSource.detune, value, time);
|
||||
const vibratoHandle = getVibratoOscillator(bufferSource.detune, value, time);
|
||||
// pitch envelope
|
||||
getPitchEnvelope(bufferSource.detune, value, time, holdEnd);
|
||||
|
||||
@@ -174,10 +174,10 @@ export function registerSoundfonts() {
|
||||
const stop = (releaseTime) => {};
|
||||
onceEnded(bufferSource, () => {
|
||||
releaseAudioNode(bufferSource);
|
||||
releaseAudioNode(vibratoOscillator);
|
||||
vibratoHandle?.stop();
|
||||
onended();
|
||||
});
|
||||
return { node, stop };
|
||||
return { node, stop, nodes: { source: [bufferSource], ...vibratoHandle?.nodes } };
|
||||
},
|
||||
{ type: 'soundfont', prebake: true, fonts },
|
||||
);
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
/*
|
||||
audioContext.mjs - Audio Context manager
|
||||
|
||||
Sets up a common and accessible audio context for all superdough operations
|
||||
|
||||
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/audiocontext.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/>.
|
||||
*/
|
||||
|
||||
let audioContext;
|
||||
|
||||
export const setDefaultAudioContext = () => {
|
||||
@@ -5,6 +14,11 @@ export const setDefaultAudioContext = () => {
|
||||
return audioContext;
|
||||
};
|
||||
|
||||
export const setAudioContext = (context) => {
|
||||
audioContext = context;
|
||||
return audioContext;
|
||||
};
|
||||
|
||||
export const getAudioContext = () => {
|
||||
if (!audioContext) {
|
||||
return setDefaultAudioContext();
|
||||
|
||||
@@ -5,19 +5,18 @@ if (typeof DelayNode !== 'undefined') {
|
||||
wet = Math.abs(wet);
|
||||
this.delayTime.value = time;
|
||||
|
||||
const feedbackGain = ac.createGain();
|
||||
feedbackGain.gain.value = Math.min(Math.abs(feedback), 0.995);
|
||||
this.feedback = feedbackGain.gain;
|
||||
this.feedbackGain = ac.createGain();
|
||||
this.feedbackGain.gain.value = Math.min(Math.abs(feedback), 0.995);
|
||||
this.feedback = this.feedbackGain.gain;
|
||||
|
||||
const delayGain = ac.createGain();
|
||||
delayGain.gain.value = wet;
|
||||
this.delayGain = delayGain;
|
||||
this.delayGain = ac.createGain();
|
||||
this.delayGain.gain.value = wet;
|
||||
|
||||
this.connect(feedbackGain);
|
||||
this.connect(delayGain);
|
||||
feedbackGain.connect(this);
|
||||
this.connect(this.feedbackGain);
|
||||
this.connect(this.delayGain);
|
||||
this.feedbackGain.connect(this);
|
||||
|
||||
this.connect = (target) => delayGain.connect(target);
|
||||
this.connect = (target) => this.delayGain.connect(target);
|
||||
return this;
|
||||
}
|
||||
start(t) {
|
||||
@@ -25,7 +24,7 @@ if (typeof DelayNode !== 'undefined') {
|
||||
}
|
||||
}
|
||||
|
||||
AudioContext.prototype.createFeedbackDelay = function (wet, time, feedback) {
|
||||
BaseAudioContext.prototype.createFeedbackDelay = function (wet, time, feedback) {
|
||||
return new FeedbackDelayNode(this, wet, time, feedback);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getAudioContext } from './audioContext.mjs';
|
||||
import { logger } from './logger.mjs';
|
||||
import { getNoiseBuffer } from './noise.mjs';
|
||||
import { getNodeFromPool } from './nodePools.mjs';
|
||||
import { clamp, nanFallback, midiToFreq, noteToMidi } from './util.mjs';
|
||||
|
||||
export const noises = ['pink', 'white', 'brown', 'crackle'];
|
||||
@@ -42,6 +43,7 @@ export const getParamADSR = (
|
||||
decay,
|
||||
sustain,
|
||||
release,
|
||||
// min = value at start of attack, max = value at end of attack; it is possible that max < min
|
||||
min,
|
||||
max,
|
||||
begin,
|
||||
@@ -59,17 +61,15 @@ export const getParamADSR = (
|
||||
max = max === 0 ? 0.001 : max;
|
||||
}
|
||||
const range = max - min;
|
||||
const peak = max;
|
||||
const sustainVal = min + sustain * range;
|
||||
const duration = end - begin;
|
||||
|
||||
const envValAtTime = (time) => {
|
||||
let val;
|
||||
if (attack > time) {
|
||||
let slope = getSlope(min, peak, 0, attack);
|
||||
val = time * slope + (min > peak ? min : 0);
|
||||
val = time * getSlope(min, max, 0, attack) + min;
|
||||
} else {
|
||||
val = (time - attack) * getSlope(peak, sustainVal, 0, decay) + peak;
|
||||
val = (time - attack) * getSlope(max, sustainVal, 0, decay) + max;
|
||||
}
|
||||
if (curve === 'exponential') {
|
||||
val = val || 0.001;
|
||||
@@ -105,22 +105,40 @@ function getModulationShapeInput(val) {
|
||||
return { tri: 0, triangle: 0, sine: 1, ramp: 2, saw: 3, square: 4 }[val] ?? 0;
|
||||
}
|
||||
|
||||
export function getLfo(audioContext, begin, end, properties = {}) {
|
||||
const { shape = 0, ...props } = properties;
|
||||
const { dcoffset = -0.5, depth = 1 } = properties;
|
||||
export function getEnvelope(audioContext, properties = {}) {
|
||||
return getWorklet(audioContext, 'envelope-processor', properties);
|
||||
}
|
||||
|
||||
export function getLfo(audioContext, properties = {}) {
|
||||
const {
|
||||
shape = 0,
|
||||
begin = 0,
|
||||
end = 0,
|
||||
time,
|
||||
depth = 1,
|
||||
dcoffset = -0.5,
|
||||
frequency = 1,
|
||||
skew = 0.5,
|
||||
phaseoffset = 0,
|
||||
curve = 1,
|
||||
min,
|
||||
max,
|
||||
...props
|
||||
} = properties;
|
||||
|
||||
const lfoprops = {
|
||||
frequency: 1,
|
||||
depth,
|
||||
skew: 0.5,
|
||||
phaseoffset: 0,
|
||||
time: begin,
|
||||
begin,
|
||||
end,
|
||||
shape: getModulationShapeInput(shape),
|
||||
time: time ?? begin,
|
||||
depth,
|
||||
dcoffset,
|
||||
min: dcoffset * depth,
|
||||
max: dcoffset * depth + depth,
|
||||
curve: 1,
|
||||
frequency,
|
||||
skew,
|
||||
phaseoffset,
|
||||
curve,
|
||||
shape: getModulationShapeInput(shape),
|
||||
min: min ?? dcoffset * depth,
|
||||
max: max ?? dcoffset * depth + depth,
|
||||
...props,
|
||||
};
|
||||
|
||||
@@ -128,6 +146,7 @@ export function getLfo(audioContext, begin, end, properties = {}) {
|
||||
}
|
||||
|
||||
export function getCompressor(ac, threshold, ratio, knee, attack, release) {
|
||||
const node = getNodeFromPool('compressor', () => new DynamicsCompressorNode(ac, {}));
|
||||
const options = {
|
||||
threshold: threshold ?? -3,
|
||||
ratio: ratio ?? 10,
|
||||
@@ -135,7 +154,11 @@ export function getCompressor(ac, threshold, ratio, knee, attack, release) {
|
||||
attack: attack ?? 0.005,
|
||||
release: release ?? 0.05,
|
||||
};
|
||||
return new DynamicsCompressorNode(ac, options);
|
||||
const now = ac.currentTime;
|
||||
Object.entries(options).forEach(([key, value]) => {
|
||||
node[key].setValueAtTime(value, now);
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
// changes the default values of the envelope based on what parameters the user has defined
|
||||
@@ -162,7 +185,9 @@ export function getParamLfo(audioContext, param, start, end, lfoValues) {
|
||||
}
|
||||
let lfo;
|
||||
if (depth) {
|
||||
lfo = getLfo(audioContext, start, end, {
|
||||
lfo = getLfo(audioContext, {
|
||||
begin: start,
|
||||
end,
|
||||
depth,
|
||||
dcoffset,
|
||||
...getLfoInputs,
|
||||
@@ -214,10 +239,13 @@ export function createFilter(context, start, end, params, cps, cycle) {
|
||||
filter = getWorklet(context, 'ladder-processor', { frequency, q, drive });
|
||||
frequencyParam = filter.parameters.get('frequency');
|
||||
} else {
|
||||
filter = context.createBiquadFilter();
|
||||
const factory = () => context.createBiquadFilter();
|
||||
filter = getNodeFromPool('filter', factory);
|
||||
filter.type = type;
|
||||
filter.Q.value = q;
|
||||
filter.frequency.value = frequency;
|
||||
const now = context.currentTime;
|
||||
Object.entries({ Q: q, frequency }).forEach(([key, value]) => {
|
||||
filter[key].setValueAtTime(value, now);
|
||||
});
|
||||
frequencyParam = filter.frequency;
|
||||
}
|
||||
const envelopeValues = [params.attack, params.decay, params.sustain, params.release];
|
||||
@@ -332,7 +360,7 @@ export function getVibratoOscillator(param, value, t) {
|
||||
releaseAudioNode(vibratoOscillator);
|
||||
});
|
||||
vibratoOscillator.start(t);
|
||||
return vibratoOscillator;
|
||||
return { stop: (t) => vibratoOscillator.stop(t), nodes: { vib: [vibratoOscillator], vib_gain: [gain] } };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,6 +416,7 @@ export function applyFM(param, value, begin) {
|
||||
const ac = getAudioContext();
|
||||
const toStop = []; // fm oscillators we will expose `stop` for
|
||||
const fms = {};
|
||||
const nodes = {};
|
||||
// Matrix
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
for (let j = 0; j <= 8; j++) {
|
||||
@@ -438,11 +467,14 @@ export function applyFM(param, value, begin) {
|
||||
output = osc.connect(envGain);
|
||||
}
|
||||
fms[idx] = { input: osc.frequency, output, freq, osc, toCleanup };
|
||||
nodes[`fm_${idx}`] = [osc];
|
||||
}
|
||||
const { input, output, freq, osc, toCleanup } = fms[idx];
|
||||
const g = gainNode(amt * freq);
|
||||
io.push(isMod ? output.connect(g) : input);
|
||||
cleanupOnEnd(osc, [...toCleanup, g]);
|
||||
const gAmt = gainNode(amt);
|
||||
const gFreq = gainNode(freq);
|
||||
io.push(isMod ? output.connect(gAmt).connect(gFreq) : input);
|
||||
cleanupOnEnd(osc, [...toCleanup, gAmt, gFreq]);
|
||||
nodes[`fm_${idx}_gain`] = [gAmt];
|
||||
}
|
||||
if (!io[1]) {
|
||||
logger(
|
||||
@@ -455,6 +487,7 @@ export function applyFM(param, value, begin) {
|
||||
}
|
||||
}
|
||||
return {
|
||||
nodes,
|
||||
stop: (t) => toStop.forEach((m) => m?.stop(t)),
|
||||
};
|
||||
}
|
||||
@@ -593,7 +626,7 @@ export const releaseAudioNode = (node) => {
|
||||
// make sure all AudioScheduledSourceNodes are in a stopped state
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/AudioScheduledSourceNode
|
||||
if (node instanceof AudioScheduledSourceNode) {
|
||||
if (node.onended && node.onended.name !== 'cleanup') {
|
||||
if (process.env.NODE_ENV === 'development' && node.onended && node.onended.name !== 'cleanup') {
|
||||
logger(
|
||||
`[superdough] Deprecation warning: it seems your code path is setting 'node.onended = callback' instead of using the onceEnded helper`,
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ export * from './helpers.mjs';
|
||||
export * from './synth.mjs';
|
||||
export * from './zzfx.mjs';
|
||||
export * from './logger.mjs';
|
||||
export * from './modulators.mjs';
|
||||
export * from './dspworklet.mjs';
|
||||
export * from './audioContext.mjs';
|
||||
export * from './wavetable.mjs';
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
modulators.mjs - Helpers for constructing modulators (envelopes, LFOs, etc.)
|
||||
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/modulators.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 { getAudioContext } from './audioContext.mjs';
|
||||
import { gainNode, getEnvelope, getLfo, webAudioTimeout } from './helpers.mjs';
|
||||
import { errorLogger } from './logger.mjs';
|
||||
import { getSuperdoughControlTargets } from './superdoughdata.mjs';
|
||||
import { clamp } from './util.mjs';
|
||||
|
||||
const getNodeParam = (node, name) => {
|
||||
// Worklet case
|
||||
if (node?.parameters) {
|
||||
const p = node.parameters.get(name);
|
||||
if (p instanceof AudioParam) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
// Built-in node case
|
||||
let p = node?.[name];
|
||||
if (p === undefined && name === 'frequency') {
|
||||
// Fallbacks for source nodes without 'frequency' params (e.g. soundfonts)
|
||||
p = node?.['detune'] ?? node?.['playbackRate'];
|
||||
}
|
||||
if (p instanceof AudioParam) {
|
||||
return p;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const controlTargets = getSuperdoughControlTargets();
|
||||
|
||||
const getControlData = (control, subControl) => {
|
||||
const controlNoIdx = control.split('_')[0];
|
||||
return controlTargets[`${controlNoIdx}_${subControl}`] ?? controlTargets[controlNoIdx];
|
||||
};
|
||||
|
||||
const getRangeForParam = (paramName, currentValue) => {
|
||||
// We clamp the frequency to a reasonable range unless the currentValue
|
||||
// is low, which indicates this may be an LFO
|
||||
if (paramName === 'frequency' && currentValue >= 30) {
|
||||
return { min: 20 - currentValue, max: 24000 - currentValue };
|
||||
}
|
||||
return { min: undefined, max: undefined };
|
||||
};
|
||||
|
||||
const clampWithWaveShaper = (modulator, min, max) => {
|
||||
const ac = getAudioContext();
|
||||
const curve = new Float32Array(256);
|
||||
for (let i = 0; i < curve.length; i++) {
|
||||
const x = (i / (curve.length - 1)) * 2 - 1;
|
||||
curve[i] = clamp(x * max, min, max);
|
||||
}
|
||||
const shaper = new WaveShaperNode(ac, { curve });
|
||||
const scaleGain = gainNode(1 / max);
|
||||
modulator.connect(scaleGain).connect(shaper);
|
||||
return { modulator, toCleanup: [shaper, scaleGain] };
|
||||
};
|
||||
|
||||
const getTargetParamsForControl = (control, nodes, subControl) => {
|
||||
const targetInfo = getControlData(control, subControl);
|
||||
if (!targetInfo) {
|
||||
errorLogger(
|
||||
new Error(`Could not find control data for target '${control}'. It may not be modulatable.`),
|
||||
'superdough',
|
||||
);
|
||||
return { targetParams: [], paramName: control };
|
||||
}
|
||||
const paramName = targetInfo.param;
|
||||
const nodeKey = nodes[targetInfo.node] ? targetInfo.node : control;
|
||||
const targetNodes = nodes[nodeKey];
|
||||
if (!targetNodes) {
|
||||
const keys = Object.keys(nodes);
|
||||
errorLogger(
|
||||
new Error(`Could not connect to target '${nodeKey}' — it does not exist. Available targets: ${keys.join(', ')}`),
|
||||
'superdough',
|
||||
);
|
||||
return { targetParams: [], paramName };
|
||||
}
|
||||
const audioParams = [];
|
||||
targetNodes.forEach((targetNode) => {
|
||||
const targetParam = getNodeParam(targetNode, paramName);
|
||||
audioParams.push(targetParam);
|
||||
});
|
||||
return { targetParams: audioParams, paramName };
|
||||
};
|
||||
|
||||
export const connectLFO = (id, params, nodeTracker) => {
|
||||
const {
|
||||
rate = 1,
|
||||
sync,
|
||||
cps,
|
||||
cycle,
|
||||
control = 'lfo',
|
||||
subControl,
|
||||
fxi = 'main',
|
||||
depth = 1,
|
||||
depthabs,
|
||||
...filteredParams
|
||||
} = params;
|
||||
const { targetParams, paramName } = getTargetParamsForControl(control, nodeTracker[fxi], subControl);
|
||||
if (!targetParams.length) return;
|
||||
let currentValue = targetParams[0].value;
|
||||
currentValue = currentValue === 0 ? 1 : currentValue;
|
||||
const { min, max } = getRangeForParam(paramName, currentValue);
|
||||
const depthValue = depthabs != null ? depthabs : depth * currentValue;
|
||||
const modParams = {
|
||||
...filteredParams,
|
||||
frequency: sync !== undefined ? sync * cps : rate,
|
||||
time: cycle / cps,
|
||||
depth: depthValue,
|
||||
min,
|
||||
max,
|
||||
};
|
||||
const lfoNode = getLfo(getAudioContext(), modParams);
|
||||
nodeTracker.main[`lfo_${id}`] = [lfoNode];
|
||||
targetParams.forEach((t) => lfoNode.connect(t));
|
||||
return lfoNode;
|
||||
};
|
||||
|
||||
export const connectEnvelope = (id, params, nodeTracker) => {
|
||||
const { control, subControl, acurve, dcurve, rcurve, depth = 1, depthabs, fxi = 'main', ...filteredParams } = params;
|
||||
const { targetParams, paramName } = getTargetParamsForControl(control, nodeTracker[fxi], subControl);
|
||||
if (!targetParams.length) return;
|
||||
let currentValue = targetParams[0].value;
|
||||
currentValue = currentValue === 0 ? 1 : currentValue;
|
||||
const { min, max } = getRangeForParam(paramName, currentValue);
|
||||
const depthValue = depthabs != null ? depthabs : depth * currentValue;
|
||||
const envNode = getEnvelope(getAudioContext(), {
|
||||
...filteredParams,
|
||||
depth: depthValue,
|
||||
min,
|
||||
max,
|
||||
attackCurve: acurve,
|
||||
decayCurve: dcurve,
|
||||
releaseCurve: rcurve,
|
||||
});
|
||||
nodeTracker.main[`env_${id}`] = [envNode];
|
||||
targetParams.forEach((t) => envNode.connect(t));
|
||||
return envNode;
|
||||
};
|
||||
|
||||
export const connectBusModulator = (params, nodeTracker, controller) => {
|
||||
const ac = getAudioContext();
|
||||
const { control, subControl, depth = 1, depthabs, fxi = 'main' } = params;
|
||||
const { targetParams, paramName } = getTargetParamsForControl(control, nodeTracker[fxi], subControl);
|
||||
if (!targetParams.length) return { toCleanup: [] };
|
||||
const signal = controller.getBus(params.bus);
|
||||
const dc = new ConstantSourceNode(ac, { offset: params.dc ?? 0 });
|
||||
dc.start(params.begin);
|
||||
const shifted = dc.connect(gainNode(1));
|
||||
signal.connect(shifted);
|
||||
let currentValue = targetParams[0].value;
|
||||
currentValue = currentValue === 0 ? 1 : currentValue;
|
||||
const { min, max } = getRangeForParam(paramName, currentValue);
|
||||
const depthValue = depthabs != null ? depthabs : depth * currentValue;
|
||||
const depthGain = gainNode((Math.sign(depthValue) * Math.abs(depthValue)) / 0.3);
|
||||
const unClamped = shifted.connect(depthGain);
|
||||
const toCleanup = [];
|
||||
let modulator = unClamped;
|
||||
if (min !== undefined && max !== undefined) {
|
||||
const wsData = clampWithWaveShaper(unClamped, min, max);
|
||||
modulator = wsData.modulator;
|
||||
toCleanup.push(...wsData.toCleanup);
|
||||
}
|
||||
webAudioTimeout(
|
||||
ac,
|
||||
() => {
|
||||
targetParams.forEach((t) => modulator.connect(t));
|
||||
},
|
||||
0,
|
||||
params.begin,
|
||||
);
|
||||
toCleanup.push(dc, shifted, depthGain);
|
||||
return { modulator, toCleanup };
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
nodePools.mjs - Helper functions related to pooling and re-using audio nodes
|
||||
|
||||
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/nodePools.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/>.
|
||||
*/
|
||||
|
||||
const nodePools = new Map();
|
||||
const POOL_KEY = Symbol('nodePoolKey');
|
||||
const IS_WORKLET_DEAD = Symbol('nodePoolIsWorkletDead');
|
||||
const MAX_POOL_SIZE = 64;
|
||||
|
||||
export const isPoolable = (node) => !!node[POOL_KEY];
|
||||
|
||||
const getParams = (node) => {
|
||||
const params = new Set();
|
||||
node.parameters?.forEach((param) => params.add(param));
|
||||
const visited = new Set(); // prioritize deepest definition
|
||||
let proto = node;
|
||||
// Move up the prototype chain
|
||||
while (proto !== Object.prototype) {
|
||||
for (const key of Object.getOwnPropertyNames(proto)) {
|
||||
if (visited.has(key)) continue;
|
||||
visited.add(key);
|
||||
const value = node[key];
|
||||
if (value instanceof AudioParam) {
|
||||
params.add(value);
|
||||
}
|
||||
}
|
||||
proto = Object.getPrototypeOf(proto);
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
export const releaseNodeToPool = (node) => {
|
||||
node.disconnect();
|
||||
if (node instanceof AudioScheduledSourceNode) {
|
||||
// not reusable
|
||||
return;
|
||||
}
|
||||
if (node[IS_WORKLET_DEAD]) {
|
||||
// Worklet already terminated, don't pool it
|
||||
return;
|
||||
}
|
||||
const key = node[POOL_KEY];
|
||||
if (key == null) return;
|
||||
const now = node.context?.currentTime ?? 0;
|
||||
getParams(node).forEach((param) => param.cancelScheduledValues(now));
|
||||
const pool = nodePools.get(key) ?? [];
|
||||
if (pool.length < MAX_POOL_SIZE) {
|
||||
pool.push(new WeakRef(node));
|
||||
nodePools.set(key, pool);
|
||||
}
|
||||
};
|
||||
|
||||
export const markWorkletAsDead = (worklet) => (worklet[IS_WORKLET_DEAD] = true);
|
||||
|
||||
// Attempt to get node from the pool. If this fails, fall back
|
||||
// to building it with the factory
|
||||
export const getNodeFromPool = (key, factory) => {
|
||||
const pool = nodePools.get(key) ?? [];
|
||||
let node;
|
||||
while (pool.length) {
|
||||
const ref = pool.pop();
|
||||
node = ref?.deref();
|
||||
if (node != null && !node[IS_WORKLET_DEAD]) break;
|
||||
}
|
||||
if (node == null || node[IS_WORKLET_DEAD]) {
|
||||
node = factory();
|
||||
}
|
||||
node[POOL_KEY] = key;
|
||||
return node;
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import reverbGen from './reverbGen.mjs';
|
||||
import { clamp } from './util.mjs';
|
||||
|
||||
if (typeof AudioContext !== 'undefined') {
|
||||
AudioContext.prototype.adjustLength = function (duration, buffer, speed = 1, offsetAmount = 0) {
|
||||
BaseAudioContext.prototype.adjustLength = function (duration, buffer, speed = 1, offsetAmount = 0) {
|
||||
const sampleOffset = Math.floor(clamp(offsetAmount, 0, 1) * buffer.length);
|
||||
const newLength = buffer.sampleRate * duration;
|
||||
const newBuffer = this.createBuffer(buffer.numberOfChannels, buffer.length, buffer.sampleRate);
|
||||
@@ -23,7 +23,7 @@ if (typeof AudioContext !== 'undefined') {
|
||||
return newBuffer;
|
||||
};
|
||||
|
||||
AudioContext.prototype.createReverb = function (duration, fade, lp, dim, ir, irspeed, irbegin) {
|
||||
BaseAudioContext.prototype.createReverb = function (duration, fade, lp, dim, ir, irspeed, irbegin) {
|
||||
const convolver = this.createConvolver();
|
||||
convolver.generate = (d = 2, fade = 0.1, lp = 15000, dim = 1000, ir, irspeed, irbegin) => {
|
||||
convolver.duration = d;
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { releaseAudioNode } from './helpers.mjs';
|
||||
|
||||
var reverbGen = {};
|
||||
|
||||
/** Generates a reverb impulse response.
|
||||
@@ -104,8 +106,8 @@ var applyGradualLowpass = function (input, lpFreqStart, lpFreqEnd, lpFreqEndAt,
|
||||
player.start();
|
||||
context.oncomplete = function (event) {
|
||||
callback(event.renderedBuffer);
|
||||
filter.disconnect();
|
||||
player.disconnect();
|
||||
releaseAudioNode(filter);
|
||||
releaseAudioNode(player);
|
||||
};
|
||||
context.startRendering();
|
||||
|
||||
|
||||
@@ -65,21 +65,22 @@ export const getSampleBufferSource = async (hapValue, bank, resolveUrl) => {
|
||||
bufferSource.playbackRate.value = playbackRate;
|
||||
|
||||
const { loopBegin = 0, loopEnd = 1, begin = 0, end = 1 } = hapValue;
|
||||
const bufferDuration = bufferSource.buffer.duration;
|
||||
|
||||
// "The computation of the offset into the sound is performed using the sound buffer's natural sample rate,
|
||||
// rather than the current playback rate, so even if the sound is playing at twice its normal speed,
|
||||
// the midway point through a 10-second audio buffer is still 5."
|
||||
const offset = begin * bufferSource.buffer.duration;
|
||||
// The computation of the offset into the sound is performed using the sound buffer's natural duration,
|
||||
// rather than the playback duration, so that even if the sound is playing at twice its normal speed,
|
||||
// the midway point through a 10-second audio buffer is still 5.
|
||||
const offset = begin * bufferDuration;
|
||||
|
||||
const loop = hapValue.loop;
|
||||
if (loop) {
|
||||
bufferSource.loop = true;
|
||||
bufferSource.loopStart = loopBegin * bufferSource.buffer.duration - offset;
|
||||
bufferSource.loopEnd = loopEnd * bufferSource.buffer.duration - offset;
|
||||
bufferSource.loopStart = loopBegin * bufferDuration;
|
||||
bufferSource.loopEnd = loopEnd * bufferDuration;
|
||||
}
|
||||
const bufferDuration = bufferSource.buffer.duration / bufferSource.playbackRate.value;
|
||||
const sliceDuration = (end - begin) * bufferDuration;
|
||||
return { bufferSource, offset, bufferDuration, sliceDuration };
|
||||
const playbackDuration = bufferDuration / bufferSource.playbackRate.value;
|
||||
const sliceDuration = (end - begin) * playbackDuration;
|
||||
return { bufferSource, offset, bufferDuration, playbackDuration, sliceDuration };
|
||||
};
|
||||
|
||||
export const loadBuffer = (url, ac, s, n = 0) => {
|
||||
@@ -301,7 +302,7 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) {
|
||||
}
|
||||
|
||||
// vibrato
|
||||
let vibratoOscillator = getVibratoOscillator(bufferSource.detune, value, t);
|
||||
const vibratoHandle = getVibratoOscillator(bufferSource.detune, value, t);
|
||||
|
||||
const time = t + nudge;
|
||||
bufferSource.start(time, offset);
|
||||
@@ -324,7 +325,7 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) {
|
||||
node.connect(out);
|
||||
onceEnded(bufferSource, function () {
|
||||
releaseAudioNode(bufferSource);
|
||||
releaseAudioNode(vibratoOscillator);
|
||||
vibratoHandle?.stop();
|
||||
releaseAudioNode(node);
|
||||
releaseAudioNode(out);
|
||||
onended();
|
||||
@@ -334,7 +335,7 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) {
|
||||
const stop = (endTime) => {
|
||||
bufferSource.stop(endTime);
|
||||
};
|
||||
const handle = { node: out, bufferSource, stop };
|
||||
const handle = { node: out, nodes: { source: [bufferSource], ...vibratoHandle?.nodes }, stop };
|
||||
|
||||
// cut groups
|
||||
if (cut !== undefined) {
|
||||
|
||||
+481
-249
@@ -7,34 +7,49 @@ This program is free software: you can redistribute it and/or modify it under th
|
||||
import './feedbackdelay.mjs';
|
||||
import './reverb.mjs';
|
||||
import './vowel.mjs';
|
||||
import { nanFallback, _mod, cycleToSeconds, pickAndRename } from './util.mjs';
|
||||
import { clamp, nanFallback, _mod, cycleToSeconds, pickAndRename } from './util.mjs';
|
||||
import workletsUrl from './worklets.mjs?audioworklet';
|
||||
import { getNodeFromPool, isPoolable, releaseNodeToPool } from './nodePools.mjs';
|
||||
import {
|
||||
createFilter,
|
||||
effectSend,
|
||||
gainNode,
|
||||
getCompressor,
|
||||
getDistortion,
|
||||
getLfo,
|
||||
getWorklet,
|
||||
effectSend,
|
||||
releaseAudioNode,
|
||||
webAudioTimeout,
|
||||
} from './helpers.mjs';
|
||||
import { map } from 'nanostores';
|
||||
import { logger } from './logger.mjs';
|
||||
import { connectLFO, connectEnvelope, connectBusModulator } from './modulators.mjs';
|
||||
import { loadBuffer } from './sampler.mjs';
|
||||
import { getAudioContext } from './audioContext.mjs';
|
||||
import { SuperdoughAudioController } from './superdoughoutput.mjs';
|
||||
import { resetSeenKeys } from './wavetable.mjs';
|
||||
|
||||
export const DEFAULT_MAX_POLYPHONY = 128;
|
||||
const DEFAULT_AUDIO_DEVICE_NAME = 'System Standard';
|
||||
|
||||
let maxPolyphony = DEFAULT_MAX_POLYPHONY;
|
||||
export let maxPolyphony = DEFAULT_MAX_POLYPHONY;
|
||||
|
||||
/**
|
||||
* Set the max polyphony. If notes are ringing out via `release` then they will
|
||||
* start to die out in first-in-first-out order once the max polyphony has been hit
|
||||
*
|
||||
* @name setMaxPolyphony
|
||||
* @param {number} Max polyphony. Defaults to 128
|
||||
* @example
|
||||
* setMaxPolyphony(4)
|
||||
* n(irand(24).seg(8)).scale("C#3:minor").room(1).release(4).gain(0.5)
|
||||
*
|
||||
*/
|
||||
export function setMaxPolyphony(polyphony) {
|
||||
maxPolyphony = parseInt(polyphony) ?? DEFAULT_MAX_POLYPHONY;
|
||||
}
|
||||
|
||||
let multiChannelOrbits = false;
|
||||
export let multiChannelOrbits = false;
|
||||
export function setMultiChannelOrbits(bool) {
|
||||
multiChannelOrbits = bool == true;
|
||||
}
|
||||
@@ -52,6 +67,17 @@ export function applyGainCurve(val) {
|
||||
return gainCurveFunc(val);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a function to all gains provided in patterns. Can be used to rescale gain to be
|
||||
* quadratic, exponential, etc. rather than linear
|
||||
*
|
||||
* @name setGainCurve
|
||||
* @param {Function} function to apply to all gain values
|
||||
* @example
|
||||
* setGainCurve((x) => x * x) // quadratic gain
|
||||
* s("bd*4").gain(0.5) // equivalent to 0.25 gain normally
|
||||
*
|
||||
*/
|
||||
export function setGainCurve(newGainCurveFunc) {
|
||||
gainCurveFunc = newGainCurveFunc;
|
||||
}
|
||||
@@ -161,6 +187,7 @@ let defaultDefaultValues = {
|
||||
distortvol: 1,
|
||||
distorttype: 0,
|
||||
delay: 0,
|
||||
busgain: 1,
|
||||
byteBeatExpression: '0',
|
||||
delayfeedback: 0.5,
|
||||
delaysync: 3 / 16,
|
||||
@@ -168,6 +195,9 @@ let defaultDefaultValues = {
|
||||
i: 1,
|
||||
velocity: 1,
|
||||
fft: 8,
|
||||
tremolodepth: 1,
|
||||
tremolophase: 0,
|
||||
release: 0.01,
|
||||
};
|
||||
|
||||
const defaultDefaultDefaultValues = Object.freeze({ ...defaultDefaultValues });
|
||||
@@ -212,11 +242,13 @@ export function registerWorklet(url) {
|
||||
}
|
||||
|
||||
let workletsLoading;
|
||||
function loadWorklets() {
|
||||
export function loadWorklets() {
|
||||
if (!workletsLoading) {
|
||||
const audioCtx = getAudioContext();
|
||||
const allWorkletURLs = externalWorklets.concat([workletsUrl]);
|
||||
workletsLoading = Promise.all(allWorkletURLs.map((workletURL) => audioCtx.audioWorklet.addModule(workletURL)));
|
||||
workletsLoading = Promise.all(allWorkletURLs.map((workletURL) => audioCtx.audioWorklet.addModule(workletURL))).then(
|
||||
() => (workletsLoading = undefined),
|
||||
);
|
||||
}
|
||||
|
||||
return workletsLoading;
|
||||
@@ -233,6 +265,7 @@ export async function initAudio(options = {}) {
|
||||
|
||||
setMaxPolyphony(maxPolyphony);
|
||||
setMultiChannelOrbits(multiChannelOrbits);
|
||||
resetSeenKeys();
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
@@ -254,8 +287,9 @@ export async function initAudio(options = {}) {
|
||||
logger('[superdough] failed to set audio interface', 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
await audioCtx.resume();
|
||||
if ((!audioCtx) instanceof OfflineAudioContext) {
|
||||
await audioCtx.resume();
|
||||
}
|
||||
if (disableWorklets) {
|
||||
logger('[superdough]: AudioWorklets disabled with disableWorklets');
|
||||
return;
|
||||
@@ -289,21 +323,27 @@ export function getSuperdoughAudioController() {
|
||||
}
|
||||
return controller;
|
||||
}
|
||||
|
||||
export function setSuperdoughAudioController(newController) {
|
||||
controller = newController;
|
||||
return controller;
|
||||
}
|
||||
|
||||
export function connectToDestination(input, channels) {
|
||||
const controller = getSuperdoughAudioController();
|
||||
controller.output.connectToDestination(input, channels);
|
||||
}
|
||||
|
||||
function getPhaser(time, end, frequency = 1, depth = 0.5, centerFrequency = 1000, sweep = 2000) {
|
||||
function getPhaser(begin, end, frequency = 1, depth = 0.5, centerFrequency = 1000, sweep = 2000) {
|
||||
const ac = getAudioContext();
|
||||
const lfo = getLfo(ac, time, end, { frequency, depth: sweep * 2 });
|
||||
const lfo = getLfo(ac, { frequency, depth: sweep * 2, begin, end });
|
||||
|
||||
//filters
|
||||
const numStages = 1; //num of filters in series
|
||||
let fOffset = 282; //for backward compat in #1800
|
||||
const filterChain = [];
|
||||
for (let i = 0; i < numStages; i++) {
|
||||
const filter = ac.createBiquadFilter();
|
||||
const filter = getNodeFromPool('filter', () => ac.createBiquadFilter());
|
||||
filter.type = 'notch';
|
||||
filter.gain.value = 1;
|
||||
filter.frequency.value = centerFrequency + fOffset;
|
||||
@@ -326,7 +366,7 @@ export let analysers = {},
|
||||
analysersData = {};
|
||||
|
||||
export function getAnalyserById(id, fftSize = 1024, smoothingTimeConstant = 0.5) {
|
||||
if (!analysers[id]) {
|
||||
if (!analysers[id] || analysers[id].context != getAudioContext()) {
|
||||
// make sure this doesn't happen too often as it piles up garbage
|
||||
const analyserNode = getAudioContext().createAnalyser();
|
||||
analyserNode.fftSize = fftSize;
|
||||
@@ -367,7 +407,37 @@ function mapChannelNumbers(channels) {
|
||||
return (Array.isArray(channels) ? channels : [channels]).map((ch) => ch - 1);
|
||||
}
|
||||
|
||||
class Chain {
|
||||
constructor(head) {
|
||||
this.audioNodes = [head];
|
||||
this.tails = [head];
|
||||
}
|
||||
connect(...nodes) {
|
||||
nodes.forEach((node) => {
|
||||
this.tails.forEach((tail) => {
|
||||
tail.connect(node);
|
||||
});
|
||||
});
|
||||
this.tails = nodes;
|
||||
this.audioNodes.push(...nodes);
|
||||
return this;
|
||||
}
|
||||
connectOne(idx, node) {
|
||||
this.tails[idx].connect(node);
|
||||
this.tails[idx] = node;
|
||||
this.audioNodes.push(node);
|
||||
return this;
|
||||
}
|
||||
releaseNodes() {
|
||||
this.audioNodes.forEach((n) => (isPoolable(n) ? releaseNodeToPool(n) : releaseAudioNode(n)));
|
||||
this.audioNodes = [];
|
||||
this.tails = [];
|
||||
}
|
||||
}
|
||||
|
||||
export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) => {
|
||||
// mapping from main FX and numbered FX chains to nodes
|
||||
const nodes = { main: {} };
|
||||
// new: t is always expected to be the absolute target onset time
|
||||
const ac = getAudioContext();
|
||||
const audioController = getSuperdoughAudioController();
|
||||
@@ -388,7 +458,6 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
|
||||
// duration is passed as value too..
|
||||
value.duration = hapDuration;
|
||||
// calculate absolute time
|
||||
|
||||
if (t < ac.currentTime) {
|
||||
console.warn(
|
||||
`[superdough]: cannot schedule sounds in the past (target: ${t.toFixed(2)}, now: ${ac.currentTime.toFixed(2)})`,
|
||||
@@ -397,49 +466,24 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
|
||||
}
|
||||
// destructure
|
||||
let {
|
||||
tremolo,
|
||||
tremolosync,
|
||||
tremolodepth = 1,
|
||||
tremoloskew,
|
||||
tremolophase = 0,
|
||||
tremoloshape,
|
||||
s = getDefaultValue('s'),
|
||||
bank,
|
||||
source,
|
||||
gain = getDefaultValue('gain'),
|
||||
postgain = getDefaultValue('postgain'),
|
||||
density = getDefaultValue('density'),
|
||||
duckorbit,
|
||||
duckonset,
|
||||
duckattack,
|
||||
duckdepth,
|
||||
djf,
|
||||
// filters
|
||||
fanchor = getDefaultValue('fanchor'),
|
||||
release = 0,
|
||||
|
||||
//phaser
|
||||
phaserrate,
|
||||
phaserdepth = getDefaultValue('phaserdepth'),
|
||||
phasersweep,
|
||||
phasercenter,
|
||||
//
|
||||
coarse,
|
||||
|
||||
crush,
|
||||
release = getDefaultValue('release'),
|
||||
dry,
|
||||
shape,
|
||||
shapevol = getDefaultValue('shapevol'),
|
||||
distort,
|
||||
distortvol = getDefaultValue('distortvol'),
|
||||
distorttype = getDefaultValue('distorttype'),
|
||||
pan,
|
||||
vowel,
|
||||
delay = getDefaultValue('delay'),
|
||||
delayfeedback = getDefaultValue('delayfeedback'),
|
||||
delaysync = getDefaultValue('delaysync'),
|
||||
delaytime,
|
||||
orbit = getDefaultValue('orbit'),
|
||||
bus,
|
||||
busgain = getDefaultValue('busgain'),
|
||||
room,
|
||||
roomfade,
|
||||
roomlp,
|
||||
@@ -449,16 +493,10 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
|
||||
irspeed,
|
||||
irbegin,
|
||||
i = getDefaultValue('i'),
|
||||
velocity = getDefaultValue('velocity'),
|
||||
analyze, // analyser wet
|
||||
fft = getDefaultValue('fft'), // fftSize 0 - 10
|
||||
compressor: compressorThreshold,
|
||||
compressorRatio,
|
||||
compressorKnee,
|
||||
compressorAttack,
|
||||
compressorRelease,
|
||||
transient,
|
||||
transsustain,
|
||||
FX = [],
|
||||
FXrelease,
|
||||
} = value;
|
||||
|
||||
delaytime = delaytime ?? cycleToSeconds(delaysync, cps);
|
||||
@@ -473,17 +511,13 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
|
||||
audioController.duck(duckorbit, t, duckonset, duckattack, duckdepth);
|
||||
}
|
||||
|
||||
gain = applyGainCurve(nanFallback(gain, 1));
|
||||
postgain = applyGainCurve(postgain);
|
||||
shapevol = applyGainCurve(shapevol);
|
||||
distortvol = applyGainCurve(distortvol);
|
||||
delay = applyGainCurve(delay);
|
||||
velocity = applyGainCurve(velocity);
|
||||
tremolodepth = applyGainCurve(tremolodepth);
|
||||
gain *= velocity; // velocity currently only multiplies with gain. it might do other things in the future
|
||||
busgain = applyGainCurve(busgain);
|
||||
|
||||
const end = t + hapDuration;
|
||||
const endWithRelease = end + release;
|
||||
const fullRelease = Math.max(release, FXrelease ?? 0);
|
||||
const endWithRelease = end + fullRelease;
|
||||
const chainID = Math.round(Math.random() * 1000000);
|
||||
|
||||
// oldest audio nodes will be destroyed if maximum polyphony is exceeded
|
||||
@@ -497,8 +531,6 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
|
||||
activeSoundSources.delete(chainID);
|
||||
}
|
||||
|
||||
let audioNodes = [];
|
||||
|
||||
if (['-', '~', '_'].includes(s)) {
|
||||
return;
|
||||
}
|
||||
@@ -511,17 +543,27 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
|
||||
let sourceNode;
|
||||
if (source) {
|
||||
sourceNode = source(t, value, hapDuration, cps);
|
||||
nodes.main['source'] = [sourceNode];
|
||||
} else if (getSound(s)) {
|
||||
const { onTrigger } = getSound(s);
|
||||
const onEnded = () => {
|
||||
audioNodes.forEach((n) => releaseAudioNode(n));
|
||||
activeSoundSources.delete(chainID);
|
||||
};
|
||||
|
||||
const onEnded = () =>
|
||||
webAudioTimeout(
|
||||
ac,
|
||||
() => {
|
||||
chain.releaseNodes();
|
||||
activeSoundSources.delete(chainID);
|
||||
},
|
||||
0,
|
||||
endWithRelease,
|
||||
);
|
||||
|
||||
const soundHandle = await onTrigger(t, value, onEnded, cps);
|
||||
|
||||
if (soundHandle) {
|
||||
sourceNode = soundHandle.node;
|
||||
activeSoundSources.set(chainID, new WeakRef(soundHandle)); // allow GC
|
||||
nodes.main = { ...nodes.main, ...soundHandle.nodes };
|
||||
}
|
||||
} else {
|
||||
throw new Error(`sound ${s} not found! Is it loaded?`);
|
||||
@@ -536,206 +578,345 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
|
||||
logger('[webaudio] skip hap: still loading', ac.currentTime - t);
|
||||
return;
|
||||
}
|
||||
const chain = []; // audio nodes that will be connected to each other sequentially
|
||||
chain.push(sourceNode);
|
||||
stretch !== undefined && chain.push(getWorklet(ac, 'phase-vocoder-processor', { pitchFactor: stretch }));
|
||||
|
||||
transient !== undefined &&
|
||||
chain.push(
|
||||
getWorklet(
|
||||
const chain = new Chain(sourceNode); // connection manager which tracks audio nodes for releasing
|
||||
FX = [...FX, value]; // run through the FX chain and then run through all FX outside of it as well
|
||||
for (let [idx, fx] of Object.entries(FX)) {
|
||||
const key = idx == FX.length - 1 ? 'main' : idx;
|
||||
nodes[key] ??= {};
|
||||
const fxNodes = nodes[key];
|
||||
let {
|
||||
gain = getDefaultValue('gain'),
|
||||
velocity = getDefaultValue('velocity'),
|
||||
shapevol = getDefaultValue('shapevol'),
|
||||
distorttype = getDefaultValue('distorttype'),
|
||||
distortvol = getDefaultValue('distortvol'),
|
||||
tremolodepth = getDefaultValue('tremolodepth'),
|
||||
phaserdepth = getDefaultValue('phaserdepth'),
|
||||
delay = getDefaultValue('delay'),
|
||||
delayfeedback = getDefaultValue('delayfeedback'),
|
||||
delaysync = getDefaultValue('delaysync'),
|
||||
delaytime,
|
||||
stretch = getDefaultValue('stretch'),
|
||||
i = getDefaultValue('i'),
|
||||
} = fx;
|
||||
gain = applyGainCurve(nanFallback(gain, 1));
|
||||
shapevol = applyGainCurve(shapevol);
|
||||
distortvol = applyGainCurve(distortvol);
|
||||
velocity = applyGainCurve(velocity);
|
||||
tremolodepth = applyGainCurve(tremolodepth);
|
||||
gain *= velocity; // velocity currently only multiplies with gain. it might do other things in the future
|
||||
delaytime = delaytime ?? cycleToSeconds(delaysync, cps);
|
||||
|
||||
if (stretch !== undefined) {
|
||||
const phaseVocoder = getWorklet(ac, 'phase-vocoder-processor', { pitchFactor: stretch });
|
||||
chain.connect(phaseVocoder);
|
||||
fxNodes['stretch'] = [phaseVocoder];
|
||||
}
|
||||
|
||||
if (fx.transient !== undefined) {
|
||||
const transProcessor = getWorklet(
|
||||
ac,
|
||||
'transient-processor',
|
||||
{},
|
||||
{
|
||||
processorOptions: {
|
||||
attack: transient,
|
||||
sustain: transsustain,
|
||||
attack: fx.transient,
|
||||
sustain: fx.transsustain,
|
||||
begin: t,
|
||||
end: endWithRelease,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
);
|
||||
chain.connect(transProcessor);
|
||||
fxNodes['transient'] = transProcessor;
|
||||
}
|
||||
|
||||
// gain stage
|
||||
chain.push(gainNode(gain));
|
||||
// gain stage
|
||||
const initialGain = gainNode(gain);
|
||||
fxNodes['gain'] = [initialGain];
|
||||
chain.connect(initialGain);
|
||||
|
||||
// filter
|
||||
const ftype = getFilterType(value.ftype);
|
||||
// filter
|
||||
const ftype = getFilterType(value.ftype);
|
||||
|
||||
if (value.cutoff !== undefined) {
|
||||
const lpMap = {
|
||||
frequency: 'cutoff',
|
||||
q: 'resonance',
|
||||
attack: 'lpattack',
|
||||
decay: 'lpdecay',
|
||||
sustain: 'lpsustain',
|
||||
release: 'lprelease',
|
||||
env: 'lpenv',
|
||||
anchor: 'fanchor',
|
||||
model: 'ftype',
|
||||
drive: 'drive',
|
||||
rate: 'lprate',
|
||||
sync: 'lpsync',
|
||||
depth: 'lpdepth',
|
||||
depthfrequency: 'lpdepthfrequency',
|
||||
shape: 'lpshape',
|
||||
dcoffset: 'lpdc',
|
||||
skew: 'lpskew',
|
||||
};
|
||||
const lpParams = pickAndRename(value, lpMap);
|
||||
lpParams.type = 'lowpass';
|
||||
const lp = () => createFilter(ac, t, end, lpParams, cps, cycle);
|
||||
const { filter: lpf1, lfo: lfo1 } = lp();
|
||||
chain.push(lpf1);
|
||||
lfo1 && audioNodes.push(lfo1);
|
||||
if (ftype === '24db') {
|
||||
const { filter: lpf2, lfo: lfo2 } = lp();
|
||||
chain.push(lpf2);
|
||||
lfo2 && audioNodes.push(lfo2);
|
||||
const filt = (params) => createFilter(ac, t, end, params, cps, cycle);
|
||||
if (fx.cutoff !== undefined) {
|
||||
const lpMap = {
|
||||
frequency: 'cutoff',
|
||||
q: 'resonance',
|
||||
attack: 'lpattack',
|
||||
decay: 'lpdecay',
|
||||
sustain: 'lpsustain',
|
||||
release: 'lprelease',
|
||||
env: 'lpenv',
|
||||
anchor: 'fanchor',
|
||||
model: 'ftype',
|
||||
drive: 'drive',
|
||||
rate: 'lprate',
|
||||
sync: 'lpsync',
|
||||
depth: 'lpdepth',
|
||||
depthfrequency: 'lpdepthfrequency',
|
||||
shape: 'lpshape',
|
||||
dcoffset: 'lpdc',
|
||||
skew: 'lpskew',
|
||||
};
|
||||
const lpParams = pickAndRename(fx, lpMap);
|
||||
lpParams.type = 'lowpass';
|
||||
const { filter: lpf1, lfo: lfo1 } = filt(lpParams);
|
||||
fxNodes['lpf'] = [lpf1];
|
||||
fxNodes['lpf_lfo'] = [lfo1];
|
||||
chain.connect(lpf1);
|
||||
lfo1 && chain.audioNodes.push(lfo1);
|
||||
if (ftype === '24db') {
|
||||
const { filter: lpf2, lfo: lfo2 } = filt(lpParams);
|
||||
fxNodes['lpf'].push(lpf2);
|
||||
fxNodes['lpf_lfo'].push(lfo2);
|
||||
chain.connect(lpf2);
|
||||
lfo2 && chain.audioNodes.push(lfo2);
|
||||
}
|
||||
}
|
||||
|
||||
if (fx.hcutoff !== undefined) {
|
||||
const hpMap = {
|
||||
frequency: 'hcutoff',
|
||||
q: 'hresonance',
|
||||
attack: 'hpattack',
|
||||
decay: 'hpdecay',
|
||||
sustain: 'hpsustain',
|
||||
release: 'hprelease',
|
||||
env: 'hpenv',
|
||||
anchor: 'fanchor',
|
||||
model: 'ftype',
|
||||
drive: 'drive',
|
||||
rate: 'hprate',
|
||||
sync: 'hpsync',
|
||||
depth: 'hpdepth',
|
||||
depthfrequency: 'hpdepthfrequency',
|
||||
shape: 'hpshape',
|
||||
dcoffset: 'hpdc',
|
||||
skew: 'hpskew',
|
||||
};
|
||||
const hpParams = pickAndRename(fx, hpMap);
|
||||
hpParams.type = 'highpass';
|
||||
const { filter: hpf1, lfo: lfo1 } = filt(hpParams);
|
||||
fxNodes['hpf'] = [hpf1];
|
||||
fxNodes['hpf_lfo'] = [lfo1];
|
||||
lfo1 && chain.audioNodes.push(lfo1);
|
||||
chain.connect(hpf1);
|
||||
if (ftype === '24db') {
|
||||
const { filter: hpf2, lfo: lfo2 } = filt(hpParams);
|
||||
fxNodes['hpf'].push(hpf2);
|
||||
fxNodes['hpf_lfo'].push(lfo2);
|
||||
chain.connect(hpf2);
|
||||
lfo2 && chain.audioNodes.push(lfo2);
|
||||
}
|
||||
}
|
||||
|
||||
if (fx.bandf !== undefined) {
|
||||
const bpMap = {
|
||||
frequency: 'bandf',
|
||||
q: 'bandq',
|
||||
attack: 'bpattack',
|
||||
decay: 'bpdecay',
|
||||
sustain: 'bpsustain',
|
||||
release: 'bprelease',
|
||||
env: 'bpenv',
|
||||
anchor: 'fanchor',
|
||||
model: 'ftype',
|
||||
drive: 'drive',
|
||||
rate: 'bprate',
|
||||
sync: 'bpsync',
|
||||
depth: 'bpdepth',
|
||||
depthfrequency: 'bpdepthfrequency',
|
||||
shape: 'bpshape',
|
||||
dcoffset: 'bpdc',
|
||||
skew: 'bpskew',
|
||||
};
|
||||
const bpParams = pickAndRename(fx, bpMap);
|
||||
bpParams.type = 'bandpass';
|
||||
const { filter: bpf1, lfo: lfo1 } = filt(bpParams);
|
||||
fxNodes['bpf'] = [bpf1];
|
||||
fxNodes['bpf_lfo'] = [lfo1];
|
||||
chain.connect(bpf1);
|
||||
lfo1 && chain.audioNodes.push(lfo1);
|
||||
if (ftype === '24db') {
|
||||
const { filter: bpf2, lfo: lfo2 } = filt(bpParams);
|
||||
fxNodes['bpf'].push(bpf2);
|
||||
fxNodes['bpf_lfo'].push(lfo2);
|
||||
chain.connect(bpf2);
|
||||
lfo2 && chain.audioNodes.push(lfo2);
|
||||
}
|
||||
}
|
||||
|
||||
if (fx.vowel !== undefined) {
|
||||
const vowelNode = ac.createVowelFilter(fx.vowel);
|
||||
fxNodes['vowel'] = vowelNode.filters;
|
||||
chain.connect(vowelNode);
|
||||
}
|
||||
|
||||
// effects
|
||||
if (fx.coarse !== undefined) {
|
||||
const coarseNode = getWorklet(ac, 'coarse-processor', { coarse: fx.coarse });
|
||||
fxNodes['coarse'] = [coarseNode];
|
||||
chain.connect(coarseNode);
|
||||
}
|
||||
if (fx.crush !== undefined) {
|
||||
const crushNode = getWorklet(ac, 'crush-processor', { crush: fx.crush });
|
||||
fxNodes['crush'] = [crushNode];
|
||||
chain.connect(crushNode);
|
||||
}
|
||||
if (fx.shape !== undefined) {
|
||||
const shapeNode = getWorklet(ac, 'shape-processor', { shape: fx.shape, postgain: shapevol });
|
||||
fxNodes['shape'] = [shapeNode];
|
||||
chain.connect(shapeNode);
|
||||
}
|
||||
if (fx.distort !== undefined) {
|
||||
const distortNode = getDistortion(fx.distort, distortvol, distorttype);
|
||||
fxNodes['distort'] = [distortNode];
|
||||
chain.connect(distortNode);
|
||||
}
|
||||
|
||||
let tremolo = fx.tremolo;
|
||||
if (fx.tremolosync != null) {
|
||||
tremolo = cps * fx.tremolosync;
|
||||
}
|
||||
|
||||
if (tremolo !== undefined) {
|
||||
// Allow clipping of modulator for more dynamic possiblities, and to prevent speaker overload
|
||||
// EX: a triangle waveform will clip like this /-\ when the depth is above 1
|
||||
const gain = Math.max(1 - tremolodepth, 0);
|
||||
const amGain = new GainNode(ac, { gain });
|
||||
|
||||
const time = cycle / cps;
|
||||
const lfo = getLfo(ac, {
|
||||
skew: fx.tremoloskew ?? (fx.tremoloshape != null ? 0.5 : 1),
|
||||
frequency: tremolo,
|
||||
depth: tremolodepth,
|
||||
time,
|
||||
dcoffset: 0,
|
||||
shape: fx.tremoloshape,
|
||||
phaseoffset: fx.tremolophase,
|
||||
min: 0,
|
||||
max: 1,
|
||||
curve: 1.5,
|
||||
begin: t,
|
||||
end: endWithRelease,
|
||||
});
|
||||
fxNodes['tremolo'] = [lfo];
|
||||
fxNodes['tremolo_gain'] = [amGain];
|
||||
lfo.connect(amGain.gain);
|
||||
chain.audioNodes.push(lfo);
|
||||
chain.connect(amGain);
|
||||
}
|
||||
|
||||
if (fx.compressor !== undefined) {
|
||||
const compressorNode = getCompressor(
|
||||
ac,
|
||||
fx.compressor,
|
||||
fx.compressorRatio,
|
||||
fx.compressorKnee,
|
||||
fx.compressorAttack,
|
||||
fx.compressorRelease,
|
||||
);
|
||||
fxNodes['compressor'] = [compressorNode];
|
||||
chain.connect(compressorNode);
|
||||
}
|
||||
|
||||
// panning
|
||||
if (fx.pan !== undefined) {
|
||||
const panner = ac.createStereoPanner();
|
||||
fxNodes['pan'] = [panner];
|
||||
panner.pan.value = 2 * fx.pan - 1;
|
||||
chain.connect(panner);
|
||||
}
|
||||
// phaser
|
||||
if (fx.phaserrate !== undefined && phaserdepth > 0) {
|
||||
const { filterChain, lfo } = getPhaser(
|
||||
t,
|
||||
endWithRelease,
|
||||
fx.phaserrate,
|
||||
phaserdepth,
|
||||
fx.phasercenter,
|
||||
fx.phasersweep,
|
||||
);
|
||||
fxNodes['phaser'] = [...filterChain];
|
||||
fxNodes['phaser_lfo'] = [lfo];
|
||||
filterChain.forEach((f) => chain.connect(f));
|
||||
chain.audioNodes.push(lfo);
|
||||
}
|
||||
// delay
|
||||
if (key !== 'main' && delay > 0 && delaytime > 0 && delayfeedback > 0) {
|
||||
const dry = gainNode(1);
|
||||
delayfeedback = clamp(delayfeedback, 0, 0.98);
|
||||
const delayNode = ac.createFeedbackDelay(1, delaytime, delayfeedback);
|
||||
const wetDelay = gainNode(delay);
|
||||
const dryDelay = gainNode(fx.dry ?? 1);
|
||||
const sum = new GainNode(ac, { gain: 1, channelCount: 2, channelCountMode: 'explicit' });
|
||||
chain
|
||||
.connect(dry)
|
||||
.connect(dryDelay, delayNode)
|
||||
.connectOne(1, wetDelay) // connect delayNode -> wetDelay
|
||||
.connect(sum);
|
||||
chain.audioNodes.push(delayNode.feedbackGain, delayNode.delayGain);
|
||||
fxNodes['delay'] = [delayNode];
|
||||
fxNodes['delay_mix'] = [wetDelay];
|
||||
}
|
||||
// reverb
|
||||
if (key !== 'main' && fx.room > 0) {
|
||||
let roomIR;
|
||||
if (fx.ir !== undefined) {
|
||||
let url;
|
||||
let sample = getSound(fx.ir);
|
||||
if (Array.isArray(sample)) {
|
||||
url = sample.data.samples[fx.i % sample.data.samples.length];
|
||||
} else if (typeof sample === 'object') {
|
||||
url = Object.values(sample.data.samples).flat()[i % Object.values(sample.data.samples).length];
|
||||
}
|
||||
roomIR = await loadBuffer(url, ac, fx.ir, 0);
|
||||
}
|
||||
const dry = gainNode(1);
|
||||
const reverbNode = ac.createReverb(
|
||||
fx.roomsize,
|
||||
fx.roomfade,
|
||||
fx.roomlp,
|
||||
fx.roomdim,
|
||||
roomIR,
|
||||
fx.irspeed,
|
||||
fx.irbegin,
|
||||
);
|
||||
const wetReverb = gainNode(fx.room);
|
||||
const dryReverb = gainNode(fx.dry ?? 1);
|
||||
const sum = new GainNode(ac, { gain: 1, channelCount: 2, channelCountMode: 'explicit' });
|
||||
chain
|
||||
.connect(dry)
|
||||
.connect(dryReverb, reverbNode)
|
||||
.connectOne(1, wetReverb) // connect reverbNode -> wetReverb
|
||||
.connect(sum);
|
||||
fxNodes['room'] = [reverbNode];
|
||||
fxNodes['room_mix'] = [wetReverb];
|
||||
}
|
||||
}
|
||||
|
||||
if (value.hcutoff !== undefined) {
|
||||
const hpMap = {
|
||||
frequency: 'hcutoff',
|
||||
q: 'hresonance',
|
||||
attack: 'hpattack',
|
||||
decay: 'hpdecay',
|
||||
sustain: 'hpsustain',
|
||||
release: 'hprelease',
|
||||
env: 'hpenv',
|
||||
anchor: 'fanchor',
|
||||
model: 'ftype',
|
||||
drive: 'drive',
|
||||
rate: 'hprate',
|
||||
sync: 'hpsync',
|
||||
depth: 'hpdepth',
|
||||
depthfrequency: 'hpdepthfrequency',
|
||||
shape: 'hpshape',
|
||||
dcoffset: 'hpdc',
|
||||
skew: 'hpskew',
|
||||
};
|
||||
const hpParams = pickAndRename(value, hpMap);
|
||||
hpParams.type = 'highpass';
|
||||
const hp = () => createFilter(ac, t, end, hpParams, cps, cycle);
|
||||
const { filter: hpf1, lfo: lfo1 } = hp();
|
||||
chain.push(hpf1);
|
||||
lfo1 && audioNodes.push(lfo1);
|
||||
if (ftype === '24db') {
|
||||
const { filter: hpf2, lfo: lfo2 } = hp();
|
||||
chain.push(hpf2);
|
||||
lfo2 && audioNodes.push(lfo2);
|
||||
}
|
||||
}
|
||||
|
||||
if (value.bandf !== undefined) {
|
||||
const bpMap = {
|
||||
frequency: 'bandf',
|
||||
q: 'bandq',
|
||||
attack: 'bpattack',
|
||||
decay: 'bpdecay',
|
||||
sustain: 'bpsustain',
|
||||
release: 'bprelease',
|
||||
env: 'bpenv',
|
||||
anchor: 'fanchor',
|
||||
model: 'ftype',
|
||||
drive: 'drive',
|
||||
rate: 'bprate',
|
||||
sync: 'bpsync',
|
||||
depth: 'bpdepth',
|
||||
depthfrequency: 'bpdepthfrequency',
|
||||
shape: 'bpshape',
|
||||
dcoffset: 'bpdc',
|
||||
skew: 'bpskew',
|
||||
};
|
||||
const bpParams = pickAndRename(value, bpMap);
|
||||
bpParams.type = 'bandpass';
|
||||
const bp = () => createFilter(ac, t, end, bpParams, cps, cycle);
|
||||
const { filter: bpf1, lfo: lfo1 } = bp();
|
||||
chain.push(bpf1);
|
||||
lfo1 && audioNodes.push(lfo1);
|
||||
if (ftype === '24db') {
|
||||
const { filter: bpf2, lfo: lfo2 } = bp();
|
||||
chain.push(bpf2);
|
||||
lfo2 && audioNodes.push(lfo2);
|
||||
}
|
||||
}
|
||||
|
||||
if (vowel !== undefined) {
|
||||
const vowelFilter = ac.createVowelFilter(vowel);
|
||||
chain.push(vowelFilter);
|
||||
}
|
||||
|
||||
// effects
|
||||
coarse !== undefined && chain.push(getWorklet(ac, 'coarse-processor', { coarse }));
|
||||
crush !== undefined && chain.push(getWorklet(ac, 'crush-processor', { crush }));
|
||||
shape !== undefined && chain.push(getWorklet(ac, 'shape-processor', { shape, postgain: shapevol }));
|
||||
distort !== undefined && chain.push(getDistortion(distort, distortvol, distorttype));
|
||||
|
||||
if (tremolosync != null) {
|
||||
tremolo = cps * tremolosync;
|
||||
}
|
||||
|
||||
if (value.wtPosSynced != null) {
|
||||
value.wtPosRate /= cps;
|
||||
}
|
||||
|
||||
if (value.wtWarpSynced != null) {
|
||||
value.wtWarpRate /= cps;
|
||||
}
|
||||
|
||||
if (tremolo !== undefined) {
|
||||
// Allow clipping of modulator for more dynamic possiblities, and to prevent speaker overload
|
||||
// EX: a triangle waveform will clip like this /-\ when the depth is above 1
|
||||
const gain = Math.max(1 - tremolodepth, 0);
|
||||
const amGain = new GainNode(ac, { gain });
|
||||
|
||||
const time = cycle / cps;
|
||||
const lfo = getLfo(ac, t, endWithRelease, {
|
||||
skew: tremoloskew ?? (tremoloshape != null ? 0.5 : 1),
|
||||
frequency: tremolo,
|
||||
depth: tremolodepth,
|
||||
time,
|
||||
dcoffset: 0,
|
||||
shape: tremoloshape,
|
||||
phaseoffset: tremolophase,
|
||||
min: 0,
|
||||
max: 1,
|
||||
curve: 1.5,
|
||||
});
|
||||
lfo.connect(amGain.gain);
|
||||
audioNodes.push(lfo);
|
||||
chain.push(amGain);
|
||||
}
|
||||
|
||||
compressorThreshold !== undefined &&
|
||||
chain.push(
|
||||
getCompressor(ac, compressorThreshold, compressorRatio, compressorKnee, compressorAttack, compressorRelease),
|
||||
);
|
||||
|
||||
// panning
|
||||
if (pan !== undefined) {
|
||||
const panner = ac.createStereoPanner();
|
||||
panner.pan.value = 2 * pan - 1;
|
||||
chain.push(panner);
|
||||
}
|
||||
// phaser
|
||||
if (phaserrate !== undefined && phaserdepth > 0) {
|
||||
const { filterChain, lfo } = getPhaser(t, endWithRelease, phaserrate, phaserdepth, phasercenter, phasersweep);
|
||||
audioNodes.push(lfo);
|
||||
chain.push(...filterChain);
|
||||
if (FXrelease !== undefined && FXrelease > release) {
|
||||
const releaseNode = gainNode(1);
|
||||
releaseNode.gain.setValueAtTime(1, end + release);
|
||||
releaseNode.gain.linearRampToValueAtTime(0, endWithRelease);
|
||||
chain.connect(releaseNode);
|
||||
}
|
||||
|
||||
// last gain
|
||||
const post = new GainNode(ac, { gain: postgain });
|
||||
chain.push(post);
|
||||
nodes.main['post'] = [post];
|
||||
chain.connect(post);
|
||||
|
||||
// delay
|
||||
if (delay > 0 && delaytime > 0 && delayfeedback > 0) {
|
||||
orbitBus.getDelay(delaytime, delayfeedback, t);
|
||||
const send = orbitBus.sendDelay(post, delay);
|
||||
audioNodes.push(send);
|
||||
const delayNode = orbitBus.getDelay(delaytime, delayfeedback, t);
|
||||
nodes.main['delay'] = [delayNode];
|
||||
const delaySend = orbitBus.sendDelay(post, delay);
|
||||
nodes.main['delay_mix'] = [delaySend];
|
||||
chain.audioNodes.push(delaySend);
|
||||
}
|
||||
// reverb
|
||||
if (room > 0) {
|
||||
@@ -750,33 +931,84 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
|
||||
}
|
||||
roomIR = await loadBuffer(url, ac, ir, 0);
|
||||
}
|
||||
orbitBus.getReverb(roomsize, roomfade, roomlp, roomdim, roomIR, irspeed, irbegin);
|
||||
const send = orbitBus.sendReverb(post, room);
|
||||
audioNodes.push(send);
|
||||
const roomNode = orbitBus.getReverb(roomsize, roomfade, roomlp, roomdim, roomIR, irspeed, irbegin);
|
||||
nodes.main['room'] = [roomNode];
|
||||
const reverbSend = orbitBus.sendReverb(post, room);
|
||||
nodes.main['room_mix'] = [reverbSend];
|
||||
chain.audioNodes.push(reverbSend);
|
||||
}
|
||||
if (bus != null) {
|
||||
const busNode = audioController.getBus(bus);
|
||||
const busSend = effectSend(post, busNode, busgain);
|
||||
chain.audioNodes.push(busSend);
|
||||
}
|
||||
|
||||
if (djf != null) {
|
||||
orbitBus.getDjf(djf, t);
|
||||
const djfNode = orbitBus.getDjf(djf, t);
|
||||
nodes.main['djf'] = [djfNode];
|
||||
}
|
||||
|
||||
// analyser
|
||||
if (analyze) {
|
||||
if (analyze && !(ac instanceof OfflineAudioContext)) {
|
||||
const analyserNode = getAnalyserById(analyze, 2 ** (fft + 5));
|
||||
const analyserSend = effectSend(post, analyserNode, 1);
|
||||
audioNodes.push(analyserSend);
|
||||
chain.audioNodes.push(analyserSend);
|
||||
}
|
||||
if (dry != null) {
|
||||
dry = applyGainCurve(dry);
|
||||
const dryGain = new GainNode(ac, { gain: dry });
|
||||
chain.push(dryGain);
|
||||
chain.connect(dryGain);
|
||||
orbitBus.connectToOutput(dryGain);
|
||||
} else {
|
||||
orbitBus.connectToOutput(post);
|
||||
}
|
||||
|
||||
// connect chain elements together
|
||||
chain.slice(1).reduce((last, current) => last.connect(current), chain[0]);
|
||||
audioNodes = audioNodes.concat(chain);
|
||||
// finally, now that `nodes` is populated, set up modulators
|
||||
FX.forEach((fx, idx) => {
|
||||
const key = idx === FX.length - 1 ? 'main' : idx;
|
||||
if (fx.lfo) {
|
||||
for (const id of fx.lfo.__ids) {
|
||||
const params = fx.lfo[id];
|
||||
params.fxi ??= key;
|
||||
const lfo = connectLFO(
|
||||
id,
|
||||
{
|
||||
...params,
|
||||
cps,
|
||||
cycle,
|
||||
begin: t,
|
||||
end: endWithRelease,
|
||||
},
|
||||
nodes,
|
||||
);
|
||||
lfo && chain.audioNodes.push(lfo);
|
||||
}
|
||||
}
|
||||
if (fx.env) {
|
||||
for (const id of fx.env.__ids) {
|
||||
const params = fx.env[id];
|
||||
params.fxi ??= key;
|
||||
const env = connectEnvelope(
|
||||
id,
|
||||
{
|
||||
...params,
|
||||
begin: t,
|
||||
end: endWithRelease,
|
||||
},
|
||||
nodes,
|
||||
);
|
||||
env && chain.audioNodes.push(env);
|
||||
}
|
||||
}
|
||||
if (fx.bmod) {
|
||||
for (const id of fx.bmod.__ids) {
|
||||
const params = fx.bmod[id];
|
||||
params.fxi ??= key;
|
||||
const { toCleanup } = connectBusModulator({ ...params, begin: t, end: endWithRelease }, nodes, controller);
|
||||
chain.audioNodes.push(...toCleanup);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const superdoughTrigger = (t, hap, ct, cps) => {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
superdoughdata.mjs - Data needed for running superdough (defaults, mappings, etc.)
|
||||
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdoughdata.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/>.
|
||||
*/
|
||||
|
||||
// Mapping from control name to webaudio node and parameter
|
||||
const CONTROL_TARGETS = {
|
||||
stretch: { node: 'stretch', param: 'pitchFactor' },
|
||||
gain: { node: 'gain', param: 'gain' },
|
||||
postgain: { node: 'post', param: 'gain' },
|
||||
pan: { node: 'pan', param: 'pan' },
|
||||
tremolo: { node: 'tremolo', param: 'frequency' },
|
||||
tremolosync: { node: 'tremolo', param: 'frequency' },
|
||||
tremolodepth: { node: 'tremolo_gain', param: 'gain' },
|
||||
tremoloskew: { node: 'tremolo', param: 'skew' },
|
||||
tremolophase: { node: 'tremolo', param: 'phase' },
|
||||
tremoloshape: { node: 'tremolo', param: 'shape' },
|
||||
|
||||
// MODULATORS
|
||||
lfo: { node: 'lfo', param: 'frequency' },
|
||||
lfo_rate: { node: 'lfo', param: 'frequency' },
|
||||
lfo_sync: { node: 'lfo', param: 'frequency' },
|
||||
lfo_depth: { node: 'lfo', param: 'depth' },
|
||||
lfo_depthabs: { node: 'lfo', param: 'depth' },
|
||||
lfo_skew: { node: 'lfo', param: 'skew' },
|
||||
lfo_curve: { node: 'lfo', param: 'curve' },
|
||||
lfo_dcoffset: { node: 'lfo', param: 'dcoffset' },
|
||||
env: { node: 'env', param: 'depth' },
|
||||
env_attack: { node: 'env', param: 'attack' },
|
||||
env_decay: { node: 'env', param: 'decay' },
|
||||
env_sustain: { node: 'env', param: 'sustain' },
|
||||
env_release: { node: 'env', param: 'release' },
|
||||
bmod: { node: 'bmod', param: 'depth' },
|
||||
bmod_depth: { node: 'bmod', param: 'depth' },
|
||||
bmod_depthabs: { node: 'bmod', param: 'depth' },
|
||||
|
||||
// LPF
|
||||
cutoff: { node: 'lpf', param: 'frequency' },
|
||||
resonance: { node: 'lpf', param: 'Q' },
|
||||
lprate: { node: 'lpf_lfo', param: 'rate' },
|
||||
lpsync: { node: 'lpf_lfo', param: 'sync' },
|
||||
lpdepth: { node: 'lpf_lfo', param: 'depth' },
|
||||
lpdepthfrequency: { node: 'lpf_lfo', param: 'depth' },
|
||||
lpshape: { node: 'lpf_lfo', param: 'shape' },
|
||||
lpdc: { node: 'lpf_lfo', param: 'dcoffset' },
|
||||
lpskew: { node: 'lpf_lfo', param: 'skew' },
|
||||
|
||||
// HPF
|
||||
hcutoff: { node: 'hpf', param: 'frequency' },
|
||||
hresonance: { node: 'hpf', param: 'Q' },
|
||||
hprate: { node: 'hpf_lfo', param: 'rate' },
|
||||
hpsync: { node: 'hpf_lfo', param: 'sync' },
|
||||
hpdepth: { node: 'hpf_lfo', param: 'depth' },
|
||||
hpdepthfrequency: { node: 'hpf_lfo', param: 'depth' },
|
||||
hpshape: { node: 'hpf_lfo', param: 'shape' },
|
||||
hpdc: { node: 'hpf_lfo', param: 'dcoffset' },
|
||||
hpskew: { node: 'hpf_lfo', param: 'skew' },
|
||||
|
||||
// BPF
|
||||
bandf: { node: 'bpf', param: 'frequency' },
|
||||
bandq: { node: 'bpf', param: 'Q' },
|
||||
bprate: { node: 'bpf_lfo', param: 'rate' },
|
||||
bpsync: { node: 'bpf_lfo', param: 'sync' },
|
||||
bpdepth: { node: 'bpf_lfo', param: 'depth' },
|
||||
bpdepthfrequency: { node: 'bpf_lfo', param: 'depth' },
|
||||
bpshape: { node: 'bpf_lfo', param: 'shape' },
|
||||
bpdc: { node: 'bpf_lfo', param: 'dcoffset' },
|
||||
bpskew: { node: 'bpf_lfo', param: 'skew' },
|
||||
|
||||
vowel: { node: 'vowel', param: 'frequency' },
|
||||
|
||||
// DISTORTION
|
||||
coarse: { node: 'coarse', param: 'coarse' },
|
||||
crush: { node: 'crush', param: 'crush' },
|
||||
shape: { node: 'shape', param: 'shape' },
|
||||
shapevol: { node: 'shape', param: 'postgain' },
|
||||
distort: { node: 'distort', param: 'distort' },
|
||||
distortvol: { node: 'distort', param: 'postgain' },
|
||||
distorttype: { node: 'distort', param: 'distort' },
|
||||
|
||||
// COMPRESSOR
|
||||
compressor: { node: 'compressor', param: 'threshold' },
|
||||
compressorRatio: { node: 'compressor', param: 'ratio' },
|
||||
compressorKnee: { node: 'compressor', param: 'knee' },
|
||||
compressorAttack: { node: 'compressor', param: 'attack' },
|
||||
compressorRelease: { node: 'compressor', param: 'release' },
|
||||
|
||||
// PHASER
|
||||
phaserrate: { node: 'phaser_lfo', param: 'frequency' },
|
||||
phasersweep: { node: 'phaser_lfo', param: 'depth' },
|
||||
phasercenter: { node: 'phaser', param: 'frequency' },
|
||||
phaserdepth: { node: 'phaser', param: 'Q' },
|
||||
|
||||
// ORBIT EFFECTS
|
||||
delay: { node: 'delay_mix', param: 'gain' },
|
||||
delaytime: { node: 'delay', param: 'delayTime' },
|
||||
delayfeedback: { node: 'delay', param: 'feedback' },
|
||||
delaysync: { node: 'delay', param: 'delayTime' },
|
||||
dry: { node: 'dry', param: 'gain' },
|
||||
room: { node: 'room_mix', param: 'gain' },
|
||||
djf: { node: 'djf', param: 'value' },
|
||||
busgain: { node: 'bus', param: 'gain' },
|
||||
|
||||
// SYNTHS
|
||||
s: { node: 'source', param: 'frequency' },
|
||||
detune: { node: 'source', param: 'freqspread' },
|
||||
wt: { node: 'source', param: 'position' },
|
||||
warp: { node: 'source', param: 'warp' },
|
||||
freq: { node: 'source', param: 'frequency' },
|
||||
note: { node: 'source', param: 'frequency' },
|
||||
wtdc: { node: 'wt_lfo', param: 'dc' },
|
||||
wtskew: { node: 'wt_lfo', param: 'skew' },
|
||||
wtrate: { node: 'wt_lfo', param: 'frequency' },
|
||||
wtsync: { node: 'wt_lfo', param: 'frequency' },
|
||||
wtdepth: { node: 'wt_lfo', param: 'depth' },
|
||||
warpdc: { node: 'warp_lfo', param: 'dc' },
|
||||
warpskew: { node: 'warp_lfo', param: 'skew' },
|
||||
warprate: { node: 'warp_lfo', param: 'frequency' },
|
||||
warpsync: { node: 'warp_lfo', param: 'frequency' },
|
||||
warpdepth: { node: 'warp_lfo', param: 'depth' },
|
||||
fmi: { node: 'fm_1_gain', param: 'gain' },
|
||||
fmi2: { node: 'fm_2_gain', param: 'gain' },
|
||||
fmi3: { node: 'fm_3_gain', param: 'gain' },
|
||||
fmi4: { node: 'fm_4_gain', param: 'gain' },
|
||||
fmi5: { node: 'fm_5_gain', param: 'gain' },
|
||||
fmi6: { node: 'fm_6_gain', param: 'gain' },
|
||||
fmi7: { node: 'fm_7_gain', param: 'gain' },
|
||||
fmi8: { node: 'fm_8_gain', param: 'gain' },
|
||||
fmh: { node: 'fm_1', param: 'frequency' },
|
||||
fmh2: { node: 'fm_2', param: 'frequency' },
|
||||
fmh3: { node: 'fm_3', param: 'frequency' },
|
||||
fmh4: { node: 'fm_4', param: 'frequency' },
|
||||
fmh5: { node: 'fm_5', param: 'frequency' },
|
||||
fmh6: { node: 'fm_6', param: 'frequency' },
|
||||
fmh7: { node: 'fm_7', param: 'frequency' },
|
||||
fmh8: { node: 'fm_8', param: 'frequency' },
|
||||
pw: { node: 'source', param: 'pulsewidth' },
|
||||
pwrate: { node: 'pw_lfo', param: 'frequency' },
|
||||
pwsweep: { node: 'pw_lfo', param: 'depth' },
|
||||
vib: { node: 'vib', param: 'frequency' },
|
||||
vibmod: { node: 'vib_gain', param: 'gain' },
|
||||
byteBeatStartTime: { node: 'source', param: 'byteBeatStartTime' },
|
||||
spread: { node: 'source', param: 'panspread' },
|
||||
transient: { node: 'transient', param: 'attack' },
|
||||
};
|
||||
|
||||
export function getSuperdoughControlTargets() {
|
||||
return CONTROL_TARGETS;
|
||||
}
|
||||
@@ -1,8 +1,20 @@
|
||||
/*
|
||||
superdoughoutput.mjs - Output controller for superdough
|
||||
|
||||
Handles setting up and mixing to the outputs as well as all global (orbit) effects
|
||||
|
||||
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdoughoutput.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 { effectSend, getWorklet, webAudioTimeout } from './helpers.mjs';
|
||||
import { errorLogger } from './logger.mjs';
|
||||
import { clamp } from './util.mjs';
|
||||
|
||||
let hasChanged = (now, before) => now !== undefined && now !== before;
|
||||
const hasChanged = (now, before) => now !== undefined && now !== before;
|
||||
// Node with fixed stereo channel count to prevent clicking when the input signal
|
||||
// switches from mono to stereo
|
||||
const getStereoNode = (ac) => new GainNode(ac, { gain: 1, channelCount: 2, channelCountMode: 'explicit' });
|
||||
|
||||
export class Orbit {
|
||||
reverbNode;
|
||||
@@ -11,10 +23,11 @@ export class Orbit {
|
||||
summingNode;
|
||||
djfNode;
|
||||
audioContext;
|
||||
|
||||
constructor(audioContext) {
|
||||
this.audioContext = audioContext;
|
||||
this.output = new GainNode(audioContext, { gain: 1, channelCount: 2, channelCountMode: 'explicit' });
|
||||
this.summingNode = new GainNode(audioContext, { gain: 1, channelCount: 2, channelCountMode: 'explicit' });
|
||||
this.output = getStereoNode(audioContext);
|
||||
this.summingNode = getStereoNode(audioContext);
|
||||
this.summingNode.connect(this.output);
|
||||
}
|
||||
|
||||
@@ -34,6 +47,7 @@ export class Orbit {
|
||||
}
|
||||
const val = this.djfNode.parameters.get('value');
|
||||
val.setValueAtTime(value, t);
|
||||
return this.djfNode;
|
||||
}
|
||||
|
||||
getDelay(delaytime = 0, feedback = 0.5, t) {
|
||||
@@ -164,6 +178,7 @@ export class SuperdoughAudioController {
|
||||
audioContext;
|
||||
output;
|
||||
nodes = {};
|
||||
buses = {};
|
||||
|
||||
constructor(audioContext) {
|
||||
this.audioContext = audioContext;
|
||||
@@ -171,10 +186,14 @@ export class SuperdoughAudioController {
|
||||
}
|
||||
|
||||
reset() {
|
||||
Array.from(this.nodes).forEach((node) => {
|
||||
Object.values(this.nodes).forEach((node) => {
|
||||
node.disconnect();
|
||||
});
|
||||
Object.values(this.buses).forEach((bus) => {
|
||||
bus.disconnect();
|
||||
});
|
||||
this.nodes = {};
|
||||
this.buses = {};
|
||||
this.output.reset();
|
||||
}
|
||||
|
||||
@@ -206,4 +225,11 @@ export class SuperdoughAudioController {
|
||||
}
|
||||
return this.nodes[orbitNum];
|
||||
}
|
||||
|
||||
getBus(busNum) {
|
||||
if (this.buses[busNum] == null) {
|
||||
this.buses[busNum] = getStereoNode(this.audioContext);
|
||||
}
|
||||
return this.buses[busNum];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { clamp } from './util.mjs';
|
||||
import { registerSound, soundMap } from './superdough.mjs';
|
||||
import { getSuperdoughAudioController, registerSound, soundMap } from './superdough.mjs';
|
||||
import { getAudioContext } from './audioContext.mjs';
|
||||
import {
|
||||
applyFM,
|
||||
@@ -18,8 +18,9 @@ import {
|
||||
} from './helpers.mjs';
|
||||
import { logger } from './logger.mjs';
|
||||
import { getNoiseMix, getNoiseOscillator } from './noise.mjs';
|
||||
import { getNodeFromPool, markWorkletAsDead, releaseNodeToPool } from './nodePools.mjs';
|
||||
|
||||
const waveforms = ['triangle', 'square', 'sawtooth', 'sine', 'user'];
|
||||
const waveforms = ['triangle', 'square', 'sawtooth', 'sine', 'user', 'one'];
|
||||
const waveformAliases = [
|
||||
['tri', 'triangle'],
|
||||
['sqr', 'square'],
|
||||
@@ -52,17 +53,17 @@ export function registerSynthSounds() {
|
||||
// turn down
|
||||
const g = gainNode(0.3);
|
||||
|
||||
let sound = getOscillator(s, t, value, () => {
|
||||
const sound = getOscillator(s, t, value, () => {
|
||||
releaseAudioNode(g);
|
||||
onended();
|
||||
});
|
||||
|
||||
let { node: o, stop, triggerRelease } = sound;
|
||||
const { node: o, nodes, stop, triggerRelease } = sound;
|
||||
|
||||
const { duration } = value;
|
||||
|
||||
const envGain = gainNode(1);
|
||||
let node = o.connect(g).connect(envGain);
|
||||
const node = o.connect(g).connect(envGain);
|
||||
const holdEnd = t + duration;
|
||||
getParamADSR(node.gain, attack, decay, sustain, release, 0, 1, t, holdEnd, 'linear');
|
||||
const envEnd = holdEnd + release + 0.01;
|
||||
@@ -70,6 +71,7 @@ export function registerSynthSounds() {
|
||||
stop(envEnd);
|
||||
return {
|
||||
node,
|
||||
nodes,
|
||||
stop: (endTime) => {
|
||||
stop(endTime);
|
||||
},
|
||||
@@ -139,6 +141,7 @@ export function registerSynthSounds() {
|
||||
|
||||
return {
|
||||
node,
|
||||
nodes: { source: [o] },
|
||||
stop: (endTime) => {
|
||||
o.stop(endTime);
|
||||
},
|
||||
@@ -165,26 +168,31 @@ export function registerSynthSounds() {
|
||||
const end = holdend + release + 0.01;
|
||||
const voices = clamp(unison, 1, 100);
|
||||
let panspread = voices > 1 ? clamp(spread, 0, 1) : 0;
|
||||
let o = getWorklet(
|
||||
ac,
|
||||
'supersaw-oscillator',
|
||||
{
|
||||
frequency,
|
||||
begin,
|
||||
end,
|
||||
freqspread: detune,
|
||||
voices,
|
||||
panspread,
|
||||
},
|
||||
{
|
||||
outputChannelCount: [2],
|
||||
},
|
||||
);
|
||||
|
||||
const params = {
|
||||
frequency,
|
||||
begin,
|
||||
end,
|
||||
freqspread: detune,
|
||||
voices,
|
||||
panspread,
|
||||
};
|
||||
const factory = () => new AudioWorkletNode(ac, 'supersaw-oscillator', { outputChannelCount: [2] });
|
||||
const o = getNodeFromPool('supersaw', factory);
|
||||
const now = ac.currentTime;
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
const param = o.parameters.get(key);
|
||||
const target = value !== undefined ? value : param.defaultValue;
|
||||
param.setValueAtTime(target, now);
|
||||
});
|
||||
o.port.postMessage({ type: 'initialize' });
|
||||
o.port.onmessage = (e) => {
|
||||
if (e.data.type === 'died') markWorkletAsDead(o);
|
||||
o.port.onmessage = null;
|
||||
};
|
||||
const gainAdjustment = 1 / Math.sqrt(voices);
|
||||
getPitchEnvelope(o.parameters.get('detune'), value, begin, holdend);
|
||||
const vibratoOscillator = getVibratoOscillator(o.parameters.get('detune'), value, begin);
|
||||
const fm = applyFM(o.parameters.get('frequency'), value, begin);
|
||||
const vibratoHandle = getVibratoOscillator(o.parameters.get('detune'), value, begin);
|
||||
const fmHandle = applyFM(o.parameters.get('frequency'), value, begin);
|
||||
let envGain = gainNode(1);
|
||||
envGain = o.connect(envGain);
|
||||
|
||||
@@ -193,10 +201,10 @@ export function registerSynthSounds() {
|
||||
let timeoutNode = webAudioTimeout(
|
||||
ac,
|
||||
() => {
|
||||
releaseAudioNode(o);
|
||||
releaseNodeToPool(o);
|
||||
onended();
|
||||
fm?.stop();
|
||||
vibratoOscillator?.stop();
|
||||
fmHandle?.stop();
|
||||
vibratoHandle?.stop();
|
||||
},
|
||||
begin,
|
||||
end,
|
||||
@@ -204,6 +212,7 @@ export function registerSynthSounds() {
|
||||
|
||||
return {
|
||||
node: envGain,
|
||||
nodes: { source: [o], ...fmHandle?.nodes, ...vibratoHandle?.nodes },
|
||||
stop: (time) => {
|
||||
timeoutNode.stop(time);
|
||||
},
|
||||
@@ -279,6 +288,7 @@ export function registerSynthSounds() {
|
||||
|
||||
return {
|
||||
node: envGain,
|
||||
source: o,
|
||||
stop: (time) => {
|
||||
timeoutNode.stop(time);
|
||||
},
|
||||
@@ -329,25 +339,25 @@ export function registerSynthSounds() {
|
||||
);
|
||||
|
||||
getPitchEnvelope(o.parameters.get('detune'), value, begin, holdend);
|
||||
const vibratoOscillator = getVibratoOscillator(o.parameters.get('detune'), value, begin);
|
||||
const fm = applyFM(o.parameters.get('frequency'), value, begin);
|
||||
const vibratoHandle = getVibratoOscillator(o.parameters.get('detune'), value, begin);
|
||||
const fmHandle = applyFM(o.parameters.get('frequency'), value, begin);
|
||||
let envGain = gainNode(1);
|
||||
envGain = o.connect(envGain);
|
||||
|
||||
getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 1, begin, holdend, 'linear');
|
||||
let lfo;
|
||||
let pw_lfo;
|
||||
if (pwsweep != 0) {
|
||||
lfo = getLfo(ac, begin, end, { frequency: pwrate, depth: pwsweep });
|
||||
lfo.connect(o.parameters.get('pulsewidth'));
|
||||
pw_lfo = getLfo(ac, { frequency: pwrate, depth: pwsweep, begin, end });
|
||||
pw_lfo.connect(o.parameters.get('pulsewidth'));
|
||||
}
|
||||
let timeoutNode = webAudioTimeout(
|
||||
ac,
|
||||
() => {
|
||||
releaseAudioNode(o);
|
||||
releaseAudioNode(lfo);
|
||||
releaseAudioNode(pw_lfo);
|
||||
onended();
|
||||
fm?.stop();
|
||||
vibratoOscillator?.stop();
|
||||
fmHandle?.stop();
|
||||
vibratoHandle?.stop();
|
||||
},
|
||||
begin,
|
||||
end,
|
||||
@@ -355,6 +365,7 @@ export function registerSynthSounds() {
|
||||
|
||||
return {
|
||||
node: envGain,
|
||||
nodes: { source: [o], pw_lfo: [pw_lfo], ...fmHandle?.nodes, ...vibratoHandle?.nodes },
|
||||
stop: (time) => {
|
||||
timeoutNode.stop(time);
|
||||
},
|
||||
@@ -363,6 +374,41 @@ export function registerSynthSounds() {
|
||||
{ prebake: true, type: 'synth' },
|
||||
);
|
||||
|
||||
registerSound(
|
||||
'bus',
|
||||
(begin, value, onended) => {
|
||||
const ac = getAudioContext();
|
||||
const [attack, decay, sustain, release] = getADSRValues(
|
||||
[value.attack, value.decay, value.sustain, value.release],
|
||||
'linear',
|
||||
[0.001, 0.05, 1, 0.01],
|
||||
);
|
||||
const holdend = begin + value.duration;
|
||||
const end = holdend + release + 0.01;
|
||||
const bus = getSuperdoughAudioController().getBus(value.n ?? 0);
|
||||
const envGain = bus.connect(gainNode(0));
|
||||
getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 1, begin, holdend, 'linear');
|
||||
const timeoutNode = webAudioTimeout(
|
||||
ac,
|
||||
() => {
|
||||
bus.disconnect(envGain);
|
||||
onended();
|
||||
},
|
||||
begin,
|
||||
end,
|
||||
);
|
||||
|
||||
return {
|
||||
node: envGain,
|
||||
nodes: { source: [bus] },
|
||||
stop: (time) => {
|
||||
timeoutNode.stop(time);
|
||||
},
|
||||
};
|
||||
},
|
||||
{ prebake: true, type: 'input' },
|
||||
);
|
||||
|
||||
[...noises].forEach((s) => {
|
||||
registerSound(
|
||||
s,
|
||||
@@ -400,6 +446,7 @@ export function registerSynthSounds() {
|
||||
stop(envEnd);
|
||||
return {
|
||||
node,
|
||||
nodes: { source: [o] },
|
||||
stop: (endTime) => {
|
||||
stop(endTime);
|
||||
},
|
||||
@@ -467,8 +514,17 @@ export function getOscillator(s, t, value, onended) {
|
||||
s = 'triangle';
|
||||
}
|
||||
s = s === 'user' && !partials ? 'triangle' : s;
|
||||
// If no partials are given, use stock waveforms
|
||||
if (!partials || partials?.length === 0 || s === 'sine') {
|
||||
if (s === 'one') {
|
||||
// Constant 1 oscillator (used for modulation)
|
||||
o = new ConstantSourceNode(getAudioContext(), { offset: 1 });
|
||||
o.start(t);
|
||||
return {
|
||||
node: o,
|
||||
nodes: { source: o },
|
||||
stop: (time) => o?.stop(time),
|
||||
};
|
||||
} else if (!partials || partials?.length === 0 || s === 'sine') {
|
||||
// If no partials are given, use stock waveforms
|
||||
o = getAudioContext().createOscillator();
|
||||
o.type = s || 'triangle';
|
||||
}
|
||||
@@ -479,11 +535,11 @@ export function getOscillator(s, t, value, onended) {
|
||||
// set frequency
|
||||
o.frequency.value = getFrequencyFromValue(value);
|
||||
|
||||
let vibratoOscillator = getVibratoOscillator(o.detune, value, t);
|
||||
const vibratoHandle = getVibratoOscillator(o.detune, value, t);
|
||||
|
||||
// pitch envelope
|
||||
getPitchEnvelope(o.detune, value, t, t + duration);
|
||||
const fmModulator = applyFM(o.frequency, value, t);
|
||||
const fmHandle = applyFM(o.frequency, value, t);
|
||||
|
||||
let noiseMix;
|
||||
if (noise) {
|
||||
@@ -500,9 +556,10 @@ export function getOscillator(s, t, value, onended) {
|
||||
|
||||
return {
|
||||
node: noiseMix?.node || o,
|
||||
nodes: { source: [o], ...vibratoHandle?.nodes, ...fmHandle?.nodes },
|
||||
stop: (time) => {
|
||||
fmModulator.stop(time);
|
||||
vibratoOscillator?.stop(time);
|
||||
fmHandle.stop(time);
|
||||
vibratoHandle?.stop(time);
|
||||
noiseMix?.stop(time);
|
||||
o.stop(time);
|
||||
},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { releaseAudioNode } from './helpers.mjs';
|
||||
|
||||
// credits to webdirt: https://github.com/dktr0/WebDirt/blob/41342e81d6ad694a2310d491fef7b7e8b0929efe/js-src/Graph.js#L597
|
||||
export var vowelFormant = {
|
||||
a: { freqs: [660, 1120, 2750, 3000, 3350], gains: [1, 0.5012, 0.0708, 0.0631, 0.0126], qs: [80, 90, 120, 130, 140] },
|
||||
@@ -46,7 +48,8 @@ if (typeof GainNode !== 'undefined') {
|
||||
}
|
||||
const { gains, qs, freqs } = vowelFormant[letter];
|
||||
this.makeupGain = ac.createGain();
|
||||
this.audioNodes = [];
|
||||
this.filters = [];
|
||||
this.gains = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const gain = ac.createGain();
|
||||
gain.gain.value = gains[i];
|
||||
@@ -56,9 +59,9 @@ if (typeof GainNode !== 'undefined') {
|
||||
filter.frequency.value = freqs[i];
|
||||
super.connect(filter);
|
||||
filter.connect(gain);
|
||||
this.audioNodes.push(filter);
|
||||
this.filters.push(filter);
|
||||
gain.connect(this.makeupGain);
|
||||
this.audioNodes.push(gain);
|
||||
this.gains.push(gain);
|
||||
}
|
||||
this.makeupGain.gain.value = 8; // how much makeup gain to add?
|
||||
return this;
|
||||
@@ -67,15 +70,17 @@ if (typeof GainNode !== 'undefined') {
|
||||
this.makeupGain.connect(target);
|
||||
}
|
||||
disconnect() {
|
||||
this.makeupGain.disconnect();
|
||||
this.audioNodes.forEach((n) => n.disconnect());
|
||||
releaseAudioNode(this.makeupGain);
|
||||
this.filters.forEach(releaseAudioNode);
|
||||
this.gains.forEach(releaseAudioNode);
|
||||
super.disconnect();
|
||||
this.makeupGain = null;
|
||||
this.audioNodes = null;
|
||||
this.filters = null;
|
||||
this.gains = null;
|
||||
}
|
||||
}
|
||||
|
||||
AudioContext.prototype.createVowelFilter = function (letter) {
|
||||
BaseAudioContext.prototype.createVowelFilter = function (letter) {
|
||||
return new VowelNode(this, letter);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
getParamADSR,
|
||||
getPitchEnvelope,
|
||||
getVibratoOscillator,
|
||||
getWorklet,
|
||||
releaseAudioNode,
|
||||
webAudioTimeout,
|
||||
releaseAudioNode,
|
||||
} from './helpers.mjs';
|
||||
import { getNodeFromPool, markWorkletAsDead, releaseNodeToPool } from './nodePools.mjs';
|
||||
import { logger } from './logger.mjs';
|
||||
|
||||
export const Warpmode = Object.freeze({
|
||||
@@ -40,6 +40,11 @@ export const Warpmode = Object.freeze({
|
||||
});
|
||||
|
||||
const seenKeys = new Set();
|
||||
|
||||
export function resetSeenKeys() {
|
||||
seenKeys.clear();
|
||||
}
|
||||
|
||||
async function getPayload(url, label, frameLen = 2048) {
|
||||
const key = `${url},${frameLen}`;
|
||||
if (!seenKeys.has(key)) {
|
||||
@@ -225,24 +230,31 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) {
|
||||
}
|
||||
const endWithRelease = holdEnd + release;
|
||||
const envEnd = endWithRelease + 0.01;
|
||||
const source = getWorklet(
|
||||
ac,
|
||||
'wavetable-oscillator-processor',
|
||||
{
|
||||
begin: t,
|
||||
end: envEnd,
|
||||
frequency,
|
||||
freqspread: value.detune,
|
||||
position: value.wt,
|
||||
warp: value.warp,
|
||||
warpMode: warpmode,
|
||||
voices: Math.max(value.unison ?? 1, 1),
|
||||
panspread: value.spread,
|
||||
phaserand: (value.wtphaserand ?? value.unison > 1) ? 1 : 0,
|
||||
},
|
||||
{ outputChannelCount: [2] },
|
||||
);
|
||||
source.port.postMessage({ type: 'table', payload });
|
||||
const params = {
|
||||
begin: t,
|
||||
end: envEnd,
|
||||
frequency,
|
||||
freqspread: value.detune,
|
||||
position: value.wt,
|
||||
warp: value.warp,
|
||||
warpMode: warpmode,
|
||||
voices: Math.max(value.unison ?? 1, 1),
|
||||
panspread: value.spread,
|
||||
phaserand: (value.wtphaserand ?? value.unison > 1) ? 1 : 0,
|
||||
};
|
||||
const factory = () => new AudioWorkletNode(ac, 'wavetable-oscillator-processor', { outputChannelCount: [2] });
|
||||
const source = getNodeFromPool('wavetable', factory);
|
||||
const now = ac.currentTime;
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
const param = source.parameters.get(key);
|
||||
const target = value !== undefined ? value : param.defaultValue;
|
||||
param.setValueAtTime(target, now);
|
||||
});
|
||||
source.port.postMessage({ type: 'initialize', payload });
|
||||
source.port.onmessage = (e) => {
|
||||
if (e.data.type === 'died') markWorkletAsDead(source);
|
||||
source.port.onmessage = null;
|
||||
};
|
||||
if (ac.currentTime > t) {
|
||||
logger(`[wavetable] still loading sound "${s}:${n}"`, 'highlight');
|
||||
return;
|
||||
@@ -309,19 +321,28 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) {
|
||||
dcoffset: value.warpdc ?? 0,
|
||||
},
|
||||
);
|
||||
const vibratoOscillator = getVibratoOscillator(source.parameters.get('detune'), value, t);
|
||||
const fm = applyFM(source.parameters.get('frequency'), value, t);
|
||||
const vibratoHandle = getVibratoOscillator(source.parameters.get('detune'), value, t);
|
||||
const fmHandle = applyFM(source.parameters.get('frequency'), value, t);
|
||||
const envGain = ac.createGain();
|
||||
const node = source.connect(envGain);
|
||||
getParamADSR(node.gain, attack, decay, sustain, release, 0, 0.3, t, holdEnd, 'linear');
|
||||
getPitchEnvelope(source.parameters.get('detune'), value, t, holdEnd);
|
||||
const handle = { node, source };
|
||||
const handle = {
|
||||
node,
|
||||
nodes: {
|
||||
source: [source],
|
||||
wt_lfo: [wtPosModulators],
|
||||
warp_lfo: [wtWarpModulators],
|
||||
...fmHandle?.nodes,
|
||||
...vibratoHandle?.nodes,
|
||||
},
|
||||
};
|
||||
const timeoutNode = webAudioTimeout(
|
||||
ac,
|
||||
() => {
|
||||
releaseAudioNode(source);
|
||||
releaseAudioNode(vibratoOscillator);
|
||||
fm?.stop();
|
||||
releaseNodeToPool(source);
|
||||
vibratoHandle?.stop();
|
||||
fmHandle?.stop();
|
||||
releaseAudioNode(wtPosModulators);
|
||||
releaseAudioNode(wtWarpModulators);
|
||||
onended();
|
||||
|
||||
@@ -124,8 +124,8 @@ class LFOProcessor extends AudioWorkletProcessor {
|
||||
{ name: 'shape', defaultValue: 0 },
|
||||
{ name: 'curve', defaultValue: 1 },
|
||||
{ name: 'dcoffset', defaultValue: 0 },
|
||||
{ name: 'min', defaultValue: 0 },
|
||||
{ name: 'max', defaultValue: 1 },
|
||||
{ name: 'min', defaultValue: -1e9 },
|
||||
{ name: 'max', defaultValue: 1e9 },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -143,7 +143,8 @@ class LFOProcessor extends AudioWorkletProcessor {
|
||||
|
||||
process(_inputs, outputs, parameters) {
|
||||
const begin = parameters['begin'][0];
|
||||
if (currentTime >= parameters.end[0]) {
|
||||
const end = parameters['end'][0];
|
||||
if (currentTime >= end) {
|
||||
return false;
|
||||
}
|
||||
if (currentTime <= begin) {
|
||||
@@ -161,6 +162,7 @@ class LFOProcessor extends AudioWorkletProcessor {
|
||||
const curve = parameters['curve'][0];
|
||||
|
||||
const dcoffset = parameters['dcoffset'][0];
|
||||
|
||||
const min = parameters['min'][0];
|
||||
const max = parameters['max'][0];
|
||||
const shape = waveShapeNames[parameters['shape'][0]];
|
||||
@@ -461,6 +463,16 @@ registerProcessor('distort-processor', DistortProcessor);
|
||||
class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.isAlive = true; // used internally to prevent multiple death messages
|
||||
this.port.onmessage = (e) => {
|
||||
const { type, payload } = e.data || {};
|
||||
if (type === 'initialize') {
|
||||
this.initialize(payload);
|
||||
}
|
||||
};
|
||||
this.initialize();
|
||||
}
|
||||
initialize(_options) {
|
||||
this.phase = [];
|
||||
}
|
||||
static get parameterDescriptors() {
|
||||
@@ -511,12 +523,16 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
|
||||
];
|
||||
}
|
||||
process(_input, outputs, params) {
|
||||
if (currentTime >= params.end[0]) {
|
||||
// should terminate
|
||||
if (currentTime >= params.end[0] + 0.5) {
|
||||
// Outside of grace period - should terminate
|
||||
if (this.isAlive) {
|
||||
this.port.postMessage({ type: 'died' });
|
||||
this.isAlive = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (currentTime <= params.begin[0]) {
|
||||
// keep alive
|
||||
if (currentTime >= params.end[0] || currentTime <= params.begin[0]) {
|
||||
// Inside of grace period or not yet started
|
||||
return true;
|
||||
}
|
||||
const output = outputs[0];
|
||||
@@ -967,7 +983,9 @@ class EnvelopeProcessor extends AudioWorkletProcessor {
|
||||
{ name: 'attackCurve', defaultValue: 0, minValue: -1, maxValue: 1 },
|
||||
{ name: 'decayCurve', defaultValue: 0, minValue: -1, maxValue: 1 },
|
||||
{ name: 'releaseCurve', defaultValue: 0, minValue: -1, maxValue: 1 },
|
||||
{ name: 'peak', defaultValue: 1 },
|
||||
{ name: 'depth', defaultValue: 1 },
|
||||
{ name: 'min', defaultValue: -1e9 },
|
||||
{ name: 'max', defaultValue: 1e9 },
|
||||
{ name: 'retrigger', defaultValue: 1, minValue: 0, maxValue: 1 },
|
||||
];
|
||||
}
|
||||
@@ -1009,9 +1027,15 @@ class EnvelopeProcessor extends AudioWorkletProcessor {
|
||||
}
|
||||
|
||||
process(_inputs, outputs, params) {
|
||||
const begin = params['begin'][0];
|
||||
const end = params['end'][0];
|
||||
if (currentTime >= end) {
|
||||
return false;
|
||||
}
|
||||
if (currentTime <= begin) {
|
||||
return true;
|
||||
}
|
||||
const out = outputs[0][0];
|
||||
if (!out) return true;
|
||||
const begin = pv(params.begin, 0);
|
||||
const retrigger = pv(params.retrigger, 0) >= 0.5; // convert to bool
|
||||
if (begin !== this.beginTime && (this.state === 0 || retrigger)) {
|
||||
// triggered
|
||||
@@ -1029,7 +1053,9 @@ class EnvelopeProcessor extends AudioWorkletProcessor {
|
||||
const aCurve = pv(params.attackCurve, i);
|
||||
const dCurve = pv(params.decayCurve, i);
|
||||
const rCurve = pv(params.releaseCurve, i);
|
||||
const peak = pv(params.peak, i);
|
||||
const depth = pv(params.depth, i);
|
||||
const min = pv(params.min, i);
|
||||
const max = pv(params.max, i);
|
||||
const states = [
|
||||
{ time: Number.POSITIVE_INFINITY, start: 0, target: 0 }, // idle
|
||||
{ time: attack, start: this.attackStart, target: 1, curve: aCurve },
|
||||
@@ -1043,7 +1069,7 @@ class EnvelopeProcessor extends AudioWorkletProcessor {
|
||||
this.state = (this.state + 1) % states.length;
|
||||
time = states[this.state].time;
|
||||
}
|
||||
out[i] = this.val * peak;
|
||||
out[i] = clamp(this.val * depth, min, max);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1138,37 +1164,29 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
|
||||
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this.frameLen = 0;
|
||||
this.numFrames = 0;
|
||||
this.phase = [];
|
||||
|
||||
this.isAlive = true; // used internally to prevent multiple death messages
|
||||
this.port.onmessage = (e) => {
|
||||
const { type, payload } = e.data || {};
|
||||
if (type === 'table') {
|
||||
const key = payload.key;
|
||||
this.frameLen = payload.frameLen;
|
||||
if (!tablesCache[key]) {
|
||||
const tables = [payload.frames];
|
||||
let table = tables[0];
|
||||
for (let level = 1; level < 1; level++) {
|
||||
const nextLen = table.length >> 1;
|
||||
const nextTable = table.map((frame) => {
|
||||
const avg = new Float32Array(nextLen);
|
||||
for (let i = 0; i < nextLen; i++) {
|
||||
avg[i] = (frame[2 * i] + frame[2 * i + 1]) / 2;
|
||||
}
|
||||
return avg;
|
||||
});
|
||||
tables.push(nextTable);
|
||||
table = nextTable;
|
||||
if (nextLen <= 32) break;
|
||||
}
|
||||
tablesCache[key] = tables;
|
||||
}
|
||||
this.tables = tablesCache[key];
|
||||
this.numFrames = this.tables[0].length;
|
||||
if (type === 'initialize') {
|
||||
this.initialize(payload);
|
||||
}
|
||||
};
|
||||
this.initialize();
|
||||
}
|
||||
initialize(options) {
|
||||
this.table = null;
|
||||
this.frameLen = null;
|
||||
this.numFrames = null;
|
||||
this.phase = [];
|
||||
if (options?.key) {
|
||||
const key = options.key;
|
||||
this.frameLen = options.frameLen;
|
||||
if (!tablesCache[key]) {
|
||||
tablesCache[key] = options.frames;
|
||||
}
|
||||
this.table = tablesCache[key];
|
||||
this.numFrames = this.table.length;
|
||||
}
|
||||
}
|
||||
|
||||
_mirror(x) {
|
||||
@@ -1313,25 +1331,22 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
|
||||
return a + (b - a) * frac;
|
||||
}
|
||||
|
||||
_chooseMip(dphi) {
|
||||
const approxHarm = clamp(dphi, 1e-6, 64);
|
||||
let level = 0;
|
||||
while (level + 1 < (this.tables?.length || 1) && approxHarm < this.tables[level][0].length / 8) {
|
||||
level++;
|
||||
}
|
||||
return level;
|
||||
}
|
||||
|
||||
process(_inputs, outputs, parameters) {
|
||||
if (currentTime >= parameters.end[0]) {
|
||||
if (currentTime >= parameters.end[0] + 0.5) {
|
||||
// Outside of grace period - should terminate
|
||||
if (this.isAlive) {
|
||||
this.port.postMessage({ type: 'died' });
|
||||
this.isAlive = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (currentTime <= parameters.begin[0]) {
|
||||
if (currentTime >= parameters.end[0] || currentTime <= parameters.begin[0]) {
|
||||
// Inside of grace period or not yet started
|
||||
return true;
|
||||
}
|
||||
const outL = outputs[0][0];
|
||||
const outR = outputs[0][1] || outputs[0][0];
|
||||
if (!this.tables) {
|
||||
if (!this.table) {
|
||||
outL.fill(0);
|
||||
if (outR !== outL) outR.set(outL);
|
||||
return true;
|
||||
@@ -1365,14 +1380,12 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
|
||||
}
|
||||
const fVoice = applySemitoneDetuneToFrequency(f, detuner(n)); // voice detune
|
||||
const dPhase = fVoice * INVSR;
|
||||
const level = this._chooseMip(dPhase);
|
||||
const table = this.tables[level];
|
||||
|
||||
// warp phase then sample
|
||||
this.phase[n] = this.phase[n] ?? Math.random() * phaseRand;
|
||||
const ph = this._warpPhase(this.phase[n], warpAmount, warpMode);
|
||||
const s0 = this._sampleFrame(table[fIdx], ph);
|
||||
const s1 = this._sampleFrame(table[Math.min(this.numFrames - 1, fIdx + 1)], ph);
|
||||
const s0 = this._sampleFrame(this.table[fIdx], ph);
|
||||
const s1 = this._sampleFrame(this.table[Math.min(this.numFrames - 1, fIdx + 1)], ph);
|
||||
let s = lerp(s0, s1, interpT);
|
||||
if (warpMode === WarpMode.FLIP && this.phase[n] < warpAmount) {
|
||||
s = -s;
|
||||
|
||||
@@ -90,6 +90,7 @@ export function registerZZFXSounds() {
|
||||
});
|
||||
return {
|
||||
node: o,
|
||||
nodes: { source: [o] },
|
||||
stop: () => {},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -42,4 +42,18 @@ describe('transpiler', () => {
|
||||
[12, 14],
|
||||
]);
|
||||
});
|
||||
it('allows disabling mini', () => {
|
||||
const code = `/* mini-off */
|
||||
const randPrefix = Math.random() > 0.5 ? "b" : "s";
|
||||
const drumPat = \`\${randPrefix}d\`;
|
||||
// mini-on
|
||||
s(drumPat).lpf("5000 10000") // make sure mini still runs;
|
||||
`;
|
||||
const { output, miniLocations } = transpiler(code, { ...simple, emitMiniLocations: true });
|
||||
expect(output).not.toContain("m('b'");
|
||||
expect(output).not.toContain("m('s'");
|
||||
const cutoffIdx = code.indexOf('5000 10000');
|
||||
expect(miniLocations).toHaveLength(2);
|
||||
expect(miniLocations[0][0]).toEqual(cutoffIdx);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,12 +21,15 @@ export function registerLanguage(type, config) {
|
||||
export function transpiler(input, options = {}) {
|
||||
const { wrapAsync = false, addReturn = true, emitMiniLocations = true, emitWidgets = true } = options;
|
||||
|
||||
const comments = [];
|
||||
let ast = parse(input, {
|
||||
ecmaVersion: 2022,
|
||||
allowAwaitOutsideFunction: true,
|
||||
locations: true,
|
||||
onComment: comments,
|
||||
});
|
||||
|
||||
const miniDisableRanges = findMiniDisableRanges(comments, input.length);
|
||||
let miniLocations = [];
|
||||
const collectMiniLocations = (value, node) => {
|
||||
const minilang = languages.get('minilang');
|
||||
@@ -66,6 +69,9 @@ export function transpiler(input, options = {}) {
|
||||
return this.replace(tidalWithLocation(raw, offset));
|
||||
}
|
||||
if (isBackTickString(node, parent)) {
|
||||
if (isMiniDisabled(node.start, miniDisableRanges)) {
|
||||
return;
|
||||
}
|
||||
const { quasis } = node;
|
||||
const { raw } = quasis[0].value;
|
||||
this.skip();
|
||||
@@ -73,6 +79,9 @@ export function transpiler(input, options = {}) {
|
||||
return this.replace(miniWithLocation(raw, node));
|
||||
}
|
||||
if (isStringWithDoubleQuotes(node)) {
|
||||
if (isMiniDisabled(node.start, miniDisableRanges)) {
|
||||
return;
|
||||
}
|
||||
const { value } = node;
|
||||
this.skip();
|
||||
emitMiniLocations && collectMiniLocations(value, node);
|
||||
@@ -327,3 +336,32 @@ function languageWithLocation(name, value, offset) {
|
||||
optional: false,
|
||||
};
|
||||
}
|
||||
|
||||
function findMiniDisableRanges(comments, codeEnd) {
|
||||
const ranges = [];
|
||||
const stack = []; // used to track on/off pairs
|
||||
for (const comment of comments) {
|
||||
const value = comment.value.trim();
|
||||
if (value.startsWith('mini-off')) {
|
||||
stack.push(comment.start);
|
||||
} else if (value.startsWith('mini-on')) {
|
||||
const start = stack.pop();
|
||||
ranges.push([start, comment.end]);
|
||||
}
|
||||
}
|
||||
while (stack.length) {
|
||||
// If no closing mini-on is found, just turn it off until `codeEnd`
|
||||
const start = stack.pop();
|
||||
ranges.push([start, codeEnd]);
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function isMiniDisabled(offset, miniDisableRanges) {
|
||||
for (const [start, end] of miniDisableRanges) {
|
||||
if (offset >= start && offset < end) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -5,10 +5,21 @@ This program is free software: you can redistribute it and/or modify it under th
|
||||
*/
|
||||
|
||||
import * as strudel from '@strudel/core';
|
||||
import { superdough, getAudioContext, setLogger, doughTrigger, registerWorklet } from 'superdough';
|
||||
import {
|
||||
superdough,
|
||||
getAudioContext,
|
||||
setLogger,
|
||||
doughTrigger,
|
||||
registerWorklet,
|
||||
setAudioContext,
|
||||
initAudio,
|
||||
setSuperdoughAudioController,
|
||||
resetGlobalEffects,
|
||||
errorLogger,
|
||||
} from 'superdough';
|
||||
import './supradough.mjs';
|
||||
import { workletUrl } from 'supradough';
|
||||
|
||||
import { SuperdoughAudioController } from 'superdough/superdoughoutput.mjs';
|
||||
registerWorklet(workletUrl);
|
||||
|
||||
const { Pattern, logger, repl } = strudel;
|
||||
@@ -26,6 +37,71 @@ export const webaudioOutput = (hap, _deadline, hapDuration, cps, t) => {
|
||||
return superdough(hap2value(hap), t, hapDuration, cps, hap.whole?.begin.valueOf());
|
||||
};
|
||||
|
||||
export async function renderPatternAudio(
|
||||
pattern,
|
||||
cps,
|
||||
begin,
|
||||
end,
|
||||
sampleRate,
|
||||
maxPolyphony,
|
||||
multiChannelOrbits,
|
||||
downloadName = undefined,
|
||||
) {
|
||||
let audioContext = getAudioContext();
|
||||
await audioContext.close();
|
||||
audioContext = new OfflineAudioContext(2, ((end - begin) / cps) * sampleRate, sampleRate);
|
||||
setAudioContext(audioContext);
|
||||
setSuperdoughAudioController(new SuperdoughAudioController(audioContext));
|
||||
await initAudio({
|
||||
maxPolyphony,
|
||||
multiChannelOrbits,
|
||||
});
|
||||
logger('[webaudio] preloading');
|
||||
|
||||
// Calling superdough(...) in ascending onset time order is important
|
||||
// for controls that depend on the audio graph state like `cut`
|
||||
let haps = pattern
|
||||
.queryArc(begin, end, { _cps: cps })
|
||||
.sort((a, b) => a.whole.begin.valueOf() - b.whole.begin.valueOf());
|
||||
for (const hap of haps) {
|
||||
if (hap.hasOnset()) {
|
||||
try {
|
||||
await superdough(
|
||||
hap2value(hap),
|
||||
(hap.whole.begin.valueOf() - begin) / cps,
|
||||
hap.duration / cps,
|
||||
cps,
|
||||
(hap.whole?.begin.valueOf() - begin) / cps,
|
||||
);
|
||||
} catch (err) {
|
||||
errorLogger(err, 'webaudio');
|
||||
}
|
||||
}
|
||||
}
|
||||
logger('[webaudio] start rendering');
|
||||
|
||||
return audioContext
|
||||
.startRendering()
|
||||
.then((renderedBuffer) => {
|
||||
const wavBuffer = audioBufferToWav(renderedBuffer);
|
||||
const blob = new Blob([wavBuffer], { type: 'audio/wav' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
downloadName = downloadName ? `${downloadName}.wav` : `${new Date().toISOString()}.wav`;
|
||||
a.download = `${downloadName}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
})
|
||||
.finally(async () => {
|
||||
setAudioContext(null);
|
||||
setSuperdoughAudioController(null);
|
||||
resetGlobalEffects();
|
||||
});
|
||||
}
|
||||
|
||||
export function webaudioRepl(options = {}) {
|
||||
options = {
|
||||
getTime: () => getAudioContext().currentTime,
|
||||
@@ -38,3 +114,98 @@ export function webaudioRepl(options = {}) {
|
||||
Pattern.prototype.dough = function () {
|
||||
return this.onTrigger(doughTrigger, 1);
|
||||
};
|
||||
|
||||
function audioBufferToWav(buffer, opt) {
|
||||
opt = opt || {};
|
||||
|
||||
var numChannels = buffer.numberOfChannels;
|
||||
var sampleRate = buffer.sampleRate;
|
||||
var format = opt.float32 ? 3 : 1;
|
||||
var bitDepth = format === 3 ? 32 : 16;
|
||||
|
||||
var result;
|
||||
if (numChannels === 2) {
|
||||
result = interleave(buffer.getChannelData(0), buffer.getChannelData(1));
|
||||
} else {
|
||||
result = buffer.getChannelData(0);
|
||||
}
|
||||
|
||||
return encodeWAV(result, format, sampleRate, numChannels, bitDepth);
|
||||
}
|
||||
|
||||
function encodeWAV(samples, format, sampleRate, numChannels, bitDepth) {
|
||||
var bytesPerSample = bitDepth / 8;
|
||||
var blockAlign = numChannels * bytesPerSample;
|
||||
|
||||
var buffer = new ArrayBuffer(44 + samples.length * bytesPerSample);
|
||||
var view = new DataView(buffer);
|
||||
|
||||
/* RIFF identifier */
|
||||
writeString(view, 0, 'RIFF');
|
||||
/* RIFF chunk length */
|
||||
view.setUint32(4, 36 + samples.length * bytesPerSample, true);
|
||||
/* RIFF type */
|
||||
writeString(view, 8, 'WAVE');
|
||||
/* format chunk identifier */
|
||||
writeString(view, 12, 'fmt ');
|
||||
/* format chunk length */
|
||||
view.setUint32(16, 16, true);
|
||||
/* sample format (raw) */
|
||||
view.setUint16(20, format, true);
|
||||
/* channel count */
|
||||
view.setUint16(22, numChannels, true);
|
||||
/* sample rate */
|
||||
view.setUint32(24, sampleRate, true);
|
||||
/* byte rate (sample rate * block align) */
|
||||
view.setUint32(28, sampleRate * blockAlign, true);
|
||||
/* block align (channel count * bytes per sample) */
|
||||
view.setUint16(32, blockAlign, true);
|
||||
/* bits per sample */
|
||||
view.setUint16(34, bitDepth, true);
|
||||
/* data chunk identifier */
|
||||
writeString(view, 36, 'data');
|
||||
/* data chunk length */
|
||||
view.setUint32(40, samples.length * bytesPerSample, true);
|
||||
if (format === 1) {
|
||||
// Raw PCM
|
||||
floatTo16BitPCM(view, 44, samples);
|
||||
} else {
|
||||
writeFloat32(view, 44, samples);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function interleave(inputL, inputR) {
|
||||
var length = inputL.length + inputR.length;
|
||||
var result = new Float32Array(length);
|
||||
|
||||
var index = 0;
|
||||
var inputIndex = 0;
|
||||
|
||||
while (index < length) {
|
||||
result[index++] = inputL[inputIndex];
|
||||
result[index++] = inputR[inputIndex];
|
||||
inputIndex++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function writeFloat32(output, offset, input) {
|
||||
for (var i = 0; i < input.length; i++, offset += 4) {
|
||||
output.setFloat32(offset, input[i], true);
|
||||
}
|
||||
}
|
||||
|
||||
function floatTo16BitPCM(output, offset, input) {
|
||||
for (var i = 0; i < input.length; i++, offset += 2) {
|
||||
var s = Math.max(-1, Math.min(1, input[i]));
|
||||
output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
|
||||
}
|
||||
}
|
||||
|
||||
function writeString(view, offset, string) {
|
||||
for (var i = 0; i < string.length; i++) {
|
||||
view.setUint8(offset + i, string.charCodeAt(i));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -565,6 +565,52 @@ exports[`runs examples > example "_euclidRot" example index 20 1`] = `
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "FX" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
|
||||
"[ 1/8 → 1/4 | s:hh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
|
||||
"[ 1/4 → 3/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
|
||||
"[ 3/8 → 1/2 | s:bd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
|
||||
"[ 1/2 → 5/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
|
||||
"[ 5/8 → 3/4 | s:hh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
|
||||
"[ 3/4 → 7/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
|
||||
"[ 7/8 → 1/1 | s:lt decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
|
||||
"[ 1/1 → 9/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
|
||||
"[ 9/8 → 5/4 | s:hh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
|
||||
"[ 5/4 → 11/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
|
||||
"[ 11/8 → 3/2 | s:oh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
|
||||
"[ 3/2 → 13/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
|
||||
"[ 13/8 → 7/4 | s:hh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
|
||||
"[ 7/4 → 15/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
|
||||
"[ 15/8 → 2/1 | s:bd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
|
||||
"[ 2/1 → 17/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
|
||||
"[ 17/8 → 9/4 | s:hh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
|
||||
"[ 9/4 → 19/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
|
||||
"[ 19/8 → 5/2 | s:bd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
|
||||
"[ 5/2 → 21/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
|
||||
"[ 21/8 → 11/4 | s:hh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
|
||||
"[ 11/4 → 23/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
|
||||
"[ 23/8 → 3/1 | s:bd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
|
||||
"[ 3/1 → 25/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
|
||||
"[ 25/8 → 13/4 | s:hh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
|
||||
"[ 13/4 → 27/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
|
||||
"[ 27/8 → 7/2 | s:lt decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
|
||||
"[ 7/2 → 29/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
|
||||
"[ 29/8 → 15/4 | s:hh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
|
||||
"[ 15/4 → 31/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
|
||||
"[ 31/8 → 4/1 | s:bd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "FX" example index 1 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/1 | s:saw fmi:0.5 delay:0.3 FX:[{coarse:4} {cutoff:500 lpenv:4 lpattack:1 lpdecay:2} {distort:1}] ]",
|
||||
"[ 1/1 → 2/1 | s:saw fmi:0.5 delay:0.3 FX:[{coarse:4} {cutoff:500 lpenv:4 lpattack:1 lpdecay:2} {distort:1}] ]",
|
||||
"[ 2/1 → 3/1 | s:saw fmi:0.5 delay:0.3 FX:[{coarse:4} {cutoff:500 lpenv:4 lpattack:1 lpdecay:2} {distort:1}] ]",
|
||||
"[ 3/1 → 4/1 | s:saw fmi:0.5 delay:0.3 FX:[{coarse:4} {cutoff:500 lpenv:4 lpattack:1 lpdecay:2} {distort:1}] ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "accelerate" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 2/1 | s:sax accelerate:0 ]",
|
||||
@@ -3803,6 +3849,51 @@ exports[`runs examples > example "end" example index 0 1`] = `
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "env" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/1 | s:saw note:F1 cutoff:500 env:{0:{control:cutoff attack:1} __ids:{}} ]",
|
||||
"[ 1/1 → 2/1 | s:saw note:F1 cutoff:500 env:{0:{control:cutoff attack:1} __ids:{}} ]",
|
||||
"[ 2/1 → 3/1 | s:saw note:F1 cutoff:500 env:{0:{control:cutoff attack:1} __ids:{}} ]",
|
||||
"[ 3/1 → 4/1 | s:saw note:F1 cutoff:500 env:{0:{control:cutoff attack:1} __ids:{}} ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "env" example index 1 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/1 | s:saw env:{0:{control:s decay:1} 1:{control:cutoff attack:0.1 decay:1 acurve:0.8 dcurve:0.3 depth:50} __ids:{}} note:F1 resonance:4 cutoff:50 ]",
|
||||
"[ 1/1 → 2/1 | s:saw env:{0:{control:s decay:1} 1:{control:cutoff attack:0.1 decay:1 acurve:0.8 dcurve:0.3 depth:50} __ids:{}} note:F1 resonance:4 cutoff:50 ]",
|
||||
"[ 2/1 → 3/1 | s:saw env:{0:{control:s decay:1} 1:{control:cutoff attack:0.1 decay:1 acurve:0.8 dcurve:0.3 depth:50} __ids:{}} note:F1 resonance:4 cutoff:50 ]",
|
||||
"[ 3/1 → 4/1 | s:saw env:{0:{control:s decay:1} 1:{control:cutoff attack:0.1 decay:1 acurve:0.8 dcurve:0.3 depth:50} __ids:{}} note:F1 resonance:4 cutoff:50 ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "env" example index 2 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/1 | s:saw cutoff:500 distort:0.3 distortvol:1 distorttype:diode env:{0:{control:cutoff attack:0.5 decay:0.5} __ids:{}} ]",
|
||||
"[ 1/1 → 2/1 | s:saw cutoff:500 distort:0.3 distortvol:1 distorttype:diode env:{0:{control:cutoff attack:0.5 decay:0.5} __ids:{}} ]",
|
||||
"[ 2/1 → 3/1 | s:saw cutoff:500 distort:0.3 distortvol:1 distorttype:diode env:{0:{control:cutoff attack:0.5 decay:0.5} __ids:{}} ]",
|
||||
"[ 3/1 → 4/1 | s:saw cutoff:500 distort:0.3 distortvol:1 distorttype:diode env:{0:{control:cutoff attack:0.5 decay:0.5} __ids:{}} ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "env" example index 3 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/1 | s:pulse cutoff:500 env:{0:{control:cutoff attack:1} 1:{control:s attack:0.5} __ids:{}} distort:0.3 distortvol:1 distorttype:diode ]",
|
||||
"[ 1/1 → 2/1 | s:pulse cutoff:500 env:{0:{control:cutoff attack:1} 1:{control:s attack:1} __ids:{}} distort:0.3 distortvol:1 distorttype:diode ]",
|
||||
"[ 2/1 → 3/1 | s:pulse cutoff:500 env:{0:{control:cutoff attack:1} 1:{control:s attack:1} __ids:{}} distort:0.3 distortvol:1 distorttype:diode ]",
|
||||
"[ 3/1 → 4/1 | s:pulse cutoff:500 env:{0:{control:cutoff attack:1} 1:{control:s attack:0.5} __ids:{}} distort:0.3 distortvol:1 distorttype:diode ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "env" example index 4 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/1 | s:pulse cutoff:500 env:{1:{control:s attack:1} __ids:{} lpf_mod:{control:cutoff attack:0.5}} distort:0.3 distortvol:1 distorttype:diode ]",
|
||||
"[ 1/1 → 2/1 | s:pulse cutoff:500 env:{1:{control:s attack:1} __ids:{} lpf_mod:{control:cutoff attack:1}} distort:0.3 distortvol:1 distorttype:diode ]",
|
||||
"[ 2/1 → 3/1 | s:pulse cutoff:500 env:{1:{control:s attack:1} __ids:{} lpf_mod:{control:cutoff attack:1}} distort:0.3 distortvol:1 distorttype:diode ]",
|
||||
"[ 3/1 → 4/1 | s:pulse cutoff:500 env:{1:{control:s attack:1} __ids:{} lpf_mod:{control:cutoff attack:0.5}} distort:0.3 distortvol:1 distorttype:diode ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "euclid" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/8 | note:c3 ]",
|
||||
@@ -6113,6 +6204,51 @@ exports[`runs examples > example "leslie" example index 0 1`] = `
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "lfo" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/1 | s:saw note:F1 cutoff:500 lfo:{0:{control:cutoff} __ids:{}} ]",
|
||||
"[ 1/1 → 2/1 | s:saw note:F1 cutoff:500 lfo:{0:{control:cutoff} __ids:{}} ]",
|
||||
"[ 2/1 → 3/1 | s:saw note:F1 cutoff:500 lfo:{0:{control:cutoff} __ids:{}} ]",
|
||||
"[ 3/1 → 4/1 | s:saw note:F1 cutoff:500 lfo:{0:{control:cutoff} __ids:{}} ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "lfo" example index 1 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/1 | s:saw lfo:{0:{control:s} 1:{control:cutoff sync:0.3} __ids:{}} cutoff:500 ]",
|
||||
"[ 1/1 → 2/1 | s:saw lfo:{0:{control:s} 1:{control:cutoff sync:0.3} __ids:{}} cutoff:500 ]",
|
||||
"[ 2/1 → 3/1 | s:saw lfo:{0:{control:s} 1:{control:cutoff sync:0.3} __ids:{}} cutoff:500 ]",
|
||||
"[ 3/1 → 4/1 | s:saw lfo:{0:{control:s} 1:{control:cutoff sync:0.3} __ids:{}} cutoff:500 ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "lfo" example index 2 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/1 | s:saw cutoff:500 distort:0.3 distortvol:1 distorttype:diode lfo:{0:{control:cutoff} __ids:{}} ]",
|
||||
"[ 1/1 → 2/1 | s:saw cutoff:500 distort:0.3 distortvol:1 distorttype:diode lfo:{0:{control:cutoff} __ids:{}} ]",
|
||||
"[ 2/1 → 3/1 | s:saw cutoff:500 distort:0.3 distortvol:1 distorttype:diode lfo:{0:{control:cutoff} __ids:{}} ]",
|
||||
"[ 3/1 → 4/1 | s:saw cutoff:500 distort:0.3 distortvol:1 distorttype:diode lfo:{0:{control:cutoff} __ids:{}} ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "lfo" example index 3 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/1 | s:pulse cutoff:500 lfo:{0:{control:cutoff} 1:{control:s sync:8} __ids:{}} distort:0.3 distortvol:1 distorttype:diode ]",
|
||||
"[ 1/1 → 2/1 | s:pulse cutoff:500 lfo:{0:{control:cutoff} 1:{control:s} __ids:{}} distort:0.3 distortvol:1 distorttype:diode ]",
|
||||
"[ 2/1 → 3/1 | s:pulse cutoff:500 lfo:{0:{control:cutoff} 1:{control:s} __ids:{}} distort:0.3 distortvol:1 distorttype:diode ]",
|
||||
"[ 3/1 → 4/1 | s:pulse cutoff:500 lfo:{0:{control:cutoff} 1:{control:s sync:8} __ids:{}} distort:0.3 distortvol:1 distorttype:diode ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "lfo" example index 4 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/1 | s:pulse cutoff:500 lfo:{1:{control:s} __ids:{} lpf_mod:{control:cutoff depth:4 sync:8}} distort:0.3 distortvol:1 distorttype:diode ]",
|
||||
"[ 1/1 → 2/1 | s:pulse cutoff:500 lfo:{1:{control:s} __ids:{} lpf_mod:{control:cutoff depth:4}} distort:0.3 distortvol:1 distorttype:diode ]",
|
||||
"[ 2/1 → 3/1 | s:pulse cutoff:500 lfo:{1:{control:s} __ids:{} lpf_mod:{control:cutoff depth:4}} distort:0.3 distortvol:1 distorttype:diode ]",
|
||||
"[ 3/1 → 4/1 | s:pulse cutoff:500 lfo:{1:{control:s} __ids:{} lpf_mod:{control:cutoff depth:4 sync:8}} distort:0.3 distortvol:1 distorttype:diode ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "linger" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/4 | s:lt ]",
|
||||
@@ -10413,6 +10549,18 @@ exports[`runs examples > example "scrub" example index 1 1`] = `
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "seed" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/4 | s:bd ]",
|
||||
"[ 1/4 → 1/2 | s:bd ]",
|
||||
"[ 1/2 → 3/4 | s:bd ]",
|
||||
"[ 1/1 → 5/4 | s:bd ]",
|
||||
"[ 7/4 → 2/1 | s:bd ]",
|
||||
"[ 9/4 → 5/2 | s:bd ]",
|
||||
"[ 11/4 → 3/1 | s:bd ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "segment" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/24 | note:40 ]",
|
||||
@@ -10596,6 +10744,64 @@ exports[`runs examples > example "seqPLoop" example index 0 1`] = `
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "setGainCurve" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/4 | s:bd gain:0.5 ]",
|
||||
"[ 1/4 → 1/2 | s:bd gain:0.5 ]",
|
||||
"[ 1/2 → 3/4 | s:bd gain:0.5 ]",
|
||||
"[ 3/4 → 1/1 | s:bd gain:0.5 ]",
|
||||
"[ 1/1 → 5/4 | s:bd gain:0.5 ]",
|
||||
"[ 5/4 → 3/2 | s:bd gain:0.5 ]",
|
||||
"[ 3/2 → 7/4 | s:bd gain:0.5 ]",
|
||||
"[ 7/4 → 2/1 | s:bd gain:0.5 ]",
|
||||
"[ 2/1 → 9/4 | s:bd gain:0.5 ]",
|
||||
"[ 9/4 → 5/2 | s:bd gain:0.5 ]",
|
||||
"[ 5/2 → 11/4 | s:bd gain:0.5 ]",
|
||||
"[ 11/4 → 3/1 | s:bd gain:0.5 ]",
|
||||
"[ 3/1 → 13/4 | s:bd gain:0.5 ]",
|
||||
"[ 13/4 → 7/2 | s:bd gain:0.5 ]",
|
||||
"[ 7/2 → 15/4 | s:bd gain:0.5 ]",
|
||||
"[ 15/4 → 4/1 | s:bd gain:0.5 ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "setMaxPolyphony" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/8 | note:C#3 room:1 release:4 gain:0.5 ]",
|
||||
"[ 1/8 → 1/4 | note:E5 room:1 release:4 gain:0.5 ]",
|
||||
"[ 1/4 → 3/8 | note:D#4 room:1 release:4 gain:0.5 ]",
|
||||
"[ 3/8 → 1/2 | note:E4 room:1 release:4 gain:0.5 ]",
|
||||
"[ 1/2 → 5/8 | note:B3 room:1 release:4 gain:0.5 ]",
|
||||
"[ 5/8 → 3/4 | note:F#3 room:1 release:4 gain:0.5 ]",
|
||||
"[ 3/4 → 7/8 | note:G#3 room:1 release:4 gain:0.5 ]",
|
||||
"[ 7/8 → 1/1 | note:E4 room:1 release:4 gain:0.5 ]",
|
||||
"[ 1/1 → 9/8 | note:A4 room:1 release:4 gain:0.5 ]",
|
||||
"[ 9/8 → 5/4 | note:E5 room:1 release:4 gain:0.5 ]",
|
||||
"[ 5/4 → 11/8 | note:F#5 room:1 release:4 gain:0.5 ]",
|
||||
"[ 11/8 → 3/2 | note:G#3 room:1 release:4 gain:0.5 ]",
|
||||
"[ 3/2 → 13/8 | note:C#5 room:1 release:4 gain:0.5 ]",
|
||||
"[ 13/8 → 7/4 | note:G#4 room:1 release:4 gain:0.5 ]",
|
||||
"[ 7/4 → 15/8 | note:G#3 room:1 release:4 gain:0.5 ]",
|
||||
"[ 15/8 → 2/1 | note:C#5 room:1 release:4 gain:0.5 ]",
|
||||
"[ 2/1 → 17/8 | note:E6 room:1 release:4 gain:0.5 ]",
|
||||
"[ 17/8 → 9/4 | note:C#6 room:1 release:4 gain:0.5 ]",
|
||||
"[ 9/4 → 19/8 | note:D#4 room:1 release:4 gain:0.5 ]",
|
||||
"[ 19/8 → 5/2 | note:B5 room:1 release:4 gain:0.5 ]",
|
||||
"[ 5/2 → 21/8 | note:G#4 room:1 release:4 gain:0.5 ]",
|
||||
"[ 21/8 → 11/4 | note:F#3 room:1 release:4 gain:0.5 ]",
|
||||
"[ 11/4 → 23/8 | note:D#5 room:1 release:4 gain:0.5 ]",
|
||||
"[ 23/8 → 3/1 | note:C#3 room:1 release:4 gain:0.5 ]",
|
||||
"[ 3/1 → 25/8 | note:A3 room:1 release:4 gain:0.5 ]",
|
||||
"[ 25/8 → 13/4 | note:D#4 room:1 release:4 gain:0.5 ]",
|
||||
"[ 13/4 → 27/8 | note:E6 room:1 release:4 gain:0.5 ]",
|
||||
"[ 27/8 → 7/2 | note:E4 room:1 release:4 gain:0.5 ]",
|
||||
"[ 7/2 → 29/8 | note:E4 room:1 release:4 gain:0.5 ]",
|
||||
"[ 29/8 → 15/4 | note:D#6 room:1 release:4 gain:0.5 ]",
|
||||
"[ 15/4 → 31/8 | note:A5 room:1 release:4 gain:0.5 ]",
|
||||
"[ 31/8 → 4/1 | note:F#5 room:1 release:4 gain:0.5 ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "setcpm" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/4 | s:bd bank:tr707 ]",
|
||||
@@ -12941,6 +13147,75 @@ exports[`runs examples > example "unit" example index 0 1`] = `
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "useRNG" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/16 | note:D8 ]",
|
||||
"[ 1/16 → 1/8 | note:Bb7 ]",
|
||||
"[ 1/8 → 3/16 | note:Ab6 ]",
|
||||
"[ 3/16 → 1/4 | note:D5 ]",
|
||||
"[ 1/4 → 5/16 | note:Ab9 ]",
|
||||
"[ 5/16 → 3/8 | note:D3 ]",
|
||||
"[ 3/8 → 7/16 | note:G5 ]",
|
||||
"[ 7/16 → 1/2 | note:G3 ]",
|
||||
"[ 1/2 → 9/16 | note:Ab3 ]",
|
||||
"[ 9/16 → 5/8 | note:Eb6 ]",
|
||||
"[ 5/8 → 11/16 | note:Eb6 ]",
|
||||
"[ 11/16 → 3/4 | note:Eb5 ]",
|
||||
"[ 3/4 → 13/16 | note:G7 ]",
|
||||
"[ 13/16 → 7/8 | note:Bb5 ]",
|
||||
"[ 7/8 → 15/16 | note:D3 ]",
|
||||
"[ 15/16 → 1/1 | note:Eb7 ]",
|
||||
"[ 1/1 → 17/16 | note:Eb9 ]",
|
||||
"[ 17/16 → 9/8 | note:G8 ]",
|
||||
"[ 9/8 → 19/16 | note:Ab3 ]",
|
||||
"[ 19/16 → 5/4 | note:Ab5 ]",
|
||||
"[ 5/4 → 21/16 | note:C10 ]",
|
||||
"[ 21/16 → 11/8 | note:C7 ]",
|
||||
"[ 11/8 → 23/16 | note:G5 ]",
|
||||
"[ 23/16 → 3/2 | note:Ab7 ]",
|
||||
"[ 3/2 → 25/16 | note:G6 ]",
|
||||
"[ 25/16 → 13/8 | note:Bb6 ]",
|
||||
"[ 13/8 → 27/16 | note:Eb9 ]",
|
||||
"[ 27/16 → 7/4 | note:G9 ]",
|
||||
"[ 7/4 → 29/16 | note:G7 ]",
|
||||
"[ 29/16 → 15/8 | note:C10 ]",
|
||||
"[ 15/8 → 31/16 | note:Eb3 ]",
|
||||
"[ 31/16 → 2/1 | note:Ab7 ]",
|
||||
"[ 2/1 → 33/16 | note:F6 ]",
|
||||
"[ 33/16 → 17/8 | note:C5 ]",
|
||||
"[ 17/8 → 35/16 | note:Ab4 ]",
|
||||
"[ 35/16 → 9/4 | note:G5 ]",
|
||||
"[ 9/4 → 37/16 | note:C9 ]",
|
||||
"[ 37/16 → 19/8 | note:Eb6 ]",
|
||||
"[ 19/8 → 39/16 | note:C9 ]",
|
||||
"[ 39/16 → 5/2 | note:Eb9 ]",
|
||||
"[ 5/2 → 41/16 | note:D5 ]",
|
||||
"[ 41/16 → 21/8 | note:F5 ]",
|
||||
"[ 21/8 → 43/16 | note:F9 ]",
|
||||
"[ 43/16 → 11/4 | note:Bb7 ]",
|
||||
"[ 11/4 → 45/16 | note:Ab6 ]",
|
||||
"[ 45/16 → 23/8 | note:Bb9 ]",
|
||||
"[ 23/8 → 47/16 | note:C8 ]",
|
||||
"[ 47/16 → 3/1 | note:Eb5 ]",
|
||||
"[ 3/1 → 49/16 | note:F3 ]",
|
||||
"[ 49/16 → 25/8 | note:G4 ]",
|
||||
"[ 25/8 → 51/16 | note:D7 ]",
|
||||
"[ 51/16 → 13/4 | note:D4 ]",
|
||||
"[ 13/4 → 53/16 | note:F8 ]",
|
||||
"[ 53/16 → 27/8 | note:C7 ]",
|
||||
"[ 27/8 → 55/16 | note:Ab5 ]",
|
||||
"[ 55/16 → 7/2 | note:Ab3 ]",
|
||||
"[ 7/2 → 57/16 | note:F9 ]",
|
||||
"[ 57/16 → 29/8 | note:D8 ]",
|
||||
"[ 29/8 → 59/16 | note:F5 ]",
|
||||
"[ 59/16 → 15/4 | note:Eb9 ]",
|
||||
"[ 15/4 → 61/16 | note:Bb4 ]",
|
||||
"[ 61/16 → 31/8 | note:C6 ]",
|
||||
"[ 31/8 → 63/16 | note:C4 ]",
|
||||
"[ 63/16 → 4/1 | note:G8 ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "velocity" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/8 | s:hh gain:0.4 velocity:0.4 ]",
|
||||
|
||||
@@ -20,6 +20,7 @@ const skippedExamples = [
|
||||
'accelerationX',
|
||||
'defaultmidimap',
|
||||
'midimaps',
|
||||
'bmod',
|
||||
];
|
||||
|
||||
describe('runs examples', () => {
|
||||
|
||||
@@ -16,5 +16,6 @@ export default defineConfig({
|
||||
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress}.config.*',
|
||||
'**/shared.test.mjs',
|
||||
],
|
||||
setupFiles: './vitest.setup.mjs',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { afterEach } from 'vitest';
|
||||
import { useRNG } from './packages/core/signal.mjs';
|
||||
|
||||
afterEach(() => {
|
||||
// Avoid bleed between tests
|
||||
useRNG('legacy');
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
import '../../repl/Repl.css';
|
||||
---
|
||||
|
||||
<div id="code"></div>
|
||||
|
||||
<script>
|
||||
import { DoughMirror } from './dough-mirror.mjs';
|
||||
const root = document.getElementById('code');
|
||||
const doughmirror = new DoughMirror({ root });
|
||||
globalThis.settings = doughmirror.settings.bind(doughmirror);
|
||||
</script>
|
||||
<style>
|
||||
#code {
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
initEditor,
|
||||
codemirrorSettings,
|
||||
flash,
|
||||
compartments,
|
||||
extensions,
|
||||
parseBooleans,
|
||||
activateTheme,
|
||||
updateMiniLocations,
|
||||
highlightMiniLocations,
|
||||
} from '@strudel/codemirror';
|
||||
import { evalScope, hash2code, code2hash } from '@strudel/core';
|
||||
import { Framer } from '@strudel/draw';
|
||||
import { persistentAtom } from '@nanostores/persistent';
|
||||
import { DoughRepl } from './dough-repl.mjs';
|
||||
|
||||
let initialCode = '$: note("c a f e")';
|
||||
|
||||
export const code = persistentAtom('vanilla-repl-code', initialCode, {
|
||||
encode: JSON.stringify,
|
||||
decode: JSON.parse,
|
||||
});
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const codeParam = window.location.href.split('#')[1] || '';
|
||||
if (codeParam) {
|
||||
const codeFromHash = hash2code(codeParam);
|
||||
code.set(codeFromHash);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('could not init code from url');
|
||||
}
|
||||
}
|
||||
|
||||
export class DoughMirror {
|
||||
constructor(options) {
|
||||
const { root, initialCode = code.get(), bgFill = true } = options;
|
||||
this.root = root;
|
||||
this.code = initialCode;
|
||||
this.repl = new DoughRepl();
|
||||
this.prebaked = this.prebake();
|
||||
|
||||
// init codemirror
|
||||
this.editor = initEditor({
|
||||
root,
|
||||
initialCode: this.code,
|
||||
onChange: (v) => {
|
||||
if (v.docChanged) {
|
||||
this.code = v.state.doc.toString();
|
||||
code.set(this.code);
|
||||
}
|
||||
},
|
||||
onEvaluate: this.evaluate.bind(this),
|
||||
onStop: () => this.stop(),
|
||||
mondo: false,
|
||||
});
|
||||
const settings = codemirrorSettings.get();
|
||||
this.setFontSize(settings.fontSize);
|
||||
this.setFontFamily(settings.fontFamily);
|
||||
|
||||
// init event highlighting
|
||||
this.framer = new Framer(
|
||||
(time) => {
|
||||
const frameHaps = this.repl.processHaps();
|
||||
highlightMiniLocations(this.editor, time, frameHaps);
|
||||
},
|
||||
(err) => console.log('Framer error', err),
|
||||
);
|
||||
}
|
||||
prebake() {
|
||||
const modulesLoading = evalScope(import('@strudel/core'), import('@strudel/tonal'), import('@strudel/mini'));
|
||||
return Promise.all([modulesLoading, this.repl.prebake()]);
|
||||
}
|
||||
async evaluate() {
|
||||
this.framer.start();
|
||||
this.flash();
|
||||
await this.prebaked;
|
||||
const { miniLocations } = await this.repl.evaluate(this.code);
|
||||
window.location.hash = '#' + code2hash(this.code);
|
||||
updateMiniLocations(this.editor, miniLocations);
|
||||
}
|
||||
stop() {
|
||||
this.repl.stop();
|
||||
this.framer.stop();
|
||||
highlightMiniLocations(this.editor, 0, []);
|
||||
}
|
||||
// added synonym (compared to StrudelMirror)
|
||||
settings(settings = {}) {
|
||||
this.updateSettings(settings);
|
||||
}
|
||||
|
||||
// the rest is copy pasted from StrudelMirror:
|
||||
|
||||
updateSettings(settings = {}) {
|
||||
settings.fontSize && this.setFontSize(settings.fontSize);
|
||||
settings.fontFamily && this.setFontFamily(settings.fontFamily);
|
||||
for (let key in extensions) {
|
||||
if (key in settings) {
|
||||
this.reconfigureExtension(key, settings[key]);
|
||||
}
|
||||
}
|
||||
const updated = { ...codemirrorSettings.get(), ...settings };
|
||||
// console.log(updated);
|
||||
codemirrorSettings.set(updated);
|
||||
}
|
||||
reconfigureExtension(key, value) {
|
||||
if (!extensions[key]) {
|
||||
console.warn(`extension ${key} is not known`);
|
||||
return;
|
||||
}
|
||||
value = parseBooleans(value);
|
||||
const newValue = extensions[key](value, this);
|
||||
this.editor.dispatch({
|
||||
effects: compartments[key].reconfigure(newValue),
|
||||
});
|
||||
if (key === 'theme') {
|
||||
activateTheme(value);
|
||||
}
|
||||
}
|
||||
flash(ms) {
|
||||
flash(this.editor, ms);
|
||||
}
|
||||
setFontSize(size) {
|
||||
this.root.style.fontSize = size + 'px';
|
||||
}
|
||||
setFontFamily(family) {
|
||||
this.root.style.fontFamily = family;
|
||||
const scroller = this.root.querySelector('.cm-scroller');
|
||||
if (scroller) {
|
||||
scroller.style.fontFamily = family;
|
||||
}
|
||||
}
|
||||
setCode(code, offset = 0) {
|
||||
const changes = {
|
||||
from: 0,
|
||||
to: this.editor.state.doc.length + offset,
|
||||
insert: code,
|
||||
};
|
||||
this.editor.dispatch({ changes });
|
||||
}
|
||||
getCursorLocation() {
|
||||
return this.editor.state.selection.main.head;
|
||||
}
|
||||
setCursorLocation(col) {
|
||||
return this.editor.dispatch({ selection: { anchor: col } });
|
||||
}
|
||||
appendCode(code) {
|
||||
const cursor = this.getCursorLocation();
|
||||
this.setCode(this.code + code);
|
||||
this.setCursorLocation(cursor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// import { Dough, doughsamples } from 'dough-synth';
|
||||
import { Dough, doughsamples } from 'https://unpkg.com/dough-synth@0.1.9/dough.js';
|
||||
import { Pattern, noteToMidi, evaluate, stack } from '@strudel/core';
|
||||
// import doughUrl from 'dough-synth?url';
|
||||
import { transpiler } from '@strudel/transpiler';
|
||||
//const doughBaseUrl = doughUrl.split('/').slice(0, -1).join('/') + '/';
|
||||
const doughBaseUrl = 'https://unpkg.com/dough-synth@0.1.9/';
|
||||
|
||||
Object.assign(globalThis, { doughsamples });
|
||||
|
||||
export class DoughRepl {
|
||||
pattern;
|
||||
latency = 0.1;
|
||||
cps = 0.5;
|
||||
origin;
|
||||
t0;
|
||||
lasttime;
|
||||
strudel;
|
||||
q = [];
|
||||
constructor() {
|
||||
this.ready = this.init();
|
||||
}
|
||||
async init() {
|
||||
// init dough immediately, so that it can attach the document click event to initAudio immediately
|
||||
this.dough = new Dough({
|
||||
base: doughBaseUrl,
|
||||
//base: "../", // local dev
|
||||
onTick: ({ t0, t1 }) => {
|
||||
if (!this.pattern) {
|
||||
return;
|
||||
}
|
||||
this.origin ??= t0;
|
||||
this.t0 = t0;
|
||||
this.lasttime = performance.now();
|
||||
const a = (t0 - this.origin) * this.cps;
|
||||
const b = (t1 - this.origin) * this.cps;
|
||||
|
||||
const haps = this.pattern.queryArc(a, b).filter((hap) => hap.hasOnset());
|
||||
if (!haps.length) {
|
||||
return;
|
||||
}
|
||||
haps.forEach((hap) => {
|
||||
const time = hap.whole.begin.valueOf() / this.cps + this.origin + this.latency;
|
||||
const duration = hap.duration.valueOf() / this.cps;
|
||||
const event = {
|
||||
dough: 'play',
|
||||
...hap.value,
|
||||
time,
|
||||
duration,
|
||||
};
|
||||
if (event.note && typeof event.note === 'string') {
|
||||
event.note = noteToMidi(event.note);
|
||||
}
|
||||
//console.log("event", JSON.stringify(Object.entries(event)));
|
||||
this.dough.evaluate(event);
|
||||
this.q.push({ event, hap });
|
||||
});
|
||||
},
|
||||
});
|
||||
// miniAllStrings();
|
||||
const setcps = (cps) => (this.cps = cps);
|
||||
const setcpm = (cpm) => setcps(cpm / 60);
|
||||
const replScope = { setcps, setcpm };
|
||||
Object.assign(globalThis, replScope);
|
||||
}
|
||||
async evaluate(code) {
|
||||
await this.ready;
|
||||
let patterns = [];
|
||||
Pattern.prototype.p = function (id) {
|
||||
if (!id.startsWith('_')) {
|
||||
patterns.push(this);
|
||||
}
|
||||
};
|
||||
|
||||
let { meta } = await evaluate(code, transpiler, { addReturn: false, wrapAsync: true, emitWidgets: false });
|
||||
const { miniLocations } = meta;
|
||||
|
||||
this.pattern = stack(...patterns);
|
||||
return { miniLocations, pattern: this.pattern };
|
||||
}
|
||||
stop() {
|
||||
this.pattern = undefined;
|
||||
this.origin = undefined;
|
||||
}
|
||||
prebake() {
|
||||
return Promise.all([
|
||||
// doughsamples('github:eddyflux/crate')
|
||||
]);
|
||||
}
|
||||
// tbd: move this to dough-synth
|
||||
get time() {
|
||||
return this.t0 + (performance.now() - this.lasttime) / 1000 + this.latency;
|
||||
}
|
||||
processHaps() {
|
||||
const currentHaps = [];
|
||||
const time = this.time;
|
||||
this.q = this.q.filter(({ event, hap }) => {
|
||||
const end = event.time + event.duration;
|
||||
const isActive = time >= event.time && time <= end;
|
||||
if (isActive) {
|
||||
currentHaps.push(hap);
|
||||
}
|
||||
return end > time; // delete old events
|
||||
// we do NOT return !isActive, because a frame might miss an event, which would cause a leak
|
||||
});
|
||||
// console.log(this.q.length); // to check for leaks
|
||||
return currentHaps;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
import HeadCommon from '../../components/HeadCommon.astro';
|
||||
import Dough from '../../components/Dough/Dough.astro';
|
||||
---
|
||||
|
||||
<html lang="en" class="m-0 dark">
|
||||
<head>
|
||||
<HeadCommon />
|
||||
<title>Strudel Dough REPL</title>
|
||||
</head>
|
||||
<body class="h-app-height bg-background m-0">
|
||||
<Dough />
|
||||
<a rel="me" href="https://social.toplap.org/@strudel" target="_blank" class="hidden">mastodon</a>
|
||||
</body>
|
||||
</html>
|
||||
@@ -11,9 +11,9 @@ import { JsDoc } from '../../docs/JsDoc';
|
||||
You can optionally add some music metadata in your Strudel code, by using tags in code comments:
|
||||
|
||||
```js
|
||||
// @title Hey Hoo
|
||||
// @by Sam Tagada
|
||||
// @license CC BY-NC-SA
|
||||
// @title My Cool Song
|
||||
// @by John Doe
|
||||
// @license CC-BY-SA-4.0
|
||||
```
|
||||
|
||||
Like other comments, those are ignored by Strudel, but it can be used by other tools to retrieve some information about the music.
|
||||
@@ -24,22 +24,22 @@ You can also use comment blocks:
|
||||
|
||||
```js
|
||||
/*
|
||||
@title Hey Hoo
|
||||
@by Sam Tagada
|
||||
@license CC BY-NC-SA
|
||||
@title My Cool Song
|
||||
@by John Doe
|
||||
@license CC-BY-SA-4.0
|
||||
*/
|
||||
```
|
||||
|
||||
Or define multiple tags in one line:
|
||||
|
||||
```js
|
||||
// @title Hey Hoo @by Sam Tagada @license CC BY-NC-SA
|
||||
// @title My Cool Song @by John Doe @license CC-BY-SA-4.0
|
||||
```
|
||||
|
||||
The `title` tag has an alternative syntax using quotes (must be defined at the very begining):
|
||||
|
||||
```js
|
||||
// "Hey Hoo" @by Sam Tagada
|
||||
// "My Cool Song" @by John Doe
|
||||
```
|
||||
|
||||
## Tags list
|
||||
@@ -48,20 +48,22 @@ Available tags are:
|
||||
|
||||
- `@title`: music title
|
||||
- `@by`: music author(s), separated by comma, eventually followed with a link in `<>` (ex: `@by John Doe <https://example.com>`)
|
||||
- `@license`: music license(s), e.g. CC BY-NC-SA. Unsure? [Choose a creative commons license here](https://creativecommons.org/choose/)
|
||||
- `@license`: music license(s), separated by comma. Each license should be specified by using the correct identifier in the [https://spdx.org/licenses/](SPDX License List). Example: CC-BY-SA-4.0. Unsure? [Choose a Creative Commons license here](https://creativecommons.org/choose/).
|
||||
- `@details`: some additional information about the music
|
||||
- `@url`: web page(s) related to the music (git repo, soundcloud link, etc.)
|
||||
- `@genre`: music genre(s) (pop, jazz, etc)
|
||||
- `@url`: web page(s) related to the music (git repository, Soundcloud link, etc.)
|
||||
- `@genre`: music genre(s) (pop, jazz, etc.)
|
||||
- `@album`: music album name
|
||||
|
||||
Note to tool authors: _Never_ trust that a song has filled those fields with syntactically correct values; make sure your software is robust enough it doesn't break if it encounters bad values
|
||||
|
||||
## Multiple values
|
||||
|
||||
Some of them accepts several values, using the comma or new line separator, or duplicating the tag:
|
||||
|
||||
```js
|
||||
/*
|
||||
@by Sam Tagada
|
||||
Jimmy
|
||||
@by John Doe
|
||||
Jane Doe
|
||||
@genre pop, jazz
|
||||
@url https://example.com
|
||||
@url https://example.org
|
||||
@@ -72,11 +74,11 @@ You can also add optional prefixes and use tags where you want:
|
||||
|
||||
```js
|
||||
/*
|
||||
song @by Sam Tagada
|
||||
samples @by Jimmy
|
||||
song @by John Doe
|
||||
samples @by Jane Doe
|
||||
*/
|
||||
...
|
||||
note("a3 c#4 e4 a4") // @by Sandy
|
||||
note("a3 c#4 e4 a4") // @by Sandy Sue
|
||||
```
|
||||
|
||||
## Multiline
|
||||
|
||||
@@ -142,6 +142,8 @@ The "~" represents a rest, and will create silence between other events:
|
||||
|
||||
<MiniRepl client:idle tune={`note("[b4 [~ c5] d5 e5]")`} punchcard />
|
||||
|
||||
Alternatively, "-" can be used instead of "~". It means the same thing.
|
||||
|
||||
## Parallel / polyphony
|
||||
|
||||
Using commas, we can play chords.
|
||||
|
||||
@@ -41,7 +41,7 @@ note("c3 [e3 g3]*2")
|
||||
is transpiled to:
|
||||
|
||||
```strudel
|
||||
note(m('c3 [e3 g3]', 5))
|
||||
note(m('c3 [e3 g3]*2', 5))
|
||||
```
|
||||
|
||||
Here, the string is wrapped in `m`, which will create a pattern from a mini-notation string. As the second parameter, it gets passed source code location of the string, which enables highlighting active events later.
|
||||
|
||||
@@ -35,3 +35,24 @@ Troubleshooting
|
||||
|
||||
- If :w logs but evaluation doesn't apply, ensure Vim keybindings are active and try again. You can also use Ctrl+Enter as a fallback.
|
||||
- For :q / gc, ensure focus is inside the editor. If an error occurs, reload the page to reset editor state and try again.
|
||||
|
||||
## Adding custom keybindings
|
||||
|
||||
To add custom keybindings the `Vim` object can be used (either from within a pattern or in a prebake script)
|
||||
|
||||
Example:
|
||||
|
||||
```javascript
|
||||
// Map 'jk' to Escape in normal mode
|
||||
Vim.map('jk', '<Esc>', 'insert');
|
||||
// Map 'U' to :w (Evaluate the current code)
|
||||
Vim.map('U', ':w<CR>', 'normal');
|
||||
// Map 'Q' to :q — Stop/pause playback
|
||||
Vim.map('Q', ':q<CR>', 'normal');
|
||||
// Map 'J' to find next '$' (jump to next label)
|
||||
Vim.map('J', '/\\$<CR>', 'normal');
|
||||
// Map 'K' to find previous '$' (jump to previous label)
|
||||
Vim.map('K', '?\\$<CR>', 'normal');
|
||||
```
|
||||
|
||||
For more information on how to use the `Vim` object see [CodeMirror Vim](https://github.com/replit/codemirror-vim)
|
||||
|
||||
@@ -377,4 +377,4 @@ insect [crow metal] - -,
|
||||
punchcard
|
||||
/>
|
||||
|
||||
Now that we know the basics of how to make beats, let's look at how we can play [notes](/workshop/first-notes)
|
||||
Now that we know the basics of how to make beats, let's look at how we can play [notes](/workshop/first-notes).
|
||||
|
||||
@@ -0,0 +1,651 @@
|
||||
/*
|
||||
audiograph.mjs - show a svg view of the web audio API graph built during a playback
|
||||
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/website/src/repl/audiograph.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// main entry point is `debugAudiograph`
|
||||
import { logger } from '@strudel/core';
|
||||
import { getAudioContext, getSuperdoughAudioController, webaudioOutput } from '@strudel/webaudio';
|
||||
|
||||
let mermaid = null;
|
||||
let svgPanZoom = null;
|
||||
|
||||
let running = false;
|
||||
let hap_count = 0;
|
||||
|
||||
let cache = new Map();
|
||||
const initCache = JSON.stringify({
|
||||
connect: [],
|
||||
where: [],
|
||||
disconnectAll: 0,
|
||||
disconnectOne: 0,
|
||||
hasStop: false,
|
||||
stopCount: 0,
|
||||
ac: null,
|
||||
creation: null,
|
||||
});
|
||||
|
||||
let toggleOrig;
|
||||
|
||||
function stackTrace() {
|
||||
var err = new Error();
|
||||
const stacktrace = err.stack;
|
||||
const lines = stacktrace.split('\n');
|
||||
let lineIndex = lines.findIndex((line) => line !== 'Error' && !line.includes('audiograph.mjs'));
|
||||
if (lines[lineIndex].includes('gainNode')) lineIndex++;
|
||||
if (lines[lineIndex].includes('getWorklet')) lineIndex++;
|
||||
const line = lines[lineIndex].replace(/\s*at\s/, '').replace('http', '@');
|
||||
let match;
|
||||
match = line.match(/([^@]*)@.*packages(\/[^:]+:\d+:\d+)/);
|
||||
if (match) {
|
||||
return match[1].replace(/[^.:/a-zA-Z0-9]/g, '') + '@' + match[2].replace(/[^.:/a-zA-Z0-9]/g, '');
|
||||
}
|
||||
return '@';
|
||||
}
|
||||
|
||||
// This captures all AudioNodes lazily
|
||||
// when an `.audioid` property is called
|
||||
// no solution was found to hook
|
||||
// AudioNode's constructor directly
|
||||
let audioid = 0;
|
||||
const lazyRegister = (o) => {
|
||||
Object.defineProperty(o.prototype, 'audioid', {
|
||||
get: function () {
|
||||
if (!this._audioid) {
|
||||
this._audioid = ++audioid;
|
||||
const s = JSON.parse(initCache);
|
||||
s.type = this.constructor.name === 'AudiographNode' ? this.constructor._parentClassName : this.constructor.name;
|
||||
// special case for subclassed AudioNodes
|
||||
// they are implemented in superdough but hard to get a reference on here
|
||||
// they are not AudioScheduledSourceNodes anyway
|
||||
if (['FeedbackDelayNode', 'VowelNode'].indexOf(s.type) === -1) {
|
||||
s.hasStop = window[s.type].prototype instanceof AudioScheduledSourceNode;
|
||||
}
|
||||
s.ac = this.context?.constructor.name || 'AudioParam';
|
||||
s.creation = s.creation || stackTrace();
|
||||
cache.set(this._audioid, s);
|
||||
}
|
||||
return this._audioid;
|
||||
},
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
});
|
||||
};
|
||||
|
||||
// extend a specific AudioNode's constructor
|
||||
// necessary when creation is done direclty by
|
||||
// calling the constructor
|
||||
// eg: new GainNode(...)
|
||||
const audioNodeHook = (node) => {
|
||||
const name = node.prototype.constructor.name;
|
||||
const PatchedNode = class AudiographNode extends node {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
// trigger the lazy register
|
||||
this._audioid = this.audioid;
|
||||
}
|
||||
};
|
||||
PatchedNode._parentClassName = name;
|
||||
window[name] = PatchedNode;
|
||||
};
|
||||
|
||||
const drawMessage = async function (message) {
|
||||
const element = document.querySelector('.strudel-mermaid');
|
||||
let gd = '';
|
||||
gd += '---\n';
|
||||
gd += 'config:\n';
|
||||
gd += ' flowchart:\n';
|
||||
gd += ' wrappingWidth: 600\n';
|
||||
gd += '---\n';
|
||||
gd += 'flowchart LR\n';
|
||||
gd += 'id[' + message.replaceAll(' ', ' ') + ']\n';
|
||||
|
||||
let { svg } = await mermaid.render('strudelSvgId', gd);
|
||||
svg = svg.replace(/max-width:\s[0-9.]*px;/i, 'height: 100%');
|
||||
svg = svg.replaceAll('&nbsp;', ' ');
|
||||
element.innerHTML = svg;
|
||||
};
|
||||
|
||||
const drawDiagram = async function () {
|
||||
const element = document.querySelector('.strudel-mermaid');
|
||||
let code = window.strudelMirror.code;
|
||||
code = code.replace(/^await debugAudiograph.*\n?/gm, '');
|
||||
code = '// date: ' + new Date().toISOString() + '\n\n' + code;
|
||||
code = '// host: ' + document.location.hostname + '\n' + code;
|
||||
const codeLines = code.split(/(?:\n|\r\n?)/);
|
||||
const maxLineLength = codeLines.reduce((memo, line) => Math.max(memo, line.length), 0);
|
||||
|
||||
// https://mermaid.js.org/syntax/flowchart.html
|
||||
let gd = '';
|
||||
gd += '---\n';
|
||||
gd += 'config:\n';
|
||||
gd += ' flowchart:\n';
|
||||
gd += ' wrappingWidth: ' + 14 * maxLineLength + '\n';
|
||||
gd += '---\n';
|
||||
gd += 'flowchart TB\n';
|
||||
gd += '\tsubgraph AG[STRUDEL AUDIOGRAPH]\n';
|
||||
|
||||
// seed graph builder with all
|
||||
// unconnected nodes
|
||||
let lookup = [];
|
||||
cache.forEach((v, k) => {
|
||||
if (v.connect.length === 0) lookup.push(k);
|
||||
});
|
||||
const relations = [];
|
||||
let curRelations;
|
||||
const zombieCount = 0;
|
||||
const sourceLoc = (stack) => {
|
||||
if (stack === '@') return stack;
|
||||
return stack.replace('@', '\n').replace('/superdough/', '/');
|
||||
};
|
||||
const label = (s) => {
|
||||
const source = s.creation ? '\n' + sourceLoc(s.creation) : '';
|
||||
let lb = '[' + '**' + s.type + '**' + source + ']';
|
||||
if (s.ac === 'OfflineAudioContext') lb = '[' + lb + ']';
|
||||
return lb;
|
||||
};
|
||||
const isConnectLeak = (s) => {
|
||||
return (
|
||||
['AudioDestinationNode', 'AudioParam'].indexOf(s.type) === -1 &&
|
||||
s.disconnectAll === 0 &&
|
||||
s.connect.length > s.disconnectOne
|
||||
);
|
||||
};
|
||||
const isStopLeak = (s) => {
|
||||
return s.hasStop && s.stopCount === 0;
|
||||
};
|
||||
do {
|
||||
curRelations = relations.length;
|
||||
lookup.slice().forEach((n) => {
|
||||
cache.forEach((v, k) => {
|
||||
if (v.connect.indexOf(n) !== -1) {
|
||||
if (lookup.indexOf(k) === -1) lookup.push(k);
|
||||
gd += v.connect
|
||||
.map((i) => {
|
||||
if (lookup.indexOf(i) === -1) lookup.push(i);
|
||||
if (relations.indexOf(k + '-' + i) === -1) {
|
||||
relations.push(k + '-' + i);
|
||||
return (
|
||||
'\t\tnode' +
|
||||
k +
|
||||
label(v) +
|
||||
' -- ' +
|
||||
sourceLoc(v.where[0]) +
|
||||
' --> node' +
|
||||
i +
|
||||
label(cache.get(i)) +
|
||||
'\n'
|
||||
);
|
||||
}
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
if (k === n) {
|
||||
gd += v.connect
|
||||
.map((i) => {
|
||||
if (lookup.indexOf(i) === -1) lookup.push(i);
|
||||
if (relations.indexOf(k + '-' + i) === -1) {
|
||||
relations.push(k + '-' + i);
|
||||
return (
|
||||
'\t\tnode' +
|
||||
k +
|
||||
label(v) +
|
||||
' -- ' +
|
||||
sourceLoc(v.where[0]) +
|
||||
' --> node' +
|
||||
i +
|
||||
label(cache.get(i)) +
|
||||
'\n'
|
||||
);
|
||||
}
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
});
|
||||
});
|
||||
} while (relations.length > curRelations /*&& lookup.length < 100*/);
|
||||
// add orphan nodes
|
||||
const inRelation = '-' + relations.join('-') + '-';
|
||||
cache.forEach((v, k) => {
|
||||
if (!inRelation.includes('-' + k + '-')) {
|
||||
gd += '\t\tnode' + k + label(v) + '\n';
|
||||
}
|
||||
});
|
||||
|
||||
const codePlaceholder = 'm'.repeat(maxLineLength);
|
||||
gd += '\tsubgraph LEGEND\n';
|
||||
gd += '\t\tlegend1[in AudioContext]\n';
|
||||
gd += '\t\tlegend2[[in OfflineAudioContext]]\n';
|
||||
gd += '\t\tlegend3[not disconnected]\n';
|
||||
gd += '\t\tlegend4[AudioParam]\n';
|
||||
gd += '\t\tlegend5[AudioDestinationNode]\n';
|
||||
gd += '\t\tlegend6[not stopped]\n';
|
||||
gd += '\tend\n';
|
||||
gd += '\tsubgraph CODE[Strudel Code]\n';
|
||||
// we use a codePlaceholder to
|
||||
// - avoid problems with special chars
|
||||
// - stop mermaid to split lines on space with multiple tspans
|
||||
// - force mermaid to prepare a sufficiently sized zone
|
||||
gd += '\ncode[' + (codePlaceholder + '<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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
svg = svg.replaceAll(codePlaceholder, () => escapeHtml(codeLines[idx++]));
|
||||
|
||||
// improve sizing on web page
|
||||
svg = svg.replace(/max-width:\s[0-9.]*px;/i, 'height: 100%');
|
||||
element.innerHTML = svg;
|
||||
|
||||
// align the code lines
|
||||
let svgText = document.querySelector('[id^=flowchart-code] text');
|
||||
svgText.setAttributeNS(null, 'style', 'text-anchor: start;');
|
||||
const svgElement = document.querySelector('svg');
|
||||
const svgLabel = document.querySelector('svg [id^=flowchart-code] .label');
|
||||
const transformList = svgLabel.transform.baseVal;
|
||||
const svgTransform = svgElement.createSVGTransform();
|
||||
const tspans = Array.from(document.querySelectorAll('[id^=flowchart-code] tspan.text-inner-tspan'));
|
||||
let tspansMaxLength = tspans.reduce((memo, tspan) => Math.max(memo, tspan.getComputedTextLength()), 0);
|
||||
svgTransform.setTranslate(-tspansMaxLength / 2, 0);
|
||||
transformList.appendItem(svgTransform);
|
||||
|
||||
let doPan = false;
|
||||
let eventsHandler;
|
||||
let panZoom;
|
||||
let mousepos;
|
||||
|
||||
eventsHandler = {
|
||||
haltEventListeners: ['mousedown', 'mousemove', 'mouseup'],
|
||||
mouseDownHandler: function (ev) {
|
||||
if (event.target.className == '[object SVGAnimatedString]') {
|
||||
doPan = true;
|
||||
mousepos = {
|
||||
x: ev.clientX,
|
||||
y: ev.clientY,
|
||||
};
|
||||
}
|
||||
},
|
||||
mouseMoveHandler: function (ev) {
|
||||
if (doPan) {
|
||||
panZoom.panBy({
|
||||
x: ev.clientX - mousepos.x,
|
||||
y: ev.clientY - mousepos.y,
|
||||
});
|
||||
mousepos = {
|
||||
x: ev.clientX,
|
||||
y: ev.clientY,
|
||||
};
|
||||
window.getSelection().removeAllRanges();
|
||||
}
|
||||
},
|
||||
mouseUpHandler: function (ev) {
|
||||
doPan = false;
|
||||
},
|
||||
init: function (options) {
|
||||
options.svgElement.addEventListener('mousedown', this.mouseDownHandler, false);
|
||||
options.svgElement.addEventListener('mousemove', this.mouseMoveHandler, false);
|
||||
options.svgElement.addEventListener('mouseup', this.mouseUpHandler, false);
|
||||
},
|
||||
destroy: function (options) {
|
||||
options.svgElement.removeEventListener('mousedown', this.mouseDownHandler, false);
|
||||
options.svgElement.removeEventListener('mousemove', this.mouseMoveHandler, false);
|
||||
options.svgElement.removeEventListener('mouseup', this.mouseUpHandler, false);
|
||||
},
|
||||
};
|
||||
panZoom = svgPanZoom('#strudelSvgId', {
|
||||
zoomEnabled: true,
|
||||
controlIconsEnabled: true,
|
||||
fit: 1,
|
||||
center: 1,
|
||||
zoomScaleSensitivity: 0.4,
|
||||
customEventsHandler: eventsHandler,
|
||||
});
|
||||
};
|
||||
|
||||
const svgExport = async () => {
|
||||
const a = document.createElement('a');
|
||||
document.body.appendChild(a);
|
||||
a.style = 'display: none';
|
||||
|
||||
const selector = '.strudel-mermaid';
|
||||
const bbox = document.querySelector('svg g').getBBox();
|
||||
let transform, style;
|
||||
// clean pan-zoom viewport
|
||||
const pzViewport = document.querySelector('.svg-pan-zoom_viewport');
|
||||
if (pzViewport) {
|
||||
transform = pzViewport.transform;
|
||||
style = pzViewport.style;
|
||||
pzViewport.setAttribute('transform', '');
|
||||
pzViewport.style = '';
|
||||
}
|
||||
|
||||
const spzMin = await fetch('https://cdn.jsdelivr.net/npm/svg-pan-zoom@3.6.2/dist/svg-pan-zoom.min.js').then((res) =>
|
||||
res.text(),
|
||||
);
|
||||
|
||||
const scriptContent = '<![CDATA[' + spzMin + ';svgPanZoom("svg");]]>';
|
||||
// prepare svg
|
||||
const content = document
|
||||
.querySelector(selector)
|
||||
.innerHTML.replaceAll('<br>', '<br/>')
|
||||
// remove useless tags
|
||||
.replace(/<g id="svg-pan-zoom.*<\/g>/, '<script>' + scriptContent + '</script>')
|
||||
.replace(/<defs>.*<\/defs>/, '')
|
||||
// give inkscape true sizes
|
||||
.replace('width="100%"', 'width="' + bbox.width + '" height="' + bbox.height + '"');
|
||||
|
||||
// restore pan-zoom viewport
|
||||
if (pzViewport) {
|
||||
pzViewport.setAttribute('transform', transform);
|
||||
pzViewport.style = style;
|
||||
}
|
||||
|
||||
// trigger download
|
||||
var blob = new Blob([content], { type: 'image/svg+xml' }),
|
||||
url = window.URL.createObjectURL(blob);
|
||||
a.href = url;
|
||||
a.download = 'audiograph.svg';
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const resetAudioOutput = function (audioid) {
|
||||
// calling reset on SuperdoughAudioController
|
||||
// will discard output nodes AND recreate them
|
||||
// so we keep the same `cache` to handle the
|
||||
// `disconnects` knowing that new nodes will be
|
||||
// stricly after the current audioid.
|
||||
// then we purge the old nodes from the `cache`
|
||||
// to have a clean state
|
||||
|
||||
// make sure destination will be recreated in the
|
||||
// cache
|
||||
const destination = getAudioContext().destination;
|
||||
if (destination._audioid) delete destination._audioid;
|
||||
|
||||
const sac = getSuperdoughAudioController();
|
||||
sac.reset();
|
||||
Array.from(cache.keys()).map((k) => {
|
||||
if (k <= audioid) cache.delete(k);
|
||||
});
|
||||
};
|
||||
|
||||
const postProcessing = async function () {
|
||||
hap_count = 0;
|
||||
await drawDiagram();
|
||||
|
||||
resetAudioOutput(audioid);
|
||||
};
|
||||
|
||||
const defaultOptions = {
|
||||
StopAfterHapCount: 10,
|
||||
hapsBatch: 0,
|
||||
maxEdges: 10000,
|
||||
maxTextSize: 200000,
|
||||
audioAPIBreathingRoomSec: 5,
|
||||
};
|
||||
|
||||
// `StopAfterHapCount` :
|
||||
// The player will auto-stop after hap count have
|
||||
// been played. when StopAfterHapCount = 0, it will
|
||||
// continue playing until 'stop' is clicked
|
||||
// `audioAPIBreathingRoomSec` :
|
||||
// how much time should we wait after 'stop' to let
|
||||
// the audioAPI finish its tail of ondended calls
|
||||
// `hapsBatch` :
|
||||
// the AudioGraph will be displayed every hapsBatch haps
|
||||
// when hapsBatch = 0, AudioGraph will only be displayed
|
||||
// after and auto-stop or after 'stop' is clicked
|
||||
// In hapsBatch mode you will probably see a trailing of
|
||||
// non disconnected notes on the graph because the audio
|
||||
// API may have some lag disconnecting them
|
||||
// cf also audioAPIBreathingRoomSec
|
||||
// `maxEdges`
|
||||
// This is a mermaid.js config that forces a hard limit
|
||||
// on the maximum number of Edges of a graph
|
||||
// needs a reload to be taken into account
|
||||
// `maxTextSize`
|
||||
// This is a mermaid.js config that forces a hard limit
|
||||
// on the maximum text size of a graph definition
|
||||
// needs a reload to be taken into account
|
||||
|
||||
export const debugAudiograph = async (argOptions = {}) => {
|
||||
const options = Object.assign({}, defaultOptions, argOptions);
|
||||
const { StopAfterHapCount, hapsBatch, maxEdges, maxTextSize, audioAPIBreathingRoomSec } = options;
|
||||
const sm = window.strudelMirror;
|
||||
const code = sm.code;
|
||||
if (!code.match(/await\s+debugAudiograph/)) {
|
||||
throw new Error('you need to call `await debugAudiograph()` for audiograph to work');
|
||||
}
|
||||
const emptyOptions = /await\s+debugAudiograph\(\)/.exec(code);
|
||||
if (emptyOptions) {
|
||||
const cutCode = emptyOptions.index + emptyOptions[0].length - 1;
|
||||
const codeOptions = JSON.stringify({ StopAfterHapCount: StopAfterHapCount }).replaceAll('"', '');
|
||||
sm.setCode(code.slice(0, cutCode) + codeOptions + code.slice(cutCode));
|
||||
}
|
||||
|
||||
if (window.audiograph === undefined) {
|
||||
const ag = (window.audiograph = {});
|
||||
|
||||
toggleOrig = sm.toggle;
|
||||
|
||||
////////////////////////////////////////
|
||||
// step 1: web audio api instrumentation
|
||||
////////////////////////////////////////
|
||||
|
||||
// path AudioNode & AudioParam
|
||||
// to give them lazy ids
|
||||
// this captures both `ac.createGain`
|
||||
// and `new GainNode(..)` patterns
|
||||
lazyRegister(AudioNode);
|
||||
lazyRegister(AudioParam);
|
||||
lazyRegister(PeriodicWave);
|
||||
|
||||
const audioNodes = [
|
||||
AudioBufferSourceNode,
|
||||
AudioWorkletNode,
|
||||
AnalyserNode,
|
||||
BiquadFilterNode,
|
||||
ChannelMergerNode,
|
||||
ChannelSplitterNode,
|
||||
ConstantSourceNode,
|
||||
ConvolverNode,
|
||||
DelayNode,
|
||||
DynamicsCompressorNode,
|
||||
GainNode,
|
||||
IIRFilterNode,
|
||||
OscillatorNode,
|
||||
PannerNode,
|
||||
StereoPannerNode,
|
||||
WaveShaperNode,
|
||||
];
|
||||
audioNodes.map((n) => {
|
||||
if (n.prototype instanceof AudioScheduledSourceNode) {
|
||||
const stopOrig = n.prototype.stop;
|
||||
n.prototype.stop = function (...args) {
|
||||
// stop called
|
||||
const result = stopOrig.call(this, ...args);
|
||||
const s = cache.get(this.audioid);
|
||||
s.stopCount++;
|
||||
return result;
|
||||
};
|
||||
}
|
||||
audioNodeHook(n);
|
||||
});
|
||||
|
||||
// patch BaseAudioContext factory methods
|
||||
// to capture the source reference
|
||||
Object.getOwnPropertyNames(BaseAudioContext.prototype)
|
||||
.filter((n) => n.startsWith('create') && ['createBuffer'].indexOf(n) === -1)
|
||||
.map((name) => {
|
||||
const orig = BaseAudioContext.prototype[name];
|
||||
BaseAudioContext.prototype[name] = function (...args) {
|
||||
const result = orig.call(this, ...args);
|
||||
const s = cache.get(result.audioid);
|
||||
s.creation = stackTrace();
|
||||
return result;
|
||||
};
|
||||
});
|
||||
|
||||
const connectOrig = AudioNode.prototype.connect;
|
||||
AudioNode.prototype.connect = function (destination, ...args) {
|
||||
const result = connectOrig.call(this, destination, ...args);
|
||||
const s = cache.get(this.audioid);
|
||||
s.connect.push(destination.audioid);
|
||||
s.where.push(stackTrace());
|
||||
return result;
|
||||
};
|
||||
|
||||
const disconnectOrig = AudioNode.prototype.disconnect;
|
||||
AudioNode.prototype.disconnect = function (destination, ...args) {
|
||||
const result = disconnectOrig.call(this, destination, ...args);
|
||||
const s = cache.get(this.audioid);
|
||||
if (s.connect.length) {
|
||||
if (destination) {
|
||||
s.disconnectOne++;
|
||||
} else {
|
||||
s.disconnectAll++;
|
||||
}
|
||||
} else {
|
||||
logger('WEIRD: node ' + this.audioid + 'called disconnect before any call to connect !');
|
||||
//logger(new Error().stack);
|
||||
console.log(cache);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// call reset 2 times to handle reload + 'play'
|
||||
// the first reset's disconnect adds audioid tags on previous outputs
|
||||
// that were not tagged (wrong cutoff)
|
||||
resetAudioOutput(audioid);
|
||||
// the second reset has the correct audioid cutoff
|
||||
resetAudioOutput(audioid);
|
||||
|
||||
////////////////////////////////////////
|
||||
// step 2: Load external modules
|
||||
////////////////////////////////////////
|
||||
|
||||
const { default: mermaidModule } = await import(
|
||||
'https://cdn.jsdelivr.net/npm/mermaid@11.12.1/dist/mermaid.esm.mjs'
|
||||
);
|
||||
mermaid = mermaidModule;
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
themeCSS: '.flowchart { height: 100%; }',
|
||||
maxEdges: maxEdges,
|
||||
maxTextSize: maxTextSize,
|
||||
htmlLabels: false,
|
||||
flowchart: {
|
||||
htmlLabels: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { default: svgPanZoomModule } = await import('https://esm.sh/svg-pan-zoom');
|
||||
svgPanZoom = svgPanZoomModule;
|
||||
|
||||
//////////////////////////////////////////
|
||||
// step 3: UI modifications
|
||||
//////////////////////////////////////////
|
||||
|
||||
// add audiograph panel
|
||||
if (!document.querySelector('.strudel-mermaid')) {
|
||||
const mermaidDiv = document.createElement('div');
|
||||
mermaidDiv.className = 'strudel-mermaid';
|
||||
mermaidDiv.style = 'min-height: 600px; width: 60%';
|
||||
const referenceNode = document.querySelector('#code');
|
||||
referenceNode.parentNode.insertBefore(mermaidDiv, referenceNode.nextSibling);
|
||||
}
|
||||
|
||||
// add svg export button
|
||||
if (!document.querySelector('button[title=svg]')) {
|
||||
const exportButton = document.createElement('button');
|
||||
exportButton.innerHTML = '<span>ExportDiagram</span>';
|
||||
exportButton.title = 'svg';
|
||||
exportButton.onclick = svgExport;
|
||||
const updateButton = document.querySelector('button[title=update]');
|
||||
updateButton.parentNode.insertBefore(exportButton, updateButton);
|
||||
}
|
||||
}
|
||||
|
||||
if (!running) {
|
||||
running = true;
|
||||
}
|
||||
|
||||
if (hapsBatch === 0 || hap_count < hapsBatch) {
|
||||
let msg = '';
|
||||
msg += 'Recording activity...';
|
||||
msg += '\npress stop to build diagram';
|
||||
if (StopAfterHapCount) {
|
||||
msg += '\nwill stop automatically in ' + Math.max(StopAfterHapCount - hap_count, 0) + ' haps';
|
||||
}
|
||||
await drawMessage(msg);
|
||||
}
|
||||
|
||||
sm.toggle = async () => {
|
||||
running = false;
|
||||
sm.toggle = toggleOrig;
|
||||
// schedule `toggle` on the js main loop
|
||||
// to avoid interfering with any on-flight onTick
|
||||
// not doing this can lead to a phase > 0 which will
|
||||
// break the next start
|
||||
setTimeout(sm.toggle.bind(sm), 0);
|
||||
await drawMessage('please wait ' + audioAPIBreathingRoomSec + ' seconds\n' + 'the audio API is finishing its work');
|
||||
setTimeout(postProcessing, audioAPIBreathingRoomSec * 1000);
|
||||
};
|
||||
|
||||
/*global all*/
|
||||
all((pat) =>
|
||||
pat.onTrigger(async (hap, duration, cps, t) => {
|
||||
hap_count++;
|
||||
const key = Object.entries(hap.value)
|
||||
.map((param) => param.join('/'))
|
||||
.join('/');
|
||||
|
||||
// if we reached StopAfterHapCount, click 'stop'
|
||||
if (StopAfterHapCount && hap_count > StopAfterHapCount) {
|
||||
if (running) {
|
||||
await sm.toggle();
|
||||
}
|
||||
// stop sending haps to superdough(...)
|
||||
return;
|
||||
}
|
||||
|
||||
await webaudioOutput(hap, t, hap.duration / cps, cps, t);
|
||||
|
||||
if (hapsBatch && hap_count % hapsBatch === 0) drawDiagram();
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon';
|
||||
import cx from '@src/cx.mjs';
|
||||
import NumberInput from '@src/repl/components/NumberInput';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Textbox } from '../textbox/Textbox';
|
||||
import { getAudioContext } from '@strudel/webaudio';
|
||||
import XMarkIcon from '@heroicons/react/24/outline/XMarkIcon';
|
||||
|
||||
function Checkbox({ label, value, onChange, disabled = false }) {
|
||||
return (
|
||||
<label className={cx(disabled && 'opacity-50')}>
|
||||
<input disabled={disabled} type="checkbox" checked={value} onChange={onChange} />
|
||||
{' ' + label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function FormItem({ label, children, disabled }) {
|
||||
return (
|
||||
<div className="grid gap-2 w-full">
|
||||
<label className={cx(disabled && 'opacity-50')}>{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExportTab(Props) {
|
||||
const { handleExport } = Props;
|
||||
|
||||
const [downloadName, setDownloadName] = useState('');
|
||||
const [startCycle, setStartCycle] = useState(0);
|
||||
const [endCycle, setEndCycle] = useState(1);
|
||||
const [sampleRate, setSampleRate] = useState(48000);
|
||||
const [multiChannelOrbits, setMultiChannelOrbits] = useState(true);
|
||||
const [maxPolyphony, setMaxPolyphony] = useState(1024);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [length, setLength] = useState(1);
|
||||
|
||||
const refreshProgress = () => {
|
||||
const audioContext = getAudioContext();
|
||||
if (audioContext instanceof OfflineAudioContext) {
|
||||
setProgress(audioContext.currentTime);
|
||||
setLength(audioContext.length / sampleRate);
|
||||
setTimeout(refreshProgress, 100);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-foreground w-full p-4 space-y-4">
|
||||
<FormItem label="File name" disabled={exporting}>
|
||||
<Textbox
|
||||
onBlur={(e) => {
|
||||
setDownloadName(e.target.value);
|
||||
}}
|
||||
onChange={(v) => {
|
||||
setDownloadName(v);
|
||||
}}
|
||||
disabled={exporting}
|
||||
placeholder="Leave empty to use current date"
|
||||
className={cx('placeholder:opacity-50', exporting && 'opacity-50 border-opacity-50')}
|
||||
value={downloadName ?? ''}
|
||||
/>
|
||||
</FormItem>
|
||||
<div className="flex flex-row gap-4 w-full">
|
||||
<FormItem label="Start cycle" disabled={exporting}>
|
||||
<Textbox
|
||||
min={1}
|
||||
max={Infinity}
|
||||
onBlur={(e) => {
|
||||
let v = parseInt(e.target.value);
|
||||
v = isNaN(v) ? 0 : Math.max(0, v);
|
||||
setStartCycle(v);
|
||||
}}
|
||||
onChange={(v) => {
|
||||
v = parseInt(v);
|
||||
setStartCycle(v);
|
||||
}}
|
||||
type="number"
|
||||
placeholder=""
|
||||
disabled={exporting}
|
||||
className={cx(exporting && 'opacity-50 border-opacity-50', 'w-full')}
|
||||
value={startCycle ?? ''}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="End cycle" disabled={exporting}>
|
||||
<Textbox
|
||||
min={1}
|
||||
max={Infinity}
|
||||
onBlur={(e) => {
|
||||
let v = parseInt(e.target.value);
|
||||
v = isNaN(v) ? Math.max(startCycle + 1, parseInt(v)) : v;
|
||||
setEndCycle(v);
|
||||
}}
|
||||
onChange={(v) => {
|
||||
v = parseInt(v);
|
||||
setEndCycle(v);
|
||||
}}
|
||||
type="number"
|
||||
placeholder=""
|
||||
disabled={exporting}
|
||||
className={cx(exporting && 'opacity-50 border-opacity-50', 'w-full')}
|
||||
value={endCycle ?? ''}
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
<div className="flex flex-row gap-4">
|
||||
<FormItem label="Sample rate" disabled={exporting}>
|
||||
<Textbox
|
||||
min={1}
|
||||
max={Infinity}
|
||||
onBlur={(e) => {
|
||||
let v = parseInt(e.target.value);
|
||||
v = isNaN(v) ? 1 : Math.max(1, v);
|
||||
setSampleRate(v);
|
||||
}}
|
||||
onChange={(v) => {
|
||||
v = parseInt(v);
|
||||
setSampleRate(v);
|
||||
}}
|
||||
type="number"
|
||||
placeholder=""
|
||||
disabled={exporting}
|
||||
className={cx(exporting && 'opacity-50 border-opacity-50')}
|
||||
value={sampleRate ?? ''}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="Maximum polyphony" disabled={exporting}>
|
||||
<Textbox
|
||||
min={1}
|
||||
max={Infinity}
|
||||
onBlur={(e) => {
|
||||
let v = parseInt(e.target.value);
|
||||
v = isNaN(v) ? Math.max(1, parseInt(v)) : v;
|
||||
setMaxPolyphony(v);
|
||||
}}
|
||||
onChange={(v) => {
|
||||
v = Math.max(1, parseInt(v));
|
||||
setMaxPolyphony(v);
|
||||
}}
|
||||
type="number"
|
||||
placeholder=""
|
||||
disabled={exporting}
|
||||
className={cx(exporting && 'opacity-50 border-opacity-50')}
|
||||
value={maxPolyphony ?? ''}
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
<div>
|
||||
<Checkbox
|
||||
label="Multi Channel Orbits"
|
||||
onChange={(cbEvent) => {
|
||||
const val = cbEvent.target.checked;
|
||||
setMultiChannelOrbits(val);
|
||||
}}
|
||||
disabled={exporting}
|
||||
value={multiChannelOrbits}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className={cx('bg-background p-2 w-full rounded-md hover:opacity-75 relative', exporting && 'opacity-50')}
|
||||
disabled={exporting}
|
||||
onClick={async () => {
|
||||
setExporting(true);
|
||||
setTimeout(refreshProgress, 2000);
|
||||
const modal = document.getElementById('exportProgressModal');
|
||||
modal.showModal();
|
||||
await handleExport(startCycle, endCycle, sampleRate, maxPolyphony, multiChannelOrbits, downloadName)
|
||||
.then(() => {
|
||||
const modal = document.getElementById('exportProgressModal');
|
||||
modal.close();
|
||||
})
|
||||
.finally(() => {
|
||||
setExporting(false);
|
||||
setProgress(0);
|
||||
setLength(1);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 bottom-0 backdrop-invert"
|
||||
style={{
|
||||
width: `${(exporting ? 1 : 0) + (progress / length) * 99}%`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-foreground">{exporting ? 'Exporting...' : 'Export to WAV'}</span>
|
||||
</button>
|
||||
</div>
|
||||
<dialog
|
||||
closedby={exporting ? 'none' : 'closerequest'}
|
||||
id="exportProgressModal"
|
||||
className="text-md bg-background text-foreground rounded-lg backdrop:bg-background backdrop:opacity-25"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { useLogger } from '../useLogger';
|
||||
import { WelcomeTab } from './WelcomeTab';
|
||||
import { PatternsTab } from './PatternsTab';
|
||||
import { ChevronLeftIcon, XMarkIcon } from '@heroicons/react/16/solid';
|
||||
import ExportTab from './ExportTab';
|
||||
|
||||
const TAURI = typeof window !== 'undefined' && window.__TAURI__;
|
||||
|
||||
@@ -80,6 +81,7 @@ const tabNames = {
|
||||
patterns: 'patterns',
|
||||
sounds: 'sounds',
|
||||
reference: 'reference',
|
||||
export: 'export',
|
||||
console: 'console',
|
||||
settings: 'settings',
|
||||
};
|
||||
@@ -126,6 +128,8 @@ function PanelContent({ context, tab }) {
|
||||
return <SoundsTab />;
|
||||
case tabNames.reference:
|
||||
return <Reference />;
|
||||
case tabNames.export:
|
||||
return <ExportTab handleExport={context.handleExport} />;
|
||||
case tabNames.settings:
|
||||
return <SettingsTab started={context.started} />;
|
||||
case tabNames.files:
|
||||
|
||||
@@ -393,6 +393,8 @@ samples({
|
||||
bass: { d2: 'https://cdn.freesound.org/previews/608/608286_13074022-lq.mp3' }
|
||||
})
|
||||
|
||||
useRNG('legacy')
|
||||
|
||||
stack(
|
||||
// bells
|
||||
n("0").euclidLegato(3,8)
|
||||
@@ -430,6 +432,7 @@ export const festivalOfFingers3 = `// "Festival of fingers 3"
|
||||
// @by Felix Roos
|
||||
|
||||
setcps(1)
|
||||
useRNG('legacy')
|
||||
|
||||
n("[-7*3],0,2,6,[8 7]")
|
||||
.echoWith(
|
||||
@@ -454,6 +457,8 @@ export const meltingsubmarine = `// "Melting submarine"
|
||||
// @by Felix Roos
|
||||
|
||||
samples('github:tidalcycles/dirt-samples')
|
||||
useRNG('legacy')
|
||||
|
||||
stack(
|
||||
s("bd:5,[~ <sd:1!3 sd:1(3,4,3)>],hh27(3,4,1)") // drums
|
||||
.speed(perlin.range(.7,.9)) // random sample speed variation
|
||||
@@ -602,6 +607,9 @@ export const belldub = `// "Belldub"
|
||||
|
||||
samples({ bell: {b4:'https://cdn.freesound.org/previews/339/339809_5121236-lq.mp3'}})
|
||||
// "Hand Bells, B, Single.wav" by InspectorJ (www.jshaw.co.uk) of Freesound.org
|
||||
|
||||
useRNG('legacy')
|
||||
|
||||
stack(
|
||||
// bass
|
||||
note("[0 ~] [2 [0 2]] [4 4*2] [[4 ~] [2 ~] 0@2]".scale('g1 dorian').superimpose(x=>x.add(.02)))
|
||||
@@ -638,6 +646,7 @@ export const dinofunk = `// "Dinofunk"
|
||||
// @by Felix Roos
|
||||
|
||||
setcps(1)
|
||||
useRNG('legacy')
|
||||
|
||||
samples({bass:'https://cdn.freesound.org/previews/614/614637_2434927-hq.mp3',
|
||||
dino:{b4:'https://cdn.freesound.org/previews/316/316403_5123851-hq.mp3'}})
|
||||
@@ -666,6 +675,8 @@ export const sampleDemo = `// "Sample demo"
|
||||
// @license CC BY-NC-SA 4.0 https://creativecommons.org/licenses/by-nc-sa/4.0/
|
||||
// @by Felix Roos
|
||||
|
||||
useRNG('legacy')
|
||||
|
||||
stack(
|
||||
// percussion
|
||||
s("[woodblock:1 woodblock:2*2] snare_rim:0,gong/8,brakedrum:1(3,8),~@3 cowbell:3")
|
||||
@@ -684,6 +695,8 @@ export const holyflute = `// "Holy flute"
|
||||
// @license CC BY-NC-SA 4.0 https://creativecommons.org/licenses/by-nc-sa/4.0/
|
||||
// @by Felix Roos
|
||||
|
||||
useRNG('legacy')
|
||||
|
||||
"c3 eb3(3,8) c4/2 g3*2"
|
||||
.superimpose(
|
||||
x=>x.slow(2).add(12),
|
||||
@@ -699,6 +712,8 @@ export const flatrave = `// "Flatrave"
|
||||
// @license CC BY-NC-SA 4.0 https://creativecommons.org/licenses/by-nc-sa/4.0/
|
||||
// @by Felix Roos
|
||||
|
||||
useRNG('legacy')
|
||||
|
||||
stack(
|
||||
s("bd*2,~ [cp,sd]").bank('RolandTR909'),
|
||||
|
||||
@@ -727,6 +742,8 @@ export const amensister = `// "Amensister"
|
||||
|
||||
samples('github:tidalcycles/dirt-samples')
|
||||
|
||||
useRNG('legacy')
|
||||
|
||||
stack(
|
||||
// amen
|
||||
n("0 1 2 3 4 5 6 7")
|
||||
@@ -834,6 +851,8 @@ export const arpoon = `// "Arpoon"
|
||||
// @license CC BY-NC-SA 4.0 https://creativecommons.org/licenses/by-nc-sa/4.0/
|
||||
// @by Felix Roos
|
||||
|
||||
useRNG('legacy')
|
||||
|
||||
samples('github:tidalcycles/dirt-samples')
|
||||
|
||||
n("[0,3] 2 [1,3] 2".fast(3).lastOf(4, fast(2))).clip(2)
|
||||
|
||||
@@ -9,11 +9,13 @@ import { getDrawContext } from '@strudel/draw';
|
||||
import { evaluate, transpiler } from '@strudel/transpiler';
|
||||
import {
|
||||
getAudioContextCurrentTime,
|
||||
renderPatternAudio,
|
||||
webaudioOutput,
|
||||
resetGlobalEffects,
|
||||
resetLoadedSounds,
|
||||
initAudioOnFirstClick,
|
||||
resetDefaults,
|
||||
initAudio,
|
||||
} from '@strudel/webaudio';
|
||||
import { setVersionDefaultsFrom } from './util.mjs';
|
||||
import { StrudelMirror, defaultSettings } from '@strudel/codemirror';
|
||||
@@ -36,6 +38,7 @@ import { getRandomTune, initCode, loadModules, shareCode } from './util.mjs';
|
||||
import './Repl.css';
|
||||
import { setInterval, clearInterval } from 'worker-timers';
|
||||
import { getMetadata } from '../metadata_parser';
|
||||
import { debugAudiograph } from './audiograph';
|
||||
|
||||
const { latestCode, maxPolyphony, audioDeviceName, multiChannelOrbits } = settingsMap.get();
|
||||
let modulesLoading, presets, drawContext, clearCanvas, audioReady;
|
||||
@@ -129,6 +132,7 @@ export function useReplContext() {
|
||||
bgFill: false,
|
||||
});
|
||||
window.strudelMirror = editor;
|
||||
window.debugAudiograph = debugAudiograph;
|
||||
|
||||
// init settings
|
||||
initCode().then(async (decoded) => {
|
||||
@@ -207,6 +211,30 @@ export function useReplContext() {
|
||||
const handleEvaluate = () => {
|
||||
editorRef.current.evaluate();
|
||||
};
|
||||
|
||||
const handleExport = async (begin, end, sampleRate, maxPolyphony, multiChannelOrbits, downloadName = undefined) => {
|
||||
await editorRef.current.evaluate(false);
|
||||
editorRef.current.repl.scheduler.stop();
|
||||
await renderPatternAudio(
|
||||
editorRef.current.repl.state.pattern,
|
||||
editorRef.current.repl.scheduler.cps,
|
||||
begin,
|
||||
end,
|
||||
sampleRate,
|
||||
maxPolyphony,
|
||||
multiChannelOrbits,
|
||||
downloadName,
|
||||
).finally(async () => {
|
||||
const { latestCode, maxPolyphony, audioDeviceName, multiChannelOrbits } = settingsMap.get();
|
||||
await initAudio({
|
||||
latestCode,
|
||||
maxPolyphony,
|
||||
audioDeviceName,
|
||||
multiChannelOrbits,
|
||||
});
|
||||
editorRef.current.repl.scheduler.stop();
|
||||
});
|
||||
};
|
||||
const handleShuffle = async () => {
|
||||
const patternData = await getRandomTune();
|
||||
const code = patternData.code;
|
||||
@@ -235,6 +263,7 @@ export function useReplContext() {
|
||||
handleShuffle,
|
||||
handleShare,
|
||||
handleEvaluate,
|
||||
handleExport,
|
||||
init,
|
||||
error,
|
||||
editorRef,
|
||||
|
||||
Reference in New Issue
Block a user