Compare commits

..

143 Commits

Author SHA1 Message Date
Felix Roos 25dbfe4c7d fix: remove faulty default readme 2026-01-11 11:43:04 +01:00
froos 0a586eddb3 Merge pull request 'fix: add trem to top level' (#1887) from trem-toplevel into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1887
2026-01-11 11:37:36 +01:00
froos b6d81ba2a0 Merge pull request 'fix: export start cycle min 0' (#1888) from fix-export-min-cycle into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1888
2026-01-11 11:37:17 +01:00
froos 96626fce25 Merge pull request 'fix: repl package init audio properly' (#1836) from fix-repl-audioworklet into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1836
2026-01-11 11:05:43 +01:00
Felix Roos c74707e804 fix: export start cycle min 0 2026-01-11 10:58:13 +01:00
Felix Roos 0174e08639 fix: add trem to top level 2026-01-11 10:50:44 +01:00
Switch Angel AKA Jade Rose af855a0c77 Merge pull request 'Fix: show reload dialog when uploading prebake script' (#1886) from jade/reloaddialog into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1886
2026-01-11 06:51:23 +01:00
Jade (Rose) Rowland 604a1b770c dialog 2026-01-11 00:27:07 -05:00
Switch Angel AKA Jade Rose 2aa978ca5b Merge pull request 'fixes Serial onTrigger() params #1633' (#1885) from gueejla/strudel:fix-serial-ontrigger into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1885
2026-01-11 06:01:41 +01:00
gueejla 0571a8dc7d fixes Serial onTrigger() params #1633 2026-01-10 22:11:13 -06:00
Switch Angel AKA Jade Rose a8a919f581 Merge pull request 'Perf: Targeted node pools' (#1810) from glossing/basic-node-pool into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1810
2026-01-10 23:12:47 +01:00
Switch Angel AKA Jade Rose bf21c4cf02 Merge branch 'main' into glossing/basic-node-pool 2026-01-10 22:58:28 +01:00
Jade (Rose) Rowland 741ff3f3f0 fix import 2026-01-10 16:55:53 -05:00
Jade (Rose) Rowland 4476921069 use releasenode 2026-01-10 16:46:11 -05:00
Aria 8c5e4a5780 Merge pull request 'Allow top level distortions for the purpose of FX' (#1884) from glossing/naked-distorts into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1884
2026-01-10 20:52:39 +01:00
Aria 34ae1bb305 Merge branch 'main' into glossing/naked-distorts 2026-01-10 20:38:34 +01:00
Aria 0cf11adc2c Allow naked distortions for the purpose of FX 2026-01-10 13:07:54 -06:00
Jade (Rose) Rowland 7a5020aa71 format 2026-01-10 01:42:43 -05:00
Jade (Rose) Rowland a205f7d873 rm dead code 2026-01-10 01:42:06 -05:00
Jade (Rose) Rowland 0e9c50bf0a fix import 2026-01-10 01:39:39 -05:00
Jade (Rose) Rowland e837c516c5 merge main 2026-01-10 01:37:14 -05:00
Jade (Rose) Rowland 3b2e9fa0b4 wip 2026-01-10 01:22:50 -05:00
Jade (Rose) Rowland 6a47139e0d wip 2026-01-10 01:07:09 -05:00
Aria f7f8c56c0a Merge pull request 'Bake in scaling by freq for FM with a gain node' (#1878) from glossing/fm-modulation into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1878
2026-01-09 03:43:35 +01:00
Aria c7e88e1ff2 Merge branch 'main' into glossing/fm-modulation 2026-01-09 03:31:26 +01:00
Aria f6a43f1b10 Make the scaling by freq in FM a separate node so that modulators behave like the fm control 2026-01-08 20:28:53 -06:00
Aria a3b183d304 Merge pull request 'Bug fix: Properly handle subcontrols' (#1877) from glossing/subcontrols into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1877
2026-01-09 02:53:37 +01:00
Aria f7175c0505 Properly handle subcontrols 2026-01-08 19:40:50 -06:00
Aria e26040d154 Merge pull request 'Feat: Add ability to turn mini parsing off with mini-off decorator' (#1786) from glossing/disable-mini into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1786
2026-01-07 20:06:17 +01:00
Aria 17a1b75e5b Merge branch 'main' into glossing/disable-mini 2026-01-07 19:22:35 +01:00
Aria 7df9688bb9 Merge pull request 'Bug Fix: Update loopStart/End to not be offset' (#1826) from glossing/sample-looping-bugfix into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1826
2026-01-07 19:22:22 +01:00
Aria d557bb59f0 Merge branch 'main' into glossing/disable-mini 2026-01-06 21:40:05 +01:00
Aria 679e81eb0f Merge branch 'main' into glossing/sample-looping-bugfix 2026-01-06 21:39:52 +01:00
yaxu 37dd834398 Update README.md 2026-01-06 16:33:06 +01:00
yaxu 959e8ac7bc Update README.md 2026-01-06 16:31:51 +01:00
Aria 16524ca805 Merge pull request 'Update modulator docstrings and allow ids to be patterns' (#1874) from glossing/pattern-ids-for-modulators into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1874
2026-01-06 06:35:47 +01:00
Aria b4b0194455 Update docstrings and allow modulator ids to be patterns 2026-01-05 23:17:40 -06:00
Alex McLean fd472a64d9 Merge pull request 'Fix doc link in @strudel/osc README.md' (#1872) from forrcaho/strudel:main into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1872
2026-01-04 17:14:10 +01:00
Forrest Cahoon 58094973b0 Fix doc link in @strudel/osc README.md 2026-01-04 09:39:17 -06:00
Aria db49b37780 Merge pull request 'Make stretch modulatable' (#1870) from glossing/stretch-node into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1870
2026-01-04 02:01:05 +01:00
Aria bf243c04b9 Make stretch modulatable 2026-01-03 18:27:11 -06:00
Aria bc05cffdbc Merge pull request 'Make pan modulatable' (#1865) from glossing/pan-fx into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1865
2026-01-02 18:44:52 +01:00
Aria 7b1e02e8cf Make pan modulatable 2026-01-02 11:43:27 -06:00
Alex McLean 257f09da4f Merge pull request 'Fix transpilation example to have same mini-notation' (#1850) from JesCoding/strudel:jescoding-patch-1 into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1850
2026-01-01 21:42:19 +01:00
Alex McLean ecd58fa501 Merge pull request 'fix: missing punctuation' (#1860) from eddyflux/strudel:fix-missing-punctuation into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1860
2026-01-01 21:39:03 +01:00
Aria c4ece4932e Merge pull request 'Fix formatting of docstring' (#1864) from glossing/fx-docstring into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1864
2026-01-01 20:20:33 +01:00
Aria 5c9855bf12 Merge branch 'main' into glossing/fx-docstring 2026-01-01 20:08:54 +01:00
Aria e54b9c3a32 Fix formatting of docstring 2026-01-01 13:07:38 -06:00
Aria 6afe0dc714 Merge pull request 'Feat: FX Chains' (#1861) from glossing/multifx-new into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1861
2026-01-01 19:52:14 +01:00
Aria ebaf7624f9 Fix lfos for vowels, phasers; fix ir index; more informative error message 2026-01-01 12:02:54 -06:00
Aria 882bcc513f Typos and some examples 2025-12-31 15:17:06 -06:00
Aria aec33710b7 Working version 2025-12-31 14:58:31 -06:00
eddyflux d41ebf190b fix: missing punctuation 2025-12-30 00:22:51 +01:00
Aria 7953fa61e4 Merge pull request 'Bugfix: Fix modulator clamping when min/max not specified' (#1859) from glossing/clamp-bugfix into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1859
2025-12-29 21:59:10 +01:00
Aria 6051b922c0 Fix clamping when min/max not specified 2025-12-29 14:45:49 -06:00
Aria 367e07da0c Merge pull request 'Feature: LFOs and Envelopes' (#1507) from glossing/strudel:glossing/lfo-anything into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1507
2025-12-29 20:54:10 +01:00
Aria 0edede106a Fixes for zzfx and soundfonts 2025-12-29 13:28:26 -06:00
Aria b6264a1513 Merge branch 'main' into glossing/lfo-anything 2025-12-29 20:00:29 +01:00
Aria 680b7d78ec Lots of examples; use id instead of index; waveshaper clamping; one mode for synth 2025-12-29 12:59:48 -06:00
Aria e79b4b8ccb Refactor modulators 2025-12-29 10:41:20 -06:00
Aria 98367868ac Handle min/max when target is LFO; fix vib and tremolo 2025-12-29 10:22:48 -06:00
froos 616bc64e70 Merge pull request 'Fix AudioContext change detection. Use AudioNode.context' (#1858) from jeromew/strudel:fix-scope into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1858
2025-12-29 16:07:16 +01:00
jeromew c0f4f47150 Fix AudioContext change detection. Use AudioNode.context 2025-12-29 14:04:40 +00:00
Aria b4496fcc24 More control mapping tweaks 2025-12-28 21:43:08 -05:00
Aria 48f6a41683 Extend to FM, vibrato, etc 2025-12-28 20:13:15 -05:00
froos 7bce739068 Merge pull request 'Say that @license should use SPDX identifier' (#1817) from Wuzzy/strudel:clarify_at_license into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1817
2025-12-28 22:58:37 +01:00
froos 06f9c2dfb5 Merge pull request 'Expose Vim object in order to create custom keybindings' (#1816) from JohnBjrk/strudel:expose-vim-for-custom-keybindings into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1816
2025-12-28 22:56:05 +01:00
Felix Roos 30fe5b47e3 hotfix: fix scope and friends #1847 2025-12-28 22:42:42 +01:00
Alex McLean 1823993e7c Merge branch 'main' into jescoding-patch-1 2025-12-28 17:53:50 +01:00
froos 1935195e9a Merge pull request 'dough repl fixes' (#1855) from vanillarepl into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1855
2025-12-28 14:20:02 +01:00
Felix Roos 300a1bbfd5 fix: actually load code from url 2025-12-28 14:07:27 +01:00
froos 2f43dcab0c Merge pull request 'add basic dough repl' (#1749) from vanillarepl into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1749
2025-12-28 13:40:28 +01:00
Felix Roos 125eece223 fix: import 2025-12-28 13:20:22 +01:00
Felix Roos 53e49952d8 persist code in hash 2025-12-28 13:10:57 +01:00
Felix Roos e286ab2830 no default samples for now + add doughsamples to scope 2025-12-28 13:10:37 +01:00
Felix Roos e4e2d60b28 Merge branch 'main' into vanillarepl 2025-12-28 12:58:45 +01:00
Aria 99bec835f8 Merge branch 'main' into glossing/lfo-anything 2025-12-27 20:54:39 -05:00
JesCoding d349d2a0cd Fix transpilation example to have same mini-notation
This was introduced in https://codeberg.org/uzu/strudel/commit/66f8ca72c1b364f72f25b8cff041cac13b380432
2025-12-24 10:38:21 +01:00
Wuzzy c7a2c2bb9c Say that @license should use SPDX identifier 2025-12-21 10:47:01 +01:00
froos 5a51b4ec71 Merge pull request 'Document "-" in mini-notation' (#1818) from Wuzzy/strudel:dashtilde into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1818
2025-12-20 22:27:50 +01:00
froos 3922defab9 Merge pull request 'simplify envValAtTime and remove asymmetric behavior (fix #1653)' (#1815) from pulu/strudel:remove-env-asymmetry into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1815
2025-12-20 22:20:26 +01:00
John Björk a9752fb2a4 Exposing Vim from codemirror-vim
Docs for adding keybindings

Only expose Vim from keybindings
2025-12-20 17:20:38 +01:00
froos 0a2f2eaa3d Merge pull request 'fix: visual block selection mode for vim bindings' (#1839) from Dsm0/strudel:visual-block-mode-fix into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1839
2025-12-19 08:11:49 +01:00
Dsm0 0de212c12f fix visual block mode 2025-12-18 22:55:44 -08:00
Aria 18b3c66eb8 Clean up error messages 2025-12-13 17:15:06 -06:00
Aria 63de46ae96 Update docstrings 2025-12-13 16:44:36 -06:00
Aria c598059dc7 Clean up old code 2025-12-13 16:26:50 -06:00
Aria dec037bed5 Bus fix 2025-12-13 16:23:47 -06:00
Aria 29f7f5c1f8 Allow lfos to modulate other lfos without clamping 2025-12-13 16:07:19 -06:00
Aria bae5571df0 Cleanup 2025-12-13 15:32:27 -06:00
Aria 4b08dd8447 Unify lfo functions 2025-12-13 15:20:51 -06:00
Aria 11e3da0552 Parity with other LFO (sync and dcoffset) 2025-12-13 15:08:48 -06:00
Aria ef2ee0969a Restore old lfo to avoid unifying right now 2025-12-13 14:59:14 -06:00
Aria 12f9af227c Merge branch 'main' into glossing/lfo-upgrades 2025-12-13 14:54:17 -06:00
Aria dc5f6827f9 Change names, cleanup 2025-12-13 14:50:27 -06:00
Aria 8baecf78b9 Add note 2025-12-13 11:37:37 -06:00
Aria 415b6d5f1d Fix name collision and move modulators into controls to avoid circular import test failure 2025-12-11 15:48:45 -06:00
Aria 4169b764c3 Simpler control handling 2025-12-11 15:28:48 -06:00
Aria e7d1613bfb Broken tests, but otherwise working 2025-12-11 14:42:16 -06:00
Aria 45aa1f5384 Control cleanup 2025-12-11 12:52:40 -06:00
Aria 3a17aeabf3 Some cleanup 2025-12-11 11:41:40 -06:00
Aria 2aeba2e53f Working version of bus 2025-12-11 11:37:20 -06:00
Aria a79491dd16 Update loopbegin/end to not be offset 2025-12-08 16:32:54 -06:00
Wuzzy 53c101005c Document "-" in mini-notation 2025-12-07 00:12:40 +01:00
Aria 82ca77be9d Working version 2025-12-06 12:02:11 -06:00
miranda 173e359a65 simplify envValAtTime and remove asymmetric behavior (fix #1653) 2025-12-06 17:46:16 +02:00
Aria ad43259353 Switch to omod 2025-12-05 13:39:52 -06:00
Aria 32924b3fcf Merge branch 'main' into glossing/lfo-anything 2025-12-05 10:47:17 -06:00
Aria 4c99f4866b Use setvalueattime to pin values and prevent bleed 2025-12-04 22:50:28 -06:00
Aria d50af71ba1 Merge branch 'main' into glossing/basic-node-pool 2025-12-04 14:20:41 -06:00
Aria 0664f90178 Remove old comment 2025-12-04 13:07:13 -06:00
Aria 4321814d36 Working version for compressor, filter, supersaw, wavetable 2025-12-04 13:04:33 -06:00
Aria 6610713018 Working version of supersaw, almost on wavetable 2025-12-04 12:40:28 -06:00
Aria 600ab0a83e WIP with compressors, filters, heavy worklets 2025-12-04 12:17:07 -06:00
Aria 8529516c66 Add ability to turn mini parsing off with midi-off decorator 2025-11-25 13:05:27 -06:00
Aria 0e3840aff4 Merge branch 'main' into glossing/lfo-anything 2025-11-20 14:40:39 -06:00
Aria a929b36770 Remove room 2025-11-20 14:06:45 -06:00
Aria 2794541dad Clean up send nodes and remove offset 2025-11-19 20:52:51 -06:00
Aria 3e118ce0a8 Fully working version 2025-11-19 20:11:02 -06:00
Aria c3ad0d1789 Fix for curve params 2025-11-19 20:01:17 -06:00
Aria 19b710c401 Mostly working version 2025-11-19 19:44:02 -06:00
Aria 0dbca05de0 Ready for testing 2025-11-19 17:24:33 -06:00
Aria f8796d039f Add param guesses 2025-11-19 17:18:38 -06:00
Aria 442f487973 Another major cleanup 90% of first pass 2025-11-19 17:10:05 -06:00
Aria 7ccdefe1c6 Huge overhaul for config part 1 2025-11-19 16:33:08 -06:00
Aria b73f624058 Trying out alias approach 2025-11-19 15:41:29 -06:00
Aria e4466082eb Merge branch 'main' into glossing/upgrading-lfospr 2025-11-19 15:10:02 -06:00
Aria 29b6729246 First pass at improving controls 2025-11-19 15:03:11 -06:00
Aria b3df7f438f Merge branch 'main' into glossing/lfo-anything 2025-11-19 14:43:47 -06:00
Felix Roos 3b1c75d3de fix: linting errors 2025-11-17 23:18:31 +01:00
Felix Roos 48407c309c add basic dough repl 2025-11-17 23:10:10 +01:00
Aria 23aa8ef509 Add ability for LFOs to target.. other lfos :devil emoji: 2025-09-13 14:49:36 -05:00
Aria 945b325336 Codeformat, example tests 2025-09-13 14:33:32 -05:00
Aria 502f0b5c23 Merge branch 'main' into glossing/lfo-anything 2025-09-13 14:32:41 -05:00
Aria 2ccbb0596d Codeformat 2025-09-04 20:27:32 -05:00
Aria 862b88b6c4 Some typos and cleanup 2025-09-04 20:27:05 -05:00
Aria cd7865a66f Merge branch 'main' into glossing/lfo-anything 2025-08-27 16:28:26 -05:00
Aria 9c8e39d83d Add.. a lot of doc strings 2025-08-27 16:27:36 -05:00
Aria 6a9739bed5 Code format and a typo on lfoNum 2025-08-20 13:39:24 -05:00
Aria d8f0d70908 Big refactor; start envelope at current value 2025-08-20 13:22:37 -05:00
Aria e25eab11ad Working version of envelopes 2025-08-20 10:47:57 -05:00
Aria efb337677c Fully working version with multiple LFOs 2025-08-20 10:08:36 -05:00
Aria 4a70ac5a99 Working version of LFOs 2025-08-20 00:38:25 -05:00
43 changed files with 2111 additions and 676 deletions
+4 -1
View File
@@ -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:
+3 -3
View File
@@ -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',
@@ -411,7 +411,7 @@ export class StrudelMirror {
}
}
function parseBooleans(value) {
export function parseBooleans(value) {
return { true: true, false: false }[value] ?? value;
}
+1
View File
@@ -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';
+8 -2
View File
@@ -1,5 +1,5 @@
import { defaultKeymap } from '@codemirror/commands';
import { Prec } from '@codemirror/state';
import { Prec, EditorState } from '@codemirror/state';
import { keymap, ViewPlugin } from '@codemirror/view';
// import { searchKeymap } from '@codemirror/search';
import { emacs } from '@replit/codemirror-emacs';
@@ -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;
}
+276 -5
View File
@@ -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);
@@ -876,7 +877,7 @@ export const { coarse } = registerControl('coarse');
* note("d d d# d".fast(4)).s("supersaw").tremolo("<3 2 100> ").tremoloskew("<.5>")
*
*/
export const { tremolo } = registerControl(['tremolo', 'tremolodepth', 'tremoloskew', 'tremolophase'], 'trem');
export const { tremolo, trem } = registerControl(['tremolo', 'tremolodepth', 'tremoloskew', 'tremolophase'], 'trem');
/**
* Modulate the amplitude of a sound with a continuous waveform
@@ -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');
+61 -8
View File
@@ -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));
};
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -68,7 +68,7 @@ Pattern.prototype.serial = function (br = 115200, sendcrc = false, singlecharids
if (!(name in writeMessagers)) {
getWriter(name, br);
}
const onTrigger = (t_deprecate, hap, currentTime, cps, targetTime) => {
const onTrigger = (hap, currentTime, _cps, targetTime) => {
var message = '';
var chk = 0;
if (typeof hap.value === 'object') {
+3 -3
View File
@@ -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 },
);
+9
View File
@@ -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 = () => {
+9 -10
View File
@@ -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) {
+58 -25
View File
@@ -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)),
};
}
+1
View File
@@ -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';
+178
View File
@@ -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 };
};
+73
View File
@@ -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;
};
+4 -2
View File
@@ -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();
+13 -12
View File
@@ -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) {
+442 -242
View File
@@ -7,22 +7,25 @@ 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, setAudioContext } from './audioContext.mjs';
import { getAudioContext } from './audioContext.mjs';
import { SuperdoughAudioController } from './superdoughoutput.mjs';
import { resetSeenKeys } from './wavetable.mjs';
@@ -184,6 +187,7 @@ let defaultDefaultValues = {
distortvol: 1,
distorttype: 0,
delay: 0,
busgain: 1,
byteBeatExpression: '0',
delayfeedback: 0.5,
delaysync: 3 / 16,
@@ -191,6 +195,9 @@ let defaultDefaultValues = {
i: 1,
velocity: 1,
fft: 8,
tremolodepth: 1,
tremolophase: 0,
release: 0.01,
};
const defaultDefaultDefaultValues = Object.freeze({ ...defaultDefaultValues });
@@ -327,16 +334,16 @@ export function connectToDestination(input, channels) {
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;
@@ -359,7 +366,7 @@ export let analysers = {},
analysersData = {};
export function getAnalyserById(id, fftSize = 1024, smoothingTimeConstant = 0.5) {
if (!analysers[id] || analysers[id].audioContext != getAudioContext()) {
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;
@@ -400,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();
@@ -429,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,
@@ -481,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);
@@ -505,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
@@ -529,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;
}
@@ -543,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?`);
@@ -568,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) {
@@ -782,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 && !(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) => {
+150
View File
@@ -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;
}
+30 -4
View File
@@ -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];
}
}
+97 -40
View File
@@ -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);
},
+11 -6
View File
@@ -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,11 +70,13 @@ 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;
}
}
+42 -26
View File
@@ -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({
@@ -230,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;
@@ -314,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();
+68 -55
View File
@@ -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;
+1
View File
@@ -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);
});
});
+38
View File
@@ -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;
}
+136
View File
@@ -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 ]",
+1
View File
@@ -20,6 +20,7 @@ const skippedExamples = [
'accelerationX',
'defaultmidimap',
'midimaps',
'bmod',
];
describe('runs examples', () => {
+1 -183
View File
@@ -3,186 +3,4 @@
This is the website for Strudel, deployed at [strudel.cc](https://strudel.cc).
It includes the REPL live coding editor and the documentation site.
## Run locally
```bash
# from project root
npm run setup
npm run repl
```
## Build
```bash
cd website
npm run build # <- builds repl + tutorial to ../docs
npm run preview # <- test static build
```
## Generate PWA icons
```sh
cd website/public
npx pwa-asset-generator icon.png icons
```
# Standard Readme of Astro Starter Kit: Docs Site
```bash
npm create astro@latest -- --template docs
```
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/docs)
[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/s/github/withastro/astro/tree/latest/examples/docs)
![docs](https://user-images.githubusercontent.com/4677417/186189283-0831b9ab-d6b9-485d-8955-3057e532ab31.png)
## Features
-**Full Markdown support**
-**Responsive mobile-friendly design**
-**Sidebar navigation**
-**Search (powered by Algolia)**
-**Multi-language i18n**
-**Automatic table of contents**
-**Automatic list of contributors**
- ✅ (and, best of all) **dark mode**
## Commands Cheatsheet
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :--------------------- | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro --help` | Get help using the Astro CLI |
To deploy your site to production, check out our [Deploy an Astro Website](https://docs.astro.build/guides/deploy) guide.
## New to Astro?
Welcome! Check out [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
## Customize This Theme
edit: removed, as css styles have been replaced with tailwind styles
### Site metadata
`src/config.ts` contains several data objects that describe metadata about your site like title, description, default language, and Open Graph details. You can customize these to match your project.
## Page metadata
Astro uses frontmatter in Markdown pages to choose layouts and pass properties to those layouts. If you are using the default layout, you can customize the page in many different ways to optimize SEO and other things. For example, you can use the `title` and `description` properties to set the document title, meta title, meta description, and Open Graph description.
```markdown
---
title: Example title
description: Really cool docs example that uses Astro
layout: ../../layouts/MainLayout.astro
---
# Page content...
```
For more SEO related properties, look at `src/components/HeadSEO.astro`
### Sidebar navigation
The sidebar navigation is controlled by the `SIDEBAR` variable in your `src/config.ts` file. You can customize the sidebar by modifying this object. A default, starter navigation has already been created for you.
```ts
export const SIDEBAR = {
en: [
{ text: "Section Header", header: true },
{ text: "Introduction", link: "en/introduction" },
{ text: "Page 2", link: "en/page-2" },
{ text: "Page 3", link: "en/page-3" },
{ text: "Another Section", header: true },
{ text: "Page 4", link: "en/page-4" },
],
};
```
Note the top-level `en` key: This is needed for multi-language support. You can change it to whatever language you'd like, or add new languages as you go. More details on this below.
### Multiple Languages support
The Astro docs template supports multiple languages out of the box. The default theme only shows `en` documentation, but you can enable multi-language support features by adding a second language to your project.
To add a new language to your project, you'll want to extend the current `src/pages/[lang]/...` layout:
```diff
📂 src/pages
┣ 📂 en
┃ ┣ 📜 page-1.md
┃ ┣ 📜 page-2.md
┃ ┣ 📜 page-3.astro
+ ┣ 📂 es
+ ┃ ┣ 📜 page-1.md
+ ┃ ┣ 📜 page-2.md
+ ┃ ┣ 📜 page-3.astro
```
You'll also need to add the new language name to the `KNOWN_LANGUAGES` map in your `src/config.ts` file. This will enable your new language switcher in the site header.
```diff
// src/config.ts
export const KNOWN_LANGUAGES = {
English: 'en',
+ Spanish: 'es',
};
```
Last step: you'll need to add a new entry to your sidebar, to create the table of contents for that language. While duplicating every page might not sound ideal to everyone, this extra control allows you to create entirely custom content for every language.
> Make sure the sidebar `link` value points to the correct language!
```diff
// src/config.ts
export const SIDEBAR = {
en: [
{ text: 'Section Header', header: true, },
{ text: 'Introduction', link: 'en/introduction' },
// ...
],
+ es: [
+ { text: 'Encabezado de sección', header: true, },
+ { text: 'Introducción', link: 'es/introduction' },
+ // ...
+ ],
};
// ...
```
If you plan to use Spanish as the default language, you just need to modify the redirect path in `src/pages/index.astro`:
```diff
<script>
- window.location.pathname = `/en/introduction`;
+ window.location.pathname = `/es/introduction`;
</script>
```
You can also remove the above script and write a landing page in Spanish instead.
### What if I don't plan to support multiple languages?
That's totally fine! Not all projects need (or can support) multiple languages. You can continue to use this theme without ever adding a second language.
If that single language is not English, you can just replace `en` in directory layouts and configurations with the preferred language.
### Search (Powered by Algolia)
[Algolia](https://www.algolia.com/) offers a free service to qualified open source projects called [DocSearch](https://docsearch.algolia.com/). If you are accepted to the DocSearch program, provide your API Key & index name in `src/config.ts` and a search box will automatically appear in your site header.
Note that Aglolia and Astro are not affiliated. We have no say over acceptance to the DocSearch program.
If you'd prefer to remove Algolia's search and replace it with your own, check out the `src/components/Header.astro` component to see where the component is added.
more setup info, see [project setup](../CONTRIBUTING.md#project-setup)
+17
View File
@@ -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);
}
}
+109
View File
@@ -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;
}
}
+15
View File
@@ -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>
+18 -16
View File
@@ -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.
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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).
@@ -66,7 +66,7 @@ export default function ExportTab(Props) {
<div className="flex flex-row gap-4 w-full">
<FormItem label="Start cycle" disabled={exporting}>
<Textbox
min={1}
min={0}
max={Infinity}
onBlur={(e) => {
let v = parseInt(e.target.value);
@@ -1,6 +1,7 @@
import { errorLogger } from '@strudel/core';
import { useSettings, storePrebakeScript } from '../../../settings.mjs';
import { SpecialActionInput } from '../button/action-button';
import { confirmDialog, SETTING_CHANGE_RELOAD_MSG } from '@src/repl/util.mjs';
async function importScript(script) {
const reader = new FileReader();
@@ -23,7 +24,19 @@ export function ImportPrebakeScriptButton() {
type="file"
label="import prebake script"
accept=".strudel"
onChange={(e) => importScript(e.target.files[0])}
onChange={async (e) => {
const file = e.target.files[0];
const confirmed = await confirmDialog(SETTING_CHANGE_RELOAD_MSG);
if (!confirmed) {
return;
}
try {
await importScript(file);
window.location.reload();
} catch (e) {
errorLogger(e);
}
}}
/>
);
}
@@ -1,13 +1,13 @@
import { defaultSettings, settingsMap, useSettings } from '../../../settings.mjs';
import { themes } from '@strudel/codemirror';
import { Textbox } from '../textbox/Textbox.jsx';
import { isUdels } from '../../util.mjs';
import { confirmAndReloadPage, isUdels } from '../../util.mjs';
import { ButtonGroup } from './Forms.jsx';
import { AudioDeviceSelector } from './AudioDeviceSelector.jsx';
import { AudioEngineTargetSelector } from './AudioEngineTargetSelector.jsx';
import { confirmDialog } from '../../util.mjs';
import { DEFAULT_MAX_POLYPHONY, setMaxPolyphony, setMultiChannelOrbits } from '@strudel/webaudio';
import { ActionButton, SpecialActionButton } from '../button/action-button.jsx';
import { SpecialActionButton } from '../button/action-button.jsx';
import { ImportPrebakeScriptButton } from './ImportPrebakeScriptButton.jsx';
function Checkbox({ label, value, onChange, disabled = false }) {
@@ -86,8 +86,6 @@ const fontFamilyOptions = {
galactico: 'galactico',
};
const RELOAD_MSG = 'Changing this setting requires the window to reload itself. OK?';
export function SettingsTab({ started }) {
const {
theme,
@@ -127,11 +125,8 @@ export function SettingsTab({ started }) {
isDisabled={started}
audioDeviceName={audioDeviceName}
onChange={(audioDeviceName) => {
confirmDialog(RELOAD_MSG).then((r) => {
if (r == true) {
settingsMap.setKey('audioDeviceName', audioDeviceName);
return window.location.reload();
}
confirmAndReloadPage(() => {
settingsMap.setKey('audioDeviceName', audioDeviceName);
});
}}
/>
@@ -141,11 +136,8 @@ export function SettingsTab({ started }) {
<AudioEngineTargetSelector
target={audioEngineTarget}
onChange={(target) => {
confirmDialog(RELOAD_MSG).then((r) => {
if (r == true) {
settingsMap.setKey('audioEngineTarget', target);
return window.location.reload();
}
confirmAndReloadPage(() => {
settingsMap.setKey('audioEngineTarget', target);
});
}}
/>
@@ -175,12 +167,9 @@ export function SettingsTab({ started }) {
label="Multi Channel Orbits"
onChange={(cbEvent) => {
const val = cbEvent.target.checked;
confirmDialog(RELOAD_MSG).then((r) => {
if (r == true) {
settingsMap.setKey('multiChannelOrbits', val);
setMultiChannelOrbits(val);
return window.location.reload();
}
confirmAndReloadPage(() => {
settingsMap.setKey('multiChannelOrbits', val);
setMultiChannelOrbits(val);
});
}}
value={multiChannelOrbits}
@@ -297,11 +286,8 @@ export function SettingsTab({ started }) {
label="Sync across Browser Tabs / Windows"
onChange={(cbEvent) => {
const newVal = cbEvent.target.checked;
confirmDialog(RELOAD_MSG).then((r) => {
if (r) {
settingsMap.setKey('isSyncEnabled', newVal);
window.location.reload();
}
confirmAndReloadPage(() => {
settingsMap.setKey('isSyncEnabled', newVal);
});
}}
disabled={shouldAlwaysSync}
+14 -2
View File
@@ -1,4 +1,4 @@
import { code2hash, evalScope, hash2code, logger } from '@strudel/core';
import { code2hash, errorLogger, evalScope, hash2code, logger } from '@strudel/core';
import { settingPatterns } from '../settings.mjs';
import { setVersionDefaults } from '@strudel/webaudio';
import { getMetadata } from '../metadata_parser';
@@ -107,7 +107,19 @@ export function confirmDialog(msg) {
resolve(confirmed);
});
}
export const SETTING_CHANGE_RELOAD_MSG = 'Changing this setting requires the window to reload itself. OK?';
export function confirmAndReloadPage(onSuccess) {
confirmDialog(SETTING_CHANGE_RELOAD_MSG).then((r) => {
if (r == true) {
try {
onSuccess();
return window.location.reload();
} catch (e) {
errorLogger(e);
}
}
});
}
//RIP due to SPAM
// let lastShared;
// export async function shareCode(codeToShare) {