Compare commits

...

443 Commits

Author SHA1 Message Date
Jade (Rose) Rowland d198910a86 Publish
- @strudel/codemirror@1.2.7
 - @strudel/core@1.2.6
 - @strudel/csound@1.2.7
 - @strudel/draw@1.2.6
 - @strudel/embed@1.1.2
 - @strudel/gamepad@1.2.6
 - @strudel/hydra@1.2.6
 - @strudel/midi@1.2.7
 - @strudel/mini@1.2.6
 - mondolang@1.1.2
 - @strudel/mondo@1.1.6
 - @strudel/motion@1.2.6
 - @strudel/mqtt@1.2.6
 - @strudel/osc@1.3.1
 - @strudel/reference@1.2.2
 - @strudel/repl@1.2.8
 - @strudel/sampler@0.2.4
 - @strudel/serial@1.2.6
 - @strudel/soundfonts@1.2.7
 - superdough@1.2.7
 - supradough@1.2.5
 - @strudel/tonal@1.2.6
 - @strudel/transpiler@1.2.6
 - vite-plugin-bundle-audioworklet@0.1.2
 - @strudel/web@1.2.7
 - @strudel/webaudio@1.2.7
 - @strudel/xen@1.2.6
2026-01-17 15:26:28 -08:00
Aria 8ce6a881e6 Merge pull request 'Make kabelsalat stereo out' (#1927) from glossing/ks-stereo-out into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1927
2026-01-17 21:00:42 +01:00
Aria 7bc8576a68 Make kabelsalat stereo out 2026-01-17 11:16:58 -08:00
froos bbe95b3995 Merge pull request 'update changelog + fix script to not miss entries' (#1916) from changelog-update into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1916
2026-01-16 10:38:03 +01:00
Felix Roos 2dc921e375 fix: add fun fact 2026-01-16 10:14:17 +01:00
Felix Roos 3d13955c41 fix: prefix usernames with @ 2026-01-16 10:08:41 +01:00
Felix Roos ae9638c353 update changelog + fix script to not miss entries 2026-01-16 09:58:14 +01:00
froos 97cd3a5946 Merge pull request 'mondo fix: add registered functions to scope automatically' (#1896) from register_to_scope into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1896
2026-01-15 16:59:13 +01:00
froos ca3452bf52 Merge pull request 'Added docs for pattern search' (#1905) from JohnBjrk/strudel:pattern-search-docs into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1905
2026-01-15 16:58:13 +01:00
froos 7f528870b7 Merge pull request 'Update kabelsalat dependency' (#1911) from jeromew/strudel:kabelsalat into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1911
2026-01-15 16:57:18 +01:00
jeromew 60e5682829 Update kabelsalat dependency 2026-01-15 15:42:34 +00:00
Aria d605fbbde8 Merge pull request 'Bug Fix: Set pooled values immediately instead of scheduling' (#1907) from glossing/set-pooled-vals-immediately into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1907
2026-01-15 00:24:38 +01:00
Aria 21e6d81d34 Also set values immediately for supersaw and wavetable 2026-01-14 17:00:14 -06:00
Aria 85e79d9932 Set pooled values immediately instead of scheduling 2026-01-14 16:53:11 -06:00
John Björk 71dfbeea88 Fix markdown format 2026-01-14 22:35:10 +01:00
John Björk ffcab7bf6c Added docs for pattern search 2026-01-14 22:25:27 +01:00
Aria c3f6a1d6ba Merge pull request 'Feat: Kabelsalat integration' (#1876) from glossing/kabelsalat into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1876
2026-01-14 20:29:15 +01:00
Aria eb96d3ad63 Merge branch 'main' into glossing/kabelsalat 2026-01-14 19:08:20 +01:00
Aria 06e10cd693 Merge pull request 'Feat: MIDI Keyboard 🎹🐈' (#1828) from glossing/keyboard into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1828
2026-01-14 19:07:48 +01:00
Aria 80875cca95 Expose scheduler state and don't enqueue/trigger midi keys when repl stopped 2026-01-14 11:44:48 -06:00
Aria b954a25b2b Merge branch 'main' into glossing/keyboard 2026-01-14 18:15:31 +01:00
Aria cca346d2c2 Merge pull request 'Bug Fix: Fix race condition between worklet termination and port messages' (#1897) from glossing/diedack into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1897
2026-01-14 18:14:51 +01:00
Aria 3343c7bcf0 Merge branch 'main' into glossing/keyboard 2026-01-13 23:00:59 +01:00
Aria 6e3c5b23dd Merge branch 'main' into glossing/diedack 2026-01-13 21:30:58 +01:00
froos c12a77a0a1 Merge pull request 'Add search/filter in patterns tab' (#1842) from JohnBjrk/strudel:patterns-search into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1842
2026-01-13 04:06:50 +01:00
Felix Roos e5ab6b3c93 fix: short circuit 2026-01-13 03:47:33 +01:00
Felix Roos e82bb6c410 fix: use pattern.id as title fallback 2026-01-13 03:38:09 +01:00
Felix Roos 8b1fb12388 fix: class -> className 2026-01-13 03:32:19 +01:00
Aria 1f1f3288a6 Switch to tracking grace period in node pool; add negative begin and ends 2026-01-12 17:59:33 -06:00
Switch Angel AKA Jade Rose d642c0e12f Merge pull request 'Add shortcut for navigating through labels!' (#1807) from jade/dollar_shortcut into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1807
2026-01-13 00:18:08 +01:00
Aria 1eb657e6fe Merge branch 'main' into glossing/diedack 2026-01-12 20:58:34 +01:00
Aria 7c5f53f9ae Add ack message for worklet death; remove max pool size 2026-01-12 13:51:21 -06:00
Switch Angel AKA Jade Rose efe7c9394f Merge branch 'main' into jade/dollar_shortcut 2026-01-12 20:30:14 +01:00
Jade (Rose) Rowland 35bfd8b3ff curried 2026-01-12 11:28:54 -08:00
Jade (Rose) Rowland c2720a5738 working 2026-01-12 11:26:18 -08:00
Aria 5355a0c563 Ignore non-note messages 2026-01-11 23:34:52 -06:00
Alex McLean 75db9ff16e Merge pull request 'add warm.strudel.cc to faq' (#1891) from faq-warm into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1891
2026-01-12 00:17:36 +01:00
Aria 12cc1e9b5e Add immediate triggering; move time to schedulerState 2026-01-11 14:51:47 -06:00
Aria cd48e67675 Merge branch 'main' into glossing/kabelsalat 2026-01-11 19:59:21 +01:00
Aria 3a284696c9 Merge branch 'main' into glossing/keyboard 2026-01-11 19:59:13 +01:00
Felix Roos 753dc48b69 update changelog 2026-01-11 19:12:45 +01:00
froos 5ee5d4f1b1 Merge pull request 'Fix sounds example to work in the REPL' (#1851) from JesCoding/strudel:jescoding-patch-2 into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1851
2026-01-11 19:00:24 +01:00
alex 31cacc3c29 add warm to faq 2026-01-11 16:23:17 +00:00
JesCoding 8d2bca59c4 Fix sounds example to work in the REPL
`freq` only takes one argument, so switch this to mini-notation
so this can be used in the REPL as indicated in the paragraph below.
Make it so this can be played by the user.

Fix syntax-type typo.
2026-01-11 14:50:30 +00:00
froos f867cbce92 Merge pull request 'Improve hint text when sound search has no results' (#1883) from floy/strudel:improve-help-text-filtered into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1883
2026-01-11 13:52:01 +01:00
floy a659c89385 fix code formatting 2026-01-11 13:25:44 +01:00
Alex McLean 8290e99cf9 Merge pull request 'New page FAQ in "More"' (#1753) from scrappy_fiddler/strudel:documentation into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1753
2026-01-11 13:19:45 +01:00
froos e648ddd9ff Merge pull request 'fix: remove faulty default readme' (#1889) from trim-website-readme into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1889
2026-01-11 12:45:06 +01:00
yaxu 85fd567fb2 Format and remove link to open document 2026-01-11 12:21:05 +01:00
scrappy_fiddler 20ea2980a2 Merge branch 'main' into documentation 2026-01-11 12:18:42 +01:00
froos af2d4784c6 Merge pull request 'fix/self-hosted-config' (#1880) from alienmind/strudel:fix/self-hosted-config into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1880
2026-01-11 12:15:32 +01:00
froos 526083d7c9 Merge pull request 'Feat: Support External AudioContext Injection' (#1833) from 1d10t/strudel:main into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1833
2026-01-11 12:07:47 +01:00
Alex McLean 8e792e1bfd Merge branch 'main' into documentation 2026-01-11 11:57:19 +01:00
yaxu f3e6f86857 Update website/src/pages/learn/faq.mdx
Trimmed all the DAW talk a little!
2026-01-11 11:55:40 +01:00
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
yaxu 8f3dc8daea Update website/src/pages/learn/faq.mdx 2026-01-11 11:34:55 +01:00
scrappy_fiddler ced434a1cf Merge branch 'main' into documentation 2026-01-11 11:20:51 +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 af88471f6b Merge branch 'main' into jade/dollar_shortcut 2026-01-11 06:51:47 +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
Switch Angel AKA Jade Rose f9a9993c42 Merge branch 'main' into jade/dollar_shortcut 2026-01-11 06:32:29 +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
Aria dab1676761 Add license for time file 2026-01-10 11:26:30 -06:00
Aria d753eedb90 Make latency consistent across all cps 2026-01-10 11:20:17 -06:00
floy fd0209dbc0 Show explicit help text when search doesn't match 2026-01-10 15:39:02 +01:00
floy b4e00538f8 Don't show 'No sounds loaded' text if search is active 2026-01-10 15:30:07 +01: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 4743334a17 Reduce latency; change name to midikeys; include in i/o learn page 2026-01-09 22:50:16 -06:00
Aria 7cff2050c8 Merge branch 'main' into glossing/kabelsalat 2026-01-09 23:38:25 +01:00
alienmind eefae851d5 fix malformed script src attributes 2026-01-09 16:26:32 +01:00
alienmind 40f9f4cde0 allow alternative URLs for self-hosted strudel 2026-01-09 16:26:32 +01: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 e6e96c8603 Fix release behavior; add multi-line example 2026-01-08 16:39:10 -06:00
Aria d4b3a3b972 Auto call functions in kabel; clean up end behavior of worklet; move kabel first in chain; write docstring 2026-01-08 12:31:26 -06:00
1d10t a1d1bd0e11 Merge branch 'main' into main 2026-01-08 09:44:32 +01:00
Aria fd2ff255e5 Don't include worklet in autocomplete; use empty object in pure for consistency 2026-01-07 22:59:54 -06:00
Aria 5903070c02 Test cleanup 2026-01-07 13:45:19 -06:00
Aria e92865cecb Handle mini notation without S 2026-01-07 13:32:12 -06:00
Aria a5c4fd5bc1 Merge branch 'main' into glossing/kabelsalat 2026-01-07 13:14:49 -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 5caaf423ff Import kabelsalat, update transpiler to handle K and S, add worklet control 2026-01-07 12:19:16 -06: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
1d10t 8f342262ab Merge branch 'main' into main 2026-01-05 00:37:28 +01: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
1d10t e82602af5a Merge branch 'main' into main 2026-01-04 13:24:39 +01: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
Sergey S Yaglov bfef9ba8d2 Merge remote-tracking branch 'upstream/main' 2026-01-04 00:14:23 +03:00
1d10t aa7ba6ae99 update webaudio to main branch 2026-01-03 22:01:37 +01:00
1d10t a86e5751eb reset audioContext to main branch 2026-01-03 21:55:16 +01: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
scrappy_fiddler 8d79715ce4 Merge branch 'main' into documentation 2026-01-02 00:30:21 +01: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
John Björk aa8209ad4c Refactored handling of trim/lowercase based on PR comments 2026-01-01 12:22:52 +01: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
scrappy_fiddler b648961d30 Merge branch 'main' into documentation 2025-12-29 16:28:57 +01: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
scrappy_fiddler fc2f5accfe Merge branch 'main' into documentation 2025-12-22 14:05:24 +01:00
Wuzzy c7a2c2bb9c Say that @license should use SPDX identifier 2025-12-21 10:47:01 +01:00
John Björk 6908ea38e4 Added search/filter in patterns tab
Added pattern as dep to filter memo

Format
2025-12-20 23:10: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
Felix Roos 181ebee374 fix: repl package init audio properly 2025-12-19 01:25:58 +01:00
froos b376b2616a Merge pull request '[perf] Add audiograph await debugAudiograph() feature' (#1763) from jeromew/strudel:audiograph into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1763
2025-12-19 01:03:52 +01:00
froos 56ecb006fa Merge pull request 'Feature: non-realtime exporting' (#1674) from BANanaD3V/strudel:feat/non-realtime-exporting into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1674
2025-12-19 00:45:39 +01:00
scrappy_fiddler e6c1e97838 Merge branch 'main' into documentation 2025-12-17 20:44:07 +01:00
Aria 2748189f18 Merge pull request 'Fix: wrong warning in build environments' (#1835) from jeromew/strudel:onenced-warning into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1835
Reviewed-by: Aria <glossing@noreply.codeberg.org>
2025-12-14 19:33:36 +01:00
Alex McLean 63d254daf4 Merge pull request 'delta -> tight/tightx/loose refinements' (#1832) from delta into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1832
2025-12-14 15:09:10 +01:00
jeromew 34991e9602 Fix: wrong warning in build environments 2025-12-14 11:27:41 +00:00
Aria 8df5c80d82 Merge pull request 'Improved randomness' (#1505) from glossing/strudel:glossing/random into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1505
2025-12-14 01:07:35 +01:00
Aria e0344dbd67 Simple seeding for this first iteration 2025-12-13 17:49:19 -06:00
Aria 931d684b19 Merge branch 'main' into glossing/random 2025-12-13 17:28:44 -06:00
Aria 4a7344595d Make legacy the default 2025-12-13 17:28:42 -06: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
alex 8eeebf1e4f try renaming to per / perx / cyclesPer 2025-12-13 16:30:51 +00:00
Sergey S Yaglov adf9596d07 codestyle(webaudio) 2025-12-13 00:28:13 +03:00
1d10t 4e14718e93 feat(webaudio): enable AudioContext sharing for webaudioRepl
- Add support for optional `existingAudioCtx` parameter in `webaudioRepl()` function
- When provided, sets it as the default AudioContext via `setDefaultAudioContext()`
- Allows integration with external systems that manage their own Web Audio API context
- Maintains backward compatibility with existing usage patterns
2025-12-12 21:56:31 +01:00
1d10t 2998de8426 Merge pull request 'feat(superdough/audiocontext): make AudioContext injection optional' (#1) from 1d10t-patch-1 into main
Reviewed-on: https://codeberg.org/1d10t/strudel/pulls/1
2025-12-12 21:28:40 +01:00
1d10t c8b2e1ac9e feat(superdough/audiocontext): make AudioContext injection optional
- Modify `setDefaultAudioContext()` to accept an optional `existingAudioCtx` parameter.
- If provided, it will use the existing context; otherwise, it creates a new one.
- This allows for better integration with external audio systems that manage their own AudioContext.
2025-12-12 21:22:21 +01:00
alex c7789a09e3 Merge branch 'delta' of ssh://codeberg.org/uzu/strudel into delta 2025-12-12 16:50:48 +00:00
alex a53385aa11 delint 2025-12-12 16:50:24 +00:00
Alex McLean 0a5fb8ca1d Merge branch 'main' into delta 2025-12-12 17:49:14 +01:00
alex 99e4999e6a snapshot 2025-12-12 15:46:19 +00:00
alex cd071cb7c0 separate out signal tests, add tests for tight, tightx, loose 2025-12-12 15:46:04 +00:00
alex 8e1fba0436 rename delta to loose, add inverse tight, and exponential tightx 2025-12-12 15:45:36 +00:00
Felix Roos f4df4f679f Merge branch 'main' of https://codeberg.org/uzu/strudel 2025-12-12 15:12:45 +01:00
Alex McLean 1d2a3bdb9c Merge pull request 'Add delta signal for representing the duration of events in patterns that are combined with it' (#1831) from delta into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1831
2025-12-12 10:28:25 +01:00
alex c6e0f0533e add delta 2025-12-11 23:32:10 +00: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
Alex McLean 3608f1ca25 Merge pull request 'Feature: stateful timeline function for jumping between timelines' (#1669) from alternative-timeline into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1669
2025-12-11 18:00:48 +01:00
alex 37990b459f bugfix and tweak doc 2025-12-11 16:51:25 +00:00
alex a34e58aa09 Merge branch 'alternative-timeline' of ssh://codeberg.org/uzu/strudel into alternative-timeline 2025-12-11 16:39:22 +00:00
alex 8cbe4b945c Respond to glossing's review 2025-12-11 16:39:08 +00:00
Alex McLean 6f9a1470e7 Merge branch 'main' into alternative-timeline 2025-12-11 17:19:03 +01:00
Alex McLean 8a3719752f Merge pull request 'Updates relating to LLM, github, etc' (#1830) from llm-guidance into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1830
2025-12-11 16:37:57 +01:00
alex 96835be056 tweaks 2025-12-11 15:07:57 +00:00
alex 960f1de9d2 tweaks 2025-12-11 12:16:54 +00:00
alex 12884cba3a Updates relating to LLM, github, etc 2025-12-11 12:09:26 +00:00
Aria 103b27c21f Working version of keyboard with variable lengths; tests and cleanup 2025-12-10 13:44:49 -06:00
Aria 597c941991 Add keyboard function 2025-12-10 12:01:04 -06:00
Alex McLean 20c513d649 Merge pull request 'fix .as so it doesn't set undefined values' (#1827) from fix-as into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1827
2025-12-10 15:32:43 +01:00
alex e89414e400 fix .as so it doesn't set undefined values 2025-12-10 09:19:12 +00:00
Aria a79491dd16 Update loopbegin/end to not be offset 2025-12-08 16:32:54 -06:00
Aria 765de9daf3 Merge pull request '[perf] propagate onceEnded and releaseAudioNode' (#1809) from jeromew/strudel:fix-perf12 into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1809
Reviewed-by: Aria <glossing@noreply.codeberg.org>
2025-12-08 19:27:30 +01:00
jeromew 132dea8478 Merge branch 'main' into fix-perf12 2025-12-08 18:54:51 +01:00
jeromew b889d19493 Merge branch 'fix-perf12' of https://codeberg.org/jeromew/strudel into fix-perf12 2025-12-08 17:32:47 +00:00
jeromew 71d36b79ed Improve getOscillator/noiseMix release 2025-12-08 17:31:34 +00:00
Aria d7bf5db8be Merge pull request 'Feat: Add channel support to midi in' (#1775) from glossing/midin-channels into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1775
2025-12-07 21:51:39 +01:00
Aria 48231ea12a Merge branch 'main' into glossing/midin-channels 2025-12-07 21:23:57 +01:00
Aria c8a442234e Merge pull request 'Feat: Transient shaper' (#1777) from glossing/transient-processor into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1777
2025-12-07 21:23:42 +01:00
Aria efda32eedb Merge branch 'main' into glossing/transient-processor 2025-12-07 20:29:50 +01:00
Aria 95d04d1149 Merge branch 'main' into glossing/midin-channels 2025-12-07 20:29:39 +01:00
Aria 358b1e3f56 Make sure processor turns off; fix array sizes for JIT 2025-12-07 13:28:57 -06:00
Aria fe7e8f7add Clearer docstring 2025-12-07 13:21:06 -06:00
Aria 45d329d3d1 Merge pull request 'Add vel as a synonym for velocity & update a few docstrings' (#1781) from glossing/controls-various into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1781
2025-12-07 19:15:42 +01:00
Aria 5e99393f5a Merge branch 'main' into glossing/controls-various 2025-12-07 18:54:18 +01:00
Aria 9650c8eb50 Merge pull request '[perf] fix phaser leak of unused biquads' (#1800) from jeromew/strudel:fix-perf10 into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1800
Reviewed-by: Aria <glossing@noreply.codeberg.org>
2025-12-07 18:53:03 +01:00
Aria fab8d2193d Merge branch 'main' into glossing/midin-channels 2025-12-07 18:45:16 +01:00
Aria 150835aa4d Merge branch 'main' into glossing/transient-processor 2025-12-07 18:45:11 +01:00
Aria 76237016a3 Merge branch 'main' into glossing/controls-various 2025-12-07 18:45:02 +01:00
Aria 6f4d8ebacf Merge branch 'main' into fix-perf10 2025-12-07 18:44:47 +01:00
Aria 4374bdf1bd Merge branch 'main' into fix-perf12 2025-12-07 18:44:38 +01:00
Aria 3cecf4a50e Merge pull request 'Bug fix: Remove failing tests due to shabda removal' (#1820) from glossing/shabda-test-fix into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1820
2025-12-07 18:42:49 +01:00
Aria 16b5faf333 Removing failing tests due to shabda removal 2025-12-07 11:23:55 -06:00
Aria c4826b7dfb Merge branch 'main' into glossing/midin-channels 2025-12-07 18:11:59 +01:00
Aria bd61c1dc7e Merge branch 'main' into glossing/transient-processor 2025-12-07 18:11:50 +01:00
Aria ceb3aa797d Merge branch 'main' into glossing/controls-various 2025-12-07 18:11:39 +01:00
Aria 9b3277da1c Merge branch 'main' into fix-perf10 2025-12-07 18:09:00 +01: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
scrappy_fiddler 6402ad5d37 better linebreak in example 2025-12-06 16:55:57 +01:00
scrappy_fiddler ab03ece2b7 prettier 2025-12-06 16:48:40 +01:00
miranda 173e359a65 simplify envValAtTime and remove asymmetric behavior (fix #1653) 2025-12-06 17:46:16 +02:00
scrappy_fiddler bd282f439a everyone wants that trancegate 2025-12-06 16:37:39 +01:00
scrappy_fiddler 177c3a4a2e some items from review 2025-12-06 16:36:25 +01: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
scrappy_fiddler d4d9b4f543 Merge branch 'main' into documentation 2025-12-05 10:22:52 +01: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
jeromew 1225d04437 codeformat 2025-12-04 15:29:21 +00:00
jeromew f6f507dd29 propagate releaseAudioNode in packages 2025-12-04 15:26:59 +00:00
jeromew c7586e96ff Add comment on end worklet param 2025-12-04 13:20:17 +00:00
jeromew 46291bf85c Difuse the onceEnded mechanism 2025-12-04 13:17:20 +00:00
jeromew d7e240e2dc Add PeriodicWave & VowelNode support 2025-12-04 12:56:54 +00:00
jeromew 0b711e879c Improve naming & backward compat 2025-12-04 08:23:36 +00:00
Jade (Rose) Rowland f0aac2098a update_shortcut 2025-12-03 22:59:20 -05:00
Jade (Rose) Rowland c1a185e852 fix null case 2025-12-03 15:25:05 -05:00
Jade (Rose) Rowland b9fce15042 working 2025-12-03 15:18:32 -05:00
Aria e01621d0cc Merge pull request 'Feature: Eight FMs' (#1628) from glossing/strudel:glossing/fmsss into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1628
2025-12-03 18:27:27 +01:00
Aria 196f70ab6b Formatting 2025-12-03 11:13:21 -06:00
Aria 1d4f2b81d8 Switch back to explicit exports and use new release mechanism 2025-12-03 11:12:30 -06:00
Aria d28c8dac8e Merge branch 'main' into glossing/fmsss 2025-12-03 10:42:35 -06:00
Aria eb18648a2a Merge pull request '[perf] release unused AudioBufferSourceNode + releaseAudioNode' (#1805) from jeromew/strudel:fix-perf11 into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1805
Reviewed-by: Aria <glossing@noreply.codeberg.org>
2025-12-03 17:35:03 +01:00
jeromew 25c20296a1 Tone down log message when a pure orphan node is found 2025-12-02 13:58:09 +00:00
jeromew 2db30a710b Fix codeformat 2025-12-02 13:54:27 +00:00
jeromew 71d1fe99e4 Fix special case for FeedbackDelayNode 2025-12-02 13:51:16 +00:00
jeromew 0ec9826de5 onceEnded mechanism to clean audioNode.onended callbacks 2025-12-02 09:29:50 +00:00
jeromew af7855402f Accept non-instanciated nodes for better developer experience 2025-12-01 20:43:55 +00:00
scrappy_fiddler 39bff8900f website/src/pages/learn/faq.mdx aktualisiert
updated the section on switch angel's prebake. Now refers to the readme for instructions.
2025-12-01 18:22:26 +01:00
jeromew 68b1cb87ca [perf] release unused AudioBufferSourceNode + releaseAudioNode 2025-12-01 15:29:19 +00:00
jeromew 05ed16e158 Add stop-leak detection. Stoppable nodes that are not stopped 2025-11-30 17:50:56 +00:00
jeromew 24b69ad1bc [perf] Add audiograph await debugAudiograph(); tool 2025-11-30 14:09:03 +00:00
Nikita 5bddb6f48d Move export UI to the panel 2025-11-30 10:13:16 +03:00
Nikita c5250d7794 Add info comment about ascending time order 2025-11-30 10:12:55 +03:00
Nikita 7d890156f4 Fix haps not being sorted by time 2025-11-30 10:12:55 +03:00
Nikita 8f9967c76d Format 2025-11-30 10:12:55 +03:00
Nikita 91ebdd5ea6 Better progress bar UI 2025-11-30 10:12:55 +03:00
Nikita fc74dcdb1a More bug fixes 2025-11-30 10:12:51 +03:00
jeromew b65cc2128f [perf] fix phaser leak of unused biquads 2025-11-29 14:47:03 +00:00
froos 7a3e0fd3fe Merge pull request 'added export to getSuperdoughAudioController() so that its possible to route superdough audio through other webaudio applications.' (#1796) from ndr0n/strudel:exportSuperdoughAudioController into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1796
2025-11-29 01:00:41 +01:00
Alex McLean 4061dd4cf8 Merge pull request 'add revv() for reversing whole patterns' (#1791) from revv into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1791
2025-11-28 23:26:19 +01:00
alex b523205590 Merge branch 'revv' of ssh://codeberg.org/uzu/strudel into revv 2025-11-28 22:03:32 +00:00
alex 956598da18 snapshot 2025-11-28 22:03:21 +00:00
Alex McLean e011f3b982 Merge branch 'main' into revv 2025-11-28 22:37:03 +01:00
alex cb591815ed fix copy paste errors 2025-11-28 21:35:35 +00:00
Alex McLean 6a4fd27f5b Merge branch 'main' into exportSuperdoughAudioController 2025-11-28 22:32:10 +01:00
Aria d30df8d4cf Merge pull request '[perf] Disconnect lfos for phaser and filters' (#1787) from glossing/perf-lfos into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1787
2025-11-28 20:19:14 +01:00
Aria e10cd22c26 Merge branch 'main' into glossing/perf-lfos 2025-11-28 19:59:51 +01:00
Aria 18dc4944bf Typos and cleanup 2025-11-28 12:36:23 -06:00
Felix Roos d84db1e663 hotfix: auto deploy to warm.strudel.cc when main changes 2025-11-28 10:02:57 +01:00
ndr0n a6008e2700 added export to getSuperdoughAudioController() so that its possible to route superdough audio on external application integrations. 2025-11-28 04:03:48 +00:00
froos 2f8cabbb6b Merge pull request 'fix: return silence when no pattern is returned' (#1795) from fix-empty-pattern into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1795
2025-11-27 23:08:50 +01:00
Felix Roos ec26392509 fix: return silence when no pattern is returned 2025-11-27 22:53:38 +01:00
froos 30cd486f37 Merge pull request 'fix(tool/dbpatch): add missing package name' (#1765) from peterpf/strudel:bugfix/add-package-name-for-dbpatch into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1765
2025-11-27 22:47:01 +01:00
froos cde0520ed9 Merge pull request 'add CHANGELOG.md + basic script to generate' (#1794) from changelog into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1794
2025-11-27 22:38:04 +01:00
Felix Roos 405338e4f0 add CHANGELOG.md + basic script to generate 2025-11-27 22:25:32 +01:00
froos afbcaa54d0 Merge pull request '[perf] in noise, let noiseMix do the disconnect when it exists' (#1783) from jeromew/strudel:fix-perf9 into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1783
2025-11-27 22:03:51 +01:00
froos 6a1c6081e0 Merge pull request 'Bug Fix: Retries for sounds tab' (#1754) from glossing/sounds-tab-preview into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1754
2025-11-27 22:01:00 +01:00
Felix Roos 3c4ba667c8 cleanup 2025-11-27 21:29:37 +01:00
Felix Roos 7fcb5262b8 Merge remote-tracking branch 'origin/main' into glossing/sounds-tab-preview 2025-11-27 21:14:22 +01:00
Felix Roos b824c45ba4 Merge remote-tracking branch 'origin/main' into glossing/sounds-tab-preview 2025-11-27 21:11:50 +01:00
froos e47e6c87fc Merge pull request '[hydra] return the hydra object when await initHydra(..) is called' (#1784) from jeromew/strudel:return-hydra into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1784
2025-11-27 20:36:44 +01:00
froos 69b5ba89cb Merge pull request 'Use errorLogger for query and tonal errors' (#1782) from glossing/query-error into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1782
2025-11-27 20:36:12 +01:00
froos 33a51761ce Merge pull request '[perf] level 5 connect-leak on vowel' (#1779) from jeromew/strudel:fix-perf7 into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1779
2025-11-27 20:27:09 +01:00
Alex McLean f4b1baac87 add revv() for reversing whole patterns 2025-11-27 15:34:18 +00:00
Peter Egger d217119391 fix(tool/dbpatch): add missing package name 2025-11-26 19:48:48 +01:00
Switch Angel AKA Jade Rose d5d6284a90 Merge pull request 'feat: add prebake script import button for loading .strudel files' (#1774) from pre_eval into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1774
2025-11-26 08:23:37 +01:00
Aria 692359309f Typo and formatting 2025-11-25 17:31:11 -06:00
Aria d65b908bda Disconnect lfos for phaser and filters 2025-11-25 17:27:37 -06:00
Aria 8529516c66 Add ability to turn mini parsing off with midi-off decorator 2025-11-25 13:05:27 -06:00
jeromew ab64451d65 [hydra] return the hydra object when await initHydra(..) is called 2025-11-25 10:49:30 +00:00
jeromew 89da9bbdb0 [perf] in noise, let noiseMix do the disconnect when it exists 2025-11-25 10:31:53 +00:00
Aria 2bc208d8f5 Use errorLogger for query and tonal errors 2025-11-24 19:05:42 -06:00
Aria 39e7f70079 Remove synonym 2025-11-24 18:44:29 -06:00
Aria 7e5a907ce5 Add a few more functions to autocomplete 2025-11-24 18:41:25 -06:00
Aria d7afb8a6c0 Add register example 2025-11-24 18:01:20 -06:00
Aria a278f21f1b Allow register in autocomplete, add vel as synonym for velocity, fix some controls 2025-11-24 17:52:00 -06:00
jeromew 89a6efb69e [perf] level 5 connect-leak on vowel 2025-11-24 16:10:00 +00:00
Aria 36587ae747 Switch back to original version 2025-11-23 22:55:36 -06:00
Aria 6c01c16a0c Fix typo 2025-11-23 22:39:21 -06:00
Aria be36898803 Formatting and examples 2025-11-23 22:29:33 -06:00
Aria df047fd48d Optimizations 2025-11-23 22:28:10 -06:00
Aria 68840de188 Working version 2025-11-23 20:58:28 -06:00
Aria 020a6bc75f First pass at transient processor 2025-11-23 20:31:31 -06:00
Aria 7bc6fcbf2a Add channel support to midi in 2025-11-23 18:24:48 -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 74488f8480 Stray log message 2025-11-19 18:41:56 -06:00
Aria 8144ec46bd Attempt system for sounds tab previews 2025-11-19 18:35:35 -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
scrappy_fiddler 4ec68fcd1d a little prettier 2025-11-20 00:07:44 +01: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
scrappy_fiddler 7abfcd0f38 Merge branch 'main' into documentation 2025-11-19 22:18:32 +01:00
scrappy_fiddler 4504d1afd0 remove trailing spaces in FAQ 2025-11-19 22:14:23 +01: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
scrappy_fiddler 69c5df67f5 FAQ is now prettier 2025-11-19 21:40:48 +01:00
scrappy_fiddler a21f0d629e Merge branch 'main' into documentation 2025-11-19 20:24:34 +01:00
scrappy_fiddler 371e6c7d6d tidy up FAQ 2025-11-19 20:17:19 +01:00
scrappy_fiddler 0bb89d1a9f New page FAQ in "More" 2025-11-19 20:09:14 +01:00
Aria 654c1a86f0 Simplify declarations via globalThis; fix cleanup/stop 2025-11-18 16:02:33 -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 ac3f477f30 Merge branch 'main' into glossing/fmsss 2025-11-16 22:32:38 -06:00
Aria 573cd1ae41 Merge branch 'main' into glossing/fmsss 2025-11-16 22:23:31 -06:00
Aria 17af92b2df Merge branch 'main' into glossing/random 2025-11-16 15:32:15 -06:00
Aria e90aa5202d Export withseed 2025-11-16 15:28:10 -06:00
Aria fb7f686519 Add seeding 2025-11-16 15:22:34 -06:00
Aria aca500ba73 Merge branch 'main' into glossing/random 2025-11-16 14:42:20 -06:00
BANanaD3V 03d2f363f1 Merge branch 'main' into feat/non-realtime-exporting 2025-10-24 13:09:09 +02:00
Nikita ff370cf86f Reapply realtimeOptions after export 2025-10-24 14:07:57 +03:00
Nikita 9cbeaef88d Format 2025-10-23 22:21:40 +03:00
Nikita ca936aada7 Fix incorrect duration calculation 2025-10-23 21:46:13 +03:00
Nikita fe70a2df43 Format 2025-10-23 17:08:11 +03:00
Nikita 6baca67cd3 Fix maxPolyphony and multiChannelOrbits not applying to export 2025-10-23 17:07:53 +03:00
BANanaD3V 85f6454340 Merge branch 'main' into feat/non-realtime-exporting 2025-10-23 16:05:09 +02:00
Nikita 16f885c010 Use errorLogger instead 2025-10-22 23:09:30 +03:00
Nikita 5a70599aac Ignore superdough errors when exporting, instead skip sound. 2025-10-22 22:48:04 +03:00
Nikita 7b74a902a2 Remove unneeded imports 2025-10-22 20:05:03 +03:00
Nikita 95700e1196 Fix wavetables breaking after playback 2025-10-22 20:00:36 +03:00
Nikita e7578be6c5 Remove preloading code and instead await superdough 2025-10-22 17:16:05 +03:00
Nikita 28df4259b8 Merge remote-tracking branch 'upstream/main' into feat/non-realtime-exporting 2025-10-22 16:21:34 +03:00
Nikita 5e27358fe8 Better preloading 2025-10-22 16:19:58 +03:00
Nikita daea207aff Better sample loading 2025-10-21 22:42:01 +03:00
Nikita 92d3fe496d Bug fixes 2025-10-21 21:00:30 +03:00
Nikita 98031676d6 Add an EOL character to package.json 2025-10-21 16:44:42 +03:00
Nikita 498624f6fe Make server.js a normal file 2025-10-21 16:35:33 +03:00
Nikita bf702c3b2a Reviewed changes 2025-10-21 16:30:18 +03:00
froos 6e4dae4dfa Merge branch 'main' into feat/non-realtime-exporting 2025-10-21 14:24:09 +02:00
Nikita a8ea9a1b63 Update lockfile 2025-10-20 21:26:40 +03:00
Nikita a2b76bde14 Format 2025-10-20 21:24:54 +03:00
Nikita 0f652e8b5b Remove webaudio from core dependencies 2025-10-20 21:24:27 +03:00
Nikita 2a0635879c Format 2025-10-20 20:58:27 +03:00
Nikita f5fc0ff9d1 Remove resetGlobalEffects from AudioContext 2025-10-20 20:58:01 +03:00
Nikita 7774364ac7 Add webaudio as a workspace dependency 2025-10-20 20:36:36 +03:00
Nikita 329289fc35 Revert refreshing deps 2025-10-20 20:33:25 +03:00
Nikita 8b3c0d2a4a Refresh deps 2025-10-20 20:27:31 +03:00
Nikita 120b46c4a5 Add webaudio as a cyclist dependency (do we want that?) 2025-10-20 20:24:42 +03:00
Nikita 4bdaf577c0 Format 2025-10-20 20:22:16 +03:00
Nikita 428a4b3dd7 Revert workletsUrl to use ?audioworklet 2025-10-20 20:21:23 +03:00
Nikita ff83994733 Better export UI 2025-10-20 20:19:52 +03:00
Nikita 418bca79b2 Disable button when exporting & min start cycle is 0 2025-10-19 23:05:33 +03:00
Nikita b3715dc5df WIP export UI 2025-10-19 23:02:20 +03:00
Nikita 9a47e0f11e close() doesn't exist for OfflineAudioContext 2025-10-19 19:47:23 +03:00
Nikita 908c0e44ff Fix worklets not loading 2025-10-19 19:44:53 +03:00
Nikita 920776fdfe Re-init audio before and after export 2025-10-19 15:10:31 +03:00
Nikita 0fbde61b2d WIP non-realtime exporting 2025-10-19 14:38:50 +03:00
Aria 4c49cd9297 More examples 2025-10-18 11:24:56 -05:00
Aria 70d2da81d8 Merge branch 'main' into glossing/fmsss 2025-10-18 11:01:23 -05:00
alex 92a82ce4bd delint 2025-10-18 10:49:15 +01:00
alex b2ce4591d9 sort exports and add impure.mjs 2025-10-18 10:42:55 +01:00
alex 1be9f40574 timeline feature 2025-10-18 10:42:35 +01:00
Aria bd73d96847 Cleanup 2025-10-03 18:49:49 -05:00
Aria 7cf9db4872 Big matrix refactor 2025-10-03 18:20:41 -05:00
Aria c9381a11f9 Working version 2025-10-03 17:21:05 -05: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 0a4b6a01b3 Merge branch 'main' into glossing/random 2025-08-24 21:19:49 -05:00
Aria b3e433291c Turn back on silent 2025-08-24 21:15:47 -05:00
Aria 969c9663ef Update tests for new randomness 2025-08-24 21:15:11 -05:00
Aria 5e4e678800 Test bleed, some docstrings 2025-08-24 21:14:57 -05:00
Aria b8423f0ed1 Add docstring 2025-08-24 12:01:12 -05:00
Aria 5d5159a08f Add option to use old random and a benchmark 2025-08-24 11:56:11 -05:00
Aria 1e96d59ac8 Merge branch 'main' into glossing/random 2025-08-24 11:05:57 -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
Aria 554d6118c6 Merge branch 'main' into glossing/random 2025-08-19 19:10:18 -05:00
Aria 03271ec5fb Remove stray comments 2025-08-19 18:15:24 -05:00
Aria a0760f9468 Merge branch 'main' into glossing/random 2025-08-19 17:46:09 -05:00
Aria 058d950121 Working version of updated randomness 2025-08-15 22:57:59 -05:00
113 changed files with 7446 additions and 1214 deletions
+4 -1
View File
@@ -1,6 +1,9 @@
name: Build and Deploy to beta (warm.strudel.cc)
on: [workflow_dispatch]
on:
push:
branches:
- main
# Allow one concurrent deployment
concurrency:
+1081
View File
File diff suppressed because it is too large Load Diff
+22 -17
View File
@@ -4,20 +4,13 @@ Thanks for wanting to contribute!!! There are many ways you can add value to thi
## Move to codeberg
We are currently in the process of moving from github to codeberg -- not everything is working, please bear with us.
To update your local clone, you can run this command:
```
git remote set-url origin git@codeberg.org:uzu/strudel.git
```
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.
## Communication Channels
To get in touch with the contributors, either
To get in touch with the community, either
- [join the Tidal Discord Channel](https://discord.com/invite/HGEdXmRkzT) and go to the #strudel channel
- [join the Uzulang Discord Server](https://discord.com/invite/HGEdXmRkzT) and go to the strudel channels (Uzulangs are a family of live coding languages inspired by each other, including TidalCycles as well as Strudel)
- Find related discussions on the [tidal club forum](https://club.tidalcycles.org/)
## Ask a Question
@@ -36,22 +29,35 @@ Use one of the Communication Channels listed above and drop us a line or two!
## Share Music
If you made some music with strudel, you can give back some love and share what you've done!
Your creation could also be part of the random selection in the REPL if you want.
Use one of the Communication Channels listed above.
(There used to be a random selection of contributed patterns in the REPL, but that is unfortunately disabled for now due to abuse)
## Improve the Docs
If you find some weak spots in the [docs](https://strudel.cc/workshop/getting-started/),
you can edit each file directly on codeburg. (we are currently fixing the "Edit this page" links in the right sidebar)
If you find some weak spots in the [docs](https://strudel.cc/workshop/getting-started/), you can edit each file directly on codeberg. There are "Edit this page" links in the right sidebar that take you to the right place.
## Propose a Feature
If you want a specific feature that is not part of strudel yet, feel free to use one of the communication channels above.
Maybe you even want to help with the implementation of that feature!
If you want a specific feature that is not part of strudel yet, feel free to use one of the communication channels above. Please bear in mind that this is a free/open source project, oriented around collaborative discussion. Maybe you even want to help with the implementation of that feature!
## Contribute a feature
Pull requests welcome! Consider starting with discussion or proof-of-concept first, rather than do loads of work on something and then find it doesn't fit the collective goals of the project, or something like that.
At the time of writing we have a PR backlog, and generally prioritise bugfixes and contributions that have arisen through community discussion.
### AI/LLM policy
Strudel is a project handmade by humans, with thought and nuance.
If you have used LLMs (so called 'AI'), please detail that in the pull request. We are still developing our response to the onslaught of LLM technology, but for practical and legal reasons are currently not accepting wholly LLM-generated code. We are also not accepting PRs that add LLM features to strudel itself.
There are #llm-chat and #llm-share channels on our discord. Please do not discuss or share LLM-related things outside of those channels.
## Report a Bug
If you've found a bug, or some behaviour that does not seem right, you are welcome to file an [issue](https://codeberg.org/uzu/strudel/issues).
Please check that it has not been reported before.
## Fix a Bug
@@ -118,8 +124,7 @@ There are also eslint extensions / plugins for most editors.
## Running all CI Checks
When opening a PR, the CI runner will automatically check the code style and eslint, as well as run all tests.
You can run the same check with `pnpm check`
When opening a PR, the CI runner will (once approved by a human) check the code style and eslint, as well as run all tests. You can run the same check with `pnpm check`.
## Package Workflow
+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:
@@ -1,4 +1,5 @@
<script src="https://unpkg.com/@strudel/repl@1.0.2"></script>
<script src="https://unpkg.com/@strudel/repl@1.2.7"></script>
<!-- <script src="../../packages/repl/dist/index.js"></script> -->
<strudel-editor>
<!--
// @date 23-08-15
+14 -5
View File
@@ -24,10 +24,11 @@ import { sliderPlugin, updateSliderWidgets } from './slider.mjs';
import { activateTheme, initTheme, theme } from './themes.mjs';
import { isTooltipEnabled } from './tooltip.mjs';
import { updateWidgets, widgetPlugin } from './widget.mjs';
import { jumpToCharacter } from './labelJump.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 +49,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',
@@ -119,6 +120,14 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
preventDefault: true,
run: () => onStop?.(),
},
{
key: 'Alt-w',
run: (view) => jumpToCharacter(view, '$', 1),
},
{
key: 'Alt-q',
run: (view) => jumpToCharacter(view, '$', -1),
},
/* {
key: 'Ctrl-Shift-.',
run: () => (onPanic ? onPanic() : onStop?.()),
@@ -293,9 +302,9 @@ export class StrudelMirror {
console.warn('first frame could not be painted');
}
}
async evaluate() {
async evaluate(autostart = true) {
this.flash();
await this.repl.evaluate(this.code);
await this.repl.evaluate(this.code, autostart);
}
async stop() {
this.repl.scheduler.stop();
@@ -411,7 +420,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;
}
+31
View File
@@ -0,0 +1,31 @@
import { EditorSelection } from '@codemirror/state';
import { SearchCursor } from '@codemirror/search';
export function jumpToCharacter(view, character, direction = 1) {
const { state, dispatch } = view;
const pos = state.selection.main.head;
const cursor = new SearchCursor(state.doc, character);
let characterPositions = [];
let jumpPos;
while (!cursor.next().done) {
characterPositions.push(cursor.value.to);
}
if (!characterPositions.length) {
return false;
}
if (direction > 0) {
jumpPos = characterPositions.find((x) => x > pos + 1) ?? characterPositions.at(0); // Loop back around for convenience
} else {
jumpPos = characterPositions.reverse().find((x) => x < pos + 1) ?? characterPositions.at(0);
}
if (jumpPos == null) {
return false;
}
dispatch({
selection: EditorSelection.cursor(jumpPos - 1),
scrollIntoView: true,
});
return true;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel/codemirror",
"version": "1.2.6",
"version": "1.2.7",
"description": "Codemirror Extensions for Strudel",
"main": "index.mjs",
"publishConfig": {
+49
View File
@@ -0,0 +1,49 @@
import { describe, bench } from 'vitest';
import { calculateSteps, rand, useRNG } from '../index.mjs';
const testingResolution = 128;
const _generateRandomPattern = () => rand.iter(testingResolution).fast(testingResolution).firstCycle();
describe('old random', () => {
calculateSteps(true);
bench(
'+tactus',
() => {
useRNG('legacy');
_generateRandomPattern();
},
{
time: 1000,
teardown() {
useRNG('legacy');
},
},
);
calculateSteps(false);
bench(
'-tactus',
() => {
useRNG('precise');
_generateRandomPattern();
},
{
time: 1000,
teardown() {
useRNG('legacy');
},
},
);
});
describe('random', () => {
calculateSteps(true);
bench('+tactus', _generateRandomPattern, { time: 1000 });
calculateSteps(false);
bench('-tactus', _generateRandomPattern, { time: 1000 });
});
calculateSteps(true);
+477 -30
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);
@@ -72,6 +73,27 @@ export function registerControl(names, ...aliases) {
return bag;
}
export function registerMultiControl(names, maxControls, ...aliases) {
names = Array.isArray(names) ? names : [names];
let bag = {};
for (let i = 1; i <= maxControls; i++) {
let theseAliases = [...aliases];
let theseNames = [...names];
if (i === 1) {
// adds e.g. fm1 as an alias for fm
const aliases1 = theseAliases.map((a) => `${a}1`);
const names1 = theseNames.map((n) => `${n}1`);
theseAliases = theseAliases.concat(aliases1).concat(names1);
} else {
theseAliases = theseAliases.map((a) => `${a}${i}`);
theseNames = theseNames.map((n) => `${n}${i}`);
}
const subBag = registerControl(theseNames, ...theseAliases);
bag = { ...bag, ...subBag };
}
return bag;
}
/**
* Select a sound / sample by name. When using mininotation, you can also optionally supply 'n' and 'gain' parameters
* separated by ':'.
@@ -380,12 +402,13 @@ export const { accelerate } = registerControl('accelerate');
* Sets the velocity from 0 to 1. Is multiplied together with gain.
*
* @name velocity
* @synonyms vel
* @example
* s("hh*8")
* .gain(".4!2 1 .4!2 1 .4 1")
* .velocity(".4 1")
*/
export const { velocity } = registerControl('velocity');
export const { velocity, vel } = registerControl('velocity', 'vel');
/**
* Controls the gain by an exponential amount.
*
@@ -435,6 +458,9 @@ export const { attack, att } = registerControl('attack', 'att');
* Whole numbers and simple ratios sound more natural,
* while decimal numbers and complex ratios sound metallic.
*
* A number may be added afterwards to control the harmonicity of
* any of the 8 individual FMs (e.g. `fmh2`)
*
* @name fmh
* @param {number | Pattern} harmonicity
* @example
@@ -444,25 +470,40 @@ export const { attack, att } = registerControl('attack', 'att');
* ._scope()
*
*/
export const { fmh } = registerControl(['fmh', 'fmi'], 'fmh');
export const { fmh, fmh1, fmh2, fmh3, fmh4, fmh5, fmh6, fmh7, fmh8 } = registerMultiControl(['fmh', 'fmi'], 8, 'fmh');
/**
* Sets the Frequency Modulation of the synth.
* Controls the modulation index, which defines the brightness of the sound.
*
* @name fm
* A number may be added afterwards to control the modulation index of
* any of the 8 individual FMs (e.g. `fm3`). Also, FMs may be routed into
* each other with matrix commands like `fm13`, which would send `fm1` back into
* `fm3`
*
* @name fmi
* @param {number | Pattern} brightness modulation index
* @synonyms fmi
* @synonyms fm
* @example
* note("c e g b g e")
* .fm("<0 1 2 8 32>")
* ._scope()
* @example
* s("sine").note("F1").seg(8)
* .fm(4).fm2(rand.mul(4)).fm3(saw.mul(8).slow(8))
* .fmh(1.06).fmh2(10).fmh3(0.1)
*
*/
export const { fmi, fm } = registerControl(['fmi', 'fmh'], 'fm');
export const { fmi, fmi1, fmi2, fmi3, fmi4, fmi5, fmi6, fmi7, fmi8, fm, fm1, fm2, fm3, fm4, fm5, fm6, fm7, fm8 } =
registerMultiControl(['fmi', 'fmh'], 8, 'fm');
// fm envelope
/**
* Ramp type of fm envelope. Exp might be a bit broken..
*
* A number may be added afterwards to control the envelope of
* any of the 8 individual FMs (e.g. `fmenv4`)
*
* @name fmenv
* @param {number | Pattern} type lin | exp
* @example
@@ -474,11 +515,19 @@ export const { fmi, fm } = registerControl(['fmi', 'fmh'], 'fm');
* ._scope()
*
*/
export const { fmenv } = registerControl('fmenv');
export const { fmenv, fmenv1, fmenv2, fmenv3, fmenv4, fmenv5, fmenv6, fmenv7, fmenv8 } = registerMultiControl(
'fmenv',
8,
);
/**
* Attack time for the FM envelope: time it takes to reach maximum modulation
*
* A number may be added afterwards to control the attack of the envelope of
* any of the 8 individual FMs (e.g. `fmatt5`)
*
* @name fmattack
* @synonyms fmatt
* @param {number | Pattern} time attack time
* @example
* note("c e g b g e")
@@ -487,11 +536,33 @@ export const { fmenv } = registerControl('fmenv');
* ._scope()
*
*/
export const { fmattack } = registerControl('fmattack');
export const {
fmattack,
fmattack1,
fmattack2,
fmattack3,
fmattack4,
fmattack5,
fmattack6,
fmattack7,
fmattack8,
fmatt,
fmatt1,
fmatt2,
fmatt3,
fmatt4,
fmatt5,
fmatt6,
fmatt7,
fmatt8,
} = registerMultiControl('fmattack', 8, 'fmatt');
/**
* Waveform of the fm modulator
*
* A number may be added afterwards to control the waveform
* any of the 8 individual FMs (e.g. `fmwave6`)
*
* @name fmwave
* @param {number | Pattern} wave waveform
* @example
@@ -500,12 +571,19 @@ export const { fmattack } = registerControl('fmattack');
* n("0 1 2 3".fast(4)).chord("<Dm Am F G>").voicing().s("sawtooth").fmwave("brown").fm(.6)
*
*/
export const { fmwave } = registerControl('fmwave');
export const { fmwave, fmwave1, fmwave2, fmwave3, fmwave4, fmwave5, fmwave6, fmwave7, fmwave8 } = registerMultiControl(
'fmwave',
8,
);
/**
* Decay time for the FM envelope: seconds until the sustain level is reached after the attack phase.
*
* A number may be added afterwards to control the decay of the envelope of
* any of the 8 individual FMs (e.g. `fmdec6`)
*
* @name fmdecay
* @synonyms fmdec
* @param {number | Pattern} time decay time
* @example
* note("c e g b g e")
@@ -515,11 +593,35 @@ export const { fmwave } = registerControl('fmwave');
* ._scope()
*
*/
export const { fmdecay } = registerControl('fmdecay');
export const {
fmdecay,
fmdecay1,
fmdecay2,
fmdecay3,
fmdecay4,
fmdecay5,
fmdecay6,
fmdecay7,
fmdecay8,
fmdec,
fmdec1,
fmdec2,
fmdec3,
fmdec4,
fmdec5,
fmdec6,
fmdec7,
fmdec8,
} = registerMultiControl('fmdecay', 8, 'fmdec');
/**
* Sustain level for the FM envelope: how much modulation is applied after the decay phase
*
* A number may be added afterwards to control the sustain of the envelope of
* any of the 8 individual FMs (e.g. `fmsus7`)
*
* @name fmsustain
* @synonyms fmsus
* @param {number | Pattern} level sustain level
* @example
* note("c e g b g e")
@@ -529,10 +631,69 @@ export const { fmdecay } = registerControl('fmdecay');
* ._scope()
*
*/
export const { fmsustain } = registerControl('fmsustain');
// these are not really useful... skipping for now
export const { fmrelease } = registerControl('fmrelease');
export const { fmvelocity } = registerControl('fmvelocity');
export const {
fmsustain,
fmsustain1,
fmsustain2,
fmsustain3,
fmsustain4,
fmsustain5,
fmsustain6,
fmsustain7,
fmsustain8,
fmsus,
fmsus1,
fmsus2,
fmsus3,
fmsus4,
fmsus5,
fmsus6,
fmsus7,
fmsus8,
} = registerMultiControl('fmsustain', 8, 'fmsus');
/**
* Release time for the FM envelope: how much modulation is applied after the note is released
*
* A number may be added afterwards to control the release of the envelope of
* any of the 8 individual FMs (e.g. `fmrel8`)
*
* @name fmrelease
* @synonyms fmrel
* @param {number | Pattern} time release time
*
*/
export const {
fmrelease,
fmrelease1,
fmrelease2,
fmrelease3,
fmrelease4,
fmrelease5,
fmrelease6,
fmrelease7,
fmrelease8,
fmrel,
fmrel1,
fmrel2,
fmrel3,
fmrel4,
fmrel5,
fmrel6,
fmrel7,
fmrel8,
} = registerMultiControl('fmrelease', 8, 'fmrel');
// FM Matrix
// Note: we do not declare top-level exports here since it would add
// ~162 more explicit exports. This is likely fine as the most common use-case would be to at least
// declare one other FM prior to utilizing the matrix functionality, but if we ever decide we need it,
// TODO to add it explicitly / go with the globalThis approach
for (let i = 0; i <= 8; i++) {
for (let j = 0; j <= 8; j++) {
registerControl(`fmi${i}${j}`, `fm${i}${j}`);
}
}
/**
* Select the sound bank to use. To be used together with `s`. The bank name (+ "_") will be prepended to the value of `s`.
@@ -716,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
@@ -1323,14 +1484,13 @@ export const { lpdepth } = registerControl('lpdepth');
* Depth of the LFO for the lowpass filter, in HZ
*
* @name lpdepthfrequency
* @synonyms
* lpdethfreq
* @synonyms lpdepthfreq
* @param {number | Pattern} depth depth of modulation
* @example
* note("<c c c# c c c4>*16").s("sawtooth").lpf(600).lpdepthfrequency("<200 500 100 0>")
*/
export const { lpdepthfrequency } = registerControl('lpdepthfrequency', 'lpdepthfreq');
export const { lpdepthfrequency, lpdepthfreq } = registerControl('lpdepthfrequency', 'lpdepthfreq');
/**
* Shape of the LFO for the lowpass filter
@@ -1384,14 +1544,13 @@ export const { bpdepth } = registerControl('bpdepth');
* Depth of the LFO for the bandpass filter, in HZ
*
* @name bpdepthfrequency
* @synonyms
* bpdethfreq
* @synonyms bpdepthfreq
* @param {number | Pattern} depth depth of modulation
* @example
* note("<c c c# c c c4>*16").s("sawtooth").lpf(600).bpdepthfrequency("<200 500 100 0>")
*/
export const { bpdepthfrequency } = registerControl('bpdepthfrequency', 'bpdepthfreq');
export const { bpdepthfrequency, bpdepthfreq } = registerControl('bpdepthfrequency', 'bpdepthfreq');
/**
* Shape of the LFO for the bandpass filter
@@ -1439,20 +1598,19 @@ export const { hpsync } = registerControl('hpsync');
* @name hpdepth
* @param {number | Pattern} depth depth of modulation
*/
export const { hpdepth, hpdepthfreq } = registerControl('hpdepth');
export const { hpdepth } = registerControl('hpdepth');
/**
* Depth of the LFO for the hipass filter, in hz
*
* @name hpdepthfrequency
* @synonyms
* hpdethfreq
* @synonyms hpdepthfreq
* @param {number | Pattern} depth depth of modulation
* @example
* note("<c c c# c c c4>*16").s("sawtooth").lpf(600).hpdepthfrequency("<200 500 100 0>")
*/
export const { hpdepthfrequency } = registerControl('hpdepthfrequency', 'hpdepthfreq');
export const { hpdepthfrequency, hpdepthfreq } = registerControl('hpdepthfrequency', 'hpdepthfreq');
/**
* Shape of the LFO for the highpass filter
@@ -1887,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
@@ -1931,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
@@ -2358,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');
@@ -2596,8 +2774,13 @@ export const as = register('as', (mapping, pat) => {
mapping = Array.isArray(mapping) ? mapping : [mapping];
return pat.fmap((v) => {
v = Array.isArray(v) ? v : [v];
v = Object.fromEntries(mapping.map((prop, i) => [getControlName(prop), v[i]]));
return v;
const entries = [];
for (let i = 0; i < mapping.length; ++i) {
if (v[i] !== undefined) {
entries.push([getControlName(mapping[i]), v[i]]);
}
}
return Object.fromEntries(entries);
});
});
@@ -2629,3 +2812,267 @@ 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
*
* @name transient
* @param {number | Pattern} attack Emphasis on transients; between -1 (deaccentuate) and 1 (accentuate)
* @param {number | Pattern} sustain Emphasis on the sustains; between -1 (deaccentuate) and 1 (accentuate)
* @example
* s("bd").transient("<-1 -0.5 0 0.5 1>")
* @example
* 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');
+90
View File
@@ -0,0 +1,90 @@
/*
stateful.mjs - File of shame for stateful, impure and otherwise illegal pattern methods
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/index.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { register, reify, Pattern } from './pattern.mjs';
let timelines = {};
export const reset_state = function () {
reset_timelines();
};
export const reset_timelines = function () {
timelines = {};
};
/***
* Allows you to switch a pattern between different 'timelines'. This is particularly useful when
* live coding, for example when you want to cue a pattern up to play from its start.
*
* Timelines are specified by number, so that if you had a pattern like
* `n("<0 1 2 3>").s("num").timeline(1)` playing, then changed the '1'
* to '2', it would always align '0' to the nearest cycle. You will likely want to trigger
* an evaluation a little bit before the cycle starts, to avoid missing events.
*
* After the first use, a timeline will continue with the same 'offset'. That is, if you change
* a pattern without changing its timeline number, it will stay on that timeline without resetting.
*
* Rather than incrementing a timeline to reset it, it's easier to negate it, e.g. by switching between `-2`
* and `2`. This is because when you negate a timeline it will always reset.
*
* You can also pattern the timeline if you want, to create strange resetting patterns.
* @param {number | Pattern} timeline The timeline that the pattern should play on.
* @example
* n("<0 1 2 3>(3,8)")
* .sound("num")
* // resets the timeline every two cycles, by negating the timeline.
* // in a lot of cases this will be edited by a human live coder
* // rather than patterned!
* .timeline("<2 -2>".slow(2))
*/
export const timeline = register(
'timeline',
function (tpat, pat) {
tpat = reify(tpat);
const f = function (state) {
// Is this called from the scheduler? (rather than from e.g. the visualiser)
const scheduler = !!state.controls.cyclist;
const timehaps = tpat.query(state);
const result = [];
for (const timehap of timehaps) {
const tlid = timehap.value;
let offset;
if (tlid === 0) {
offset = 0;
} else if (tlid in timelines) {
offset = timelines[tlid];
} else {
const timearc = timehap.wholeOrPart();
if (!scheduler || state.span.begin.lt(timearc.midpoint())) {
offset = timearc.begin;
} else {
// Sync to end of timearc if we first see it over halfway into its
// timespan. Allows 'cuing up' next timeline when live coding.
offset = timearc.end;
}
}
if (scheduler) {
// update state
timelines[tlid] = offset;
if (tlid !== 0) {
delete timelines[-tlid];
}
}
const pathaps = pat
.late(offset)
.query(state.setSpan(timehap.part))
.map((h) => h.setContext(h.combineContext(timehap)));
result.push(...pathaps);
}
return result;
};
return new Pattern(f, pat._steps);
},
false,
);
+13 -12
View File
@@ -1,6 +1,6 @@
/*
index.mjs - <short description TODO>
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/index.mjs>
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/index.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@@ -11,20 +11,21 @@ import createClock from './zyklus.mjs';
import { logger } from './logger.mjs';
export { Fraction, controls, createClock };
export * from './controls.mjs';
export * from './hap.mjs';
export * from './pattern.mjs';
export * from './signal.mjs';
export * from './pick.mjs';
export * from './state.mjs';
export * from './timespan.mjs';
export * from './util.mjs';
export * from './speak.mjs';
export * from './evaluate.mjs';
export * from './repl.mjs';
export * from './cyclist.mjs';
export * from './evaluate.mjs';
export * from './hap.mjs';
export * from './impure.mjs';
export * from './logger.mjs';
export * from './time.mjs';
export * from './pattern.mjs';
export * from './pick.mjs';
export * from './repl.mjs';
export * from './signal.mjs';
export * from './speak.mjs';
export * from './state.mjs';
export * from './schedulerState.mjs';
export * from './timespan.mjs';
export * from './ui.mjs';
export * from './util.mjs';
export { default as drawLine } from './drawLine.mjs';
// below won't work with runtime.mjs (json import fails)
/* import * as p from './package.json';
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel/core",
"version": "1.2.5",
"version": "1.2.6",
"description": "Port of Tidal Cycles to JavaScript",
"main": "index.mjs",
"type": "module",
@@ -31,6 +31,7 @@
},
"homepage": "https://strudel.cc",
"dependencies": {
"@kabelsalat/web": "^0.4.1",
"fraction.js": "^5.2.1"
},
"gitHead": "0e26d4e741500f5bae35b023608f062a794905c2",
+188 -18
View File
@@ -25,7 +25,8 @@ import {
stringifyValues,
} from './util.mjs';
import drawLine from './drawLine.mjs';
import { logger } from './logger.mjs';
import { errorLogger, logger } from './logger.mjs';
import { strudelScope } from './evaluate.mjs';
let stringParser;
@@ -414,7 +415,7 @@ export class Pattern {
try {
return this.query(new State(new TimeSpan(begin, end), controls));
} catch (err) {
logger(`[query]: ${err.message}`, 'error');
errorLogger(err, 'query');
return [];
}
}
@@ -574,7 +575,8 @@ export class Pattern {
* Returns a new Pattern, which only returns haps that meet the given test.
* @param {Function} hap_test - a function which returns false for haps to be removed from the pattern
* @returns Pattern
* @noAutocomplete
* @example
* s("bd*8").velocity(rand).filterHaps((h) => (h.whole.begin % 1) < h.value.velocity)
*/
filterHaps(hap_test) {
return new Pattern((state) => this.query(state).filter(hap_test));
@@ -585,7 +587,11 @@ export class Pattern {
* inside haps.
* @param {Function} value_test
* @returns Pattern
* @noAutocomplete
* @example
* const drums = s("bd sd bd sd")
* kick: drums.filterValues((v) => v.s === 'bd').duck(2)
* snare: drums.filterValues((v) => v.s === 'sd')
* bass: s("saw!4").note("G#1").lpf(80).lpenv(4).orbit(2)
*/
filterValues(value_test) {
return new Pattern((state) => this.query(state).filter((hap) => value_test(hap.value))).setSteps(this._steps);
@@ -1587,7 +1593,13 @@ export const func = curry((a, b) => reify(b).func(a));
*
* @param {string | string[]} name name of the function, or an array of names to be used as synonyms
* @param {function} func function with 1 or more params, where last is the current pattern
* @noAutocomplete
* @param {bool} patternify defaults to true; if set to false, you will have more control over the arguments to `func` as they will be
* in their raw form and it will be up to you to patternify them and/or query them for values
* @example
* const vlpf = register('vlpf', (freq, pat) => {
* return pat.fmap((v) => ({...v, cutoff: freq * (v.velocity ?? 1) }));
* })
* s("saw").seg(8).velocity(rand).vlpf(800)
*
*/
export function register(name, func, patternify = true, preserveSteps = false, join = (x) => x.innerJoin()) {
@@ -1672,7 +1684,9 @@ export function register(name, func, patternify = true, preserveSteps = false, j
// toplevel functions get curried as well as patternified
// because pfunc uses spread args, we need to state the arity explicitly!
return curry(pfunc, null, arity);
const curried = curry(pfunc, null, arity);
strudelScope[name] = curried;
return curried;
}
// Like register, but defaults to stepJoin
@@ -2234,7 +2248,7 @@ export const brak = register('brak', function (pat) {
});
/**
* Reverse all haps in a pattern
* Reverse all cycles in a pattern. See also `revv` for reversing a whole pattern.
*
* @name rev
* @memberof Pattern
@@ -2266,6 +2280,23 @@ export const rev = register(
true,
);
/**
* Reverse a whole pattern. See also `rev` for reversing each cycle.
*
* @name revv
* @memberof Pattern
* @returns Pattern
* @example
* // This is the same as `<[g e] [d c]>`. If `rev()` is used, you get
* // the same as `<[d c] [g e]>`, where each cycle reverses, but the order of
* // cycles stays the same.
* note("<[c d] [e g]>").revv()
*/
export const revv = register('revv', function (pat) {
const negateSpan = (span) => new TimeSpan(Fraction(0).sub(span.end), Fraction(0).sub(span.begin));
return pat.withQuerySpan(negateSpan).withHapSpan(negateSpan);
});
/** Like press, but allows you to specify the amount by which each
* event is shifted. pressBy(0.5) is the same as press, while
* pressBy(1/3) shifts each event by a third of its timespan.
@@ -2659,8 +2690,13 @@ export const hsl = register('hsl', (h, s, l, pat) => {
/**
* Tags each Hap with an identifier. Good for filtering. The function populates Hap.context.tags (Array).
* @name tag
* @noAutocomplete
* @param {string} tag anything unique
* @example
* s("saw!16").note("F1")
* .lpf(tri.range(40, 80).slow(4)).lpenv(5).lpq(4).lpd(0.15)
* .when(rand.late(0.1).gte(0.5), x => x.transpose("12").tag('altered'))
* .when(rand.late(0.2).gte(0.5), x => x.s("square").tag('altered'))
* .when("<0 1>", x => x.filter((hap) => hap.hasTag('altered')))
*/
Pattern.prototype.tag = function (tag) {
return this.withContext((ctx) => ({ ...ctx, tags: (ctx.tags || []).concat([tag]) }));
@@ -2671,15 +2707,16 @@ Pattern.prototype.tag = function (tag) {
* @name filter
* @param {Function} test function to test Hap
* @example
* s("hh!7 oh").filter(hap => hap.value.s==='hh')
* s("hh!7 oh").filter(hap => hap.value.s === 'hh')
*/
export const filter = register('filter', (test, pat) => pat.withHaps((haps) => haps.filter(test)));
/**
* Filters haps by their begin time
* @name filterWhen
* @noAutocomplete
* @param {Function} test function to test Hap.whole.begin
* @example
* oneCycle: s("bd*4").filterWhen((t) => t < 1)
*/
export const filterWhen = register('filterWhen', (test, pat) => pat.filter((h) => test(h.whole.begin)));
@@ -3552,6 +3589,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
*
@@ -3560,6 +3611,8 @@ export const morph = (frompat, topat, bypat) => {
* @param {number | Pattern} volume linear postgain of the distortion
*
*/
export const soft = _distortWithAlg('soft');
/**
* Hard-clipping distortion
*
@@ -3568,6 +3621,8 @@ export const morph = (frompat, topat, bypat) => {
* @param {number | Pattern} volume linear postgain of the distortion
*
*/
export const hard = _distortWithAlg('hard');
/**
* Cubic polynomial distortion
*
@@ -3576,6 +3631,8 @@ export const morph = (frompat, topat, bypat) => {
* @param {number | Pattern} volume linear postgain of the distortion
*
*/
export const cubic = _distortWithAlg('cubic');
/**
* Diode-emulating distortion
*
@@ -3584,6 +3641,8 @@ export const morph = (frompat, topat, bypat) => {
* @param {number | Pattern} volume linear postgain of the distortion
*
*/
export const diode = _distortWithAlg('diode');
/**
* Asymmetrical diode distortion
*
@@ -3592,6 +3651,8 @@ export const morph = (frompat, topat, bypat) => {
* @param {number | Pattern} volume linear postgain of the distortion
*
*/
export const asym = _distortWithAlg('asym');
/**
* Wavefolding distortion
*
@@ -3600,6 +3661,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
*
@@ -3608,6 +3671,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
*
@@ -3616,14 +3681,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
@@ -3687,3 +3745,115 @@ 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));
};
const _asArrayPattern = (pats) => {
const pack = (...xs) => xs;
let acc = pure(curry(pack, null, pats.length));
for (const p of pats) acc = acc.appLeft(p);
return acc;
};
/**
* Produces a [Kabelsalat](https://kabel.salat.dev/) modular sound engine.
* This can be used as either an effect (by including `audioin()` at the beginning
* of your kabel expression) or as a sound source (via any expression which doesn't
* start with `audioin()`).
*
* Some helpers you have available to you:
* * Strudel mini notation works fine in K(..) via "" or ``
* * More complex Strudel expressions (like "0 1 2".fast(4) or irand(24)) can be
* written by wrapping them in `S(..)` inside your Kabel code
* * We expose Strudel's note frequency under `sFreq` and Strudel's gate
* information under `sGate`
* * You can use more complex multi-line expressions (like `let x = a; let y = b; x.lpf(y);`)
* by wrapping them inside a function in K (see example).
*
* @name K
* @param {KabelsalatExpression | Function} expr Kabelsalat graph definition
* @memberof Pattern
* @returns Pattern
*
* @example
* note("A c e".fast(4)).transpose("<0 2 4 6 8>")
* .scale("F:minor").transpose("12")
* .s("saw")
* .K(
* // audioin().mul(sGate.adsr(0.001, 0.3, 0, 0.2)) // as effect
* saw(saw(sFreq / "2!3 16").mul(8).add(sFreq).lag("0!3 0.1")).mul(0.3) // as source
* .mul(sGate.adsr(0, 0.15, 0.5, "0.1!3 1"))
* .lpf(sGate.adsr(0, 0.2, 0.3, 0.2).mul(1).add(0))
* .add(x => x.delay(S("0.3 0.2".fast(2))).mul(0.7))
* .add(x => x.delay("0.03 [0.08 0.01] 0.01 0.013").mul(0.77)).mul(0.7)
* .add(x => x.delay(.13).mul(0.7))
* .out()
* )
*
* @example
* n("<0 1 <2 3 2 4>>*16")
* .scale("G#2:minor").sometimes(x => x.transpose("12 | 24"))
* .K(() => {
* const att = S(rand.range(0, 0.05))
* const dec = S(rand.range(0.05, 0.2))
* let f = n(sFreq);
* const mod = sine(f).mul("0.1 | 0.2 | 0.3")
* .add("[[1.5 1] | 1 | 2 | 4 | [6 4@3]]*2")
* saw(f.mul(mod))
* .mul(sGate.ad(att, dec))
* .add(x => x.delay(0.4).mul(0.3))
* .out()
* }).fxr(1).room(0.3)
*/
/**
* Creates a worklet effect. Typically derived by writing K(...) in the REPL which will parse
* Kabelsalat code.
*
* @name worklet
* @param {string} src Source code of the worklet update function
* @param {...number | ...Pattern} inputs Worklet inputs
* @memberof Pattern
* @returns Pattern
* @noAutocomplete
*/
Pattern.prototype.worklet = function (src, ...inputs) {
inputs = inputs.map(reify);
return this.outerBind((v) => {
return _asArrayPattern(inputs).withValue((vInput) => {
const currInputs = v.workletInputs ?? [];
return { ...v, workletSrc: src, workletInputs: currInputs.concat(vInput) };
});
});
};
export const worklet = (...args) => pure({}).worklet(...args);
+24 -3
View File
@@ -2,9 +2,17 @@ import { NeoCyclist } from './neocyclist.mjs';
import { Cyclist } from './cyclist.mjs';
import { evaluate as _evaluate } from './evaluate.mjs';
import { errorLogger, logger } from './logger.mjs';
import { setTime } from './time.mjs';
import {
setCpsFunc,
setIsStarted,
setPattern as exposeSchedulerPattern,
setTime,
setTriggerFunc,
} from './schedulerState.mjs';
import { evalScope } from './evaluate.mjs';
import { register, Pattern, isPattern, silence, stack } from './pattern.mjs';
import { reset_state } from './impure.mjs';
import { SalatRepl } from '@kabelsalat/web';
export function repl({
defaultOutput,
@@ -23,6 +31,7 @@ export function repl({
id,
mondo = false,
}) {
const kabel = new SalatRepl({ localScope: true });
const state = {
schedulerError: undefined,
evalError: undefined,
@@ -51,7 +60,11 @@ export function repl({
getTime,
onToggle: (started) => {
updateState({ started });
setIsStarted(started);
onToggle?.(started);
if (!started) {
reset_state();
}
},
setInterval,
clearInterval,
@@ -61,6 +74,8 @@ export function repl({
// NeoCyclist uses a shared worker to communicate between instances, which is not supported on mobile chrome
const scheduler =
sync && typeof SharedWorker != 'undefined' ? new NeoCyclist(schedulerOptions) : new Cyclist(schedulerOptions);
setTriggerFunc(schedulerOptions.onTrigger);
setCpsFunc(() => scheduler.cps);
let pPatterns = {};
let anonymousIndex = 0;
let allTransform;
@@ -74,6 +89,11 @@ export function repl({
return silence;
};
const compileKabel = (code) => {
const node = kabel.evaluate(code);
return node.compile({ log: false });
};
// helper to get a patternified pure value out
function unpure(pat) {
if (pat._Pattern) {
@@ -85,6 +105,7 @@ export function repl({
const setPattern = async (pattern, autostart = true) => {
pattern = editPattern?.(pattern) || pattern;
await scheduler.setPattern(pattern, autostart);
exposeSchedulerPattern(pattern);
return pattern;
};
setTime(() => scheduler.now()); // TODO: refactor?
@@ -194,6 +215,7 @@ export function repl({
setcps: setCps,
setCpm,
setcpm: setCpm,
compileKabel,
});
};
@@ -244,8 +266,7 @@ export function repl({
}
if (!isPattern(pattern)) {
const message = `got "${typeof evaluated}" instead of pattern`;
throw new Error(message + (typeof evaluated === 'function' ? ', did you forget to call a function?' : '.'));
pattern = silence;
}
logger(`[eval] code updated`);
pattern = await setPattern(pattern, autostart);
+53
View File
@@ -0,0 +1,53 @@
/*
schedulerState.mjs - Module to pipe out various parameters from the scheduler for global consumption
Copyright (C) 2026 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/schedulerState.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 time;
let cpsFunc;
let pattern;
let triggerFunc;
let isStarted;
export function getTime() {
if (!time) {
throw new Error('no time set! use setTime to define a time source');
}
return time();
}
export function setTime(func) {
time = func;
}
export function setCpsFunc(func) {
cpsFunc = func;
}
export function getCps() {
return cpsFunc?.();
}
export function setPattern(pat) {
pattern = pat;
}
export function getPattern() {
return pattern;
}
export function setTriggerFunc(func) {
triggerFunc = func;
}
export function getTriggerFunc() {
return triggerFunc;
}
export function setIsStarted(val) {
isStarted = !!val;
}
export function getIsStarted() {
return isStarted;
}
+171 -38
View File
@@ -16,7 +16,7 @@ export function steady(value) {
}
export const signal = (func) => {
const query = (state) => [new Hap(undefined, state.span, func(state.span.begin))];
const query = (state) => [new Hap(undefined, state.span, func(state.span.begin, state.controls))];
return new Pattern(query);
};
@@ -186,38 +186,97 @@ export const mouseY = signal(() => _mouseY);
export const mousex = signal(() => _mouseX);
export const mouseX = signal(() => _mouseX);
// random signals
// Random number generators
const xorwise = (x) => {
// Produce "Avalanche effect" where flipping a single bit of x
// results in all output bits flipping with probability 0.5
// See e.g. https://github.com/aappleby/smhasher/blob/0ff96f7835817a27d0487325b6c16033e2992eb5/src/MurmurHash3.cpp#L68-L77
const _murmurHashFinalizer = (x) => {
x |= 0;
x ^= x >>> 16;
x = Math.imul(x, 0x85ebca6b);
x ^= x >>> 13;
x = Math.imul(x, 0xc2b2ae35);
x ^= x >>> 16;
return x >>> 0; // unsigned
};
// Convert t to a 32 bit integer, preserving temporal resolution down to 1/2^29
const _tToT = (t) => {
return Math.floor(t * 536870912);
};
// Used to decorrelate nearby T, i, and seed prior to hashing
const _decorrelate = (T, i = 0, seed = 0) => {
const lowBits = (T >>> 0) >>> 0;
const highBits = Math.floor(T / 4294967296) >>> 0; // 2^32
let key = lowBits ^ Math.imul(highBits ^ 0x85ebca6b, 0xc2b2ae35);
key ^= Math.imul(i ^ 0x7f4a7c15, 0x9e3779b9);
key ^= Math.imul(seed ^ 0x165667b1, 0x27d4eb2d);
return key >>> 0;
};
const randAt = (T, i = 0, seed = 0) => {
return _murmurHashFinalizer(_decorrelate(T, i, seed)) / 4294967296; // 2^32
};
// n samples at time t
const timeToRands = (t, n, seed = 0) => {
const T = _tToT(t);
if (n === 1) {
return randAt(T, 0, seed);
}
const out = new Array(n);
for (let i = 0; i < n; i++) out[i] = randAt(T, i, seed);
return out;
};
// Old random signals. Currently the default, but can also be chosen via
// `useRNG('legacy')`
// stretch 300 cycles over the range of [0,2**29 == 536870912) then apply the xorshift algorithm
const __xorwise = (x) => {
const a = (x << 13) ^ x;
const b = (a >> 17) ^ a;
return (b << 5) ^ b;
};
// stretch 300 cycles over the range of [0,2**29 == 536870912) then apply the xorshift algorithm
const _frac = (x) => x - Math.trunc(x);
const timeToIntSeed = (x) => xorwise(Math.trunc(_frac(x / 300) * 536870912));
const intSeedToRand = (x) => (x % 536870912) / 536870912;
const timeToRand = (x) => Math.abs(intSeedToRand(timeToIntSeed(x)));
const timeToRandsPrime = (seed, n) => {
const __frac = (x) => x - Math.trunc(x);
const __timeToIntSeed = (x) => __xorwise(Math.trunc(__frac(x / 300) * 536870912));
const __intSeedToRand = (x) => (x % 536870912) / 536870912;
const __timeToRandsPrime = (seed, n) => {
if (n === 1) {
return Math.abs(__intSeedToRand(seed));
}
const result = [];
// eslint-disable-next-line
for (let i = 0; i < n; ++i) {
result.push(intSeedToRand(seed));
seed = xorwise(seed);
for (let i = 0; i < n; i++) {
result.push(__intSeedToRand(seed));
seed = __xorwise(seed);
}
return result;
};
const __timeToRands = (t, n) => __timeToRandsPrime(__timeToIntSeed(t), n);
const timeToRands = (t, n) => timeToRandsPrime(timeToIntSeed(t), n);
// End old random
let RNG_MODE = 'legacy';
export const getRandsAtTime = (t, n = 1, seed = 0) => {
return RNG_MODE === 'legacy' ? __timeToRands(t + seed, n) : timeToRands(t, n, seed);
};
/**
* Sets which random number generator to use. Historically Strudel would
* use `useRNG('legacy')`, which remains the default. To use a new more statistically
* precise RNG, try `useRNG('precise')`.
*
* @name useRNG
* @param {string} mod - Mode. One of 'legacy', 'precise'
* @example
* useRNG('legacy')
* // Repeats every 300 cycles
* $: n(irand(50)).seg(16).scale("C:minor").ribbon(88, 32)
* $: n(irand(50)).seg(16).scale("C:minor").ribbon(388, 32)
*/
export const useRNG = (mode = 'legacy') => (RNG_MODE = mode);
/**
* A discrete pattern of numbers from 0 to n-1
@@ -300,13 +359,13 @@ export const binaryNL = (n, nBits = 16) => {
* .partials(randL(8))
*/
export const randL = (n) => {
return signal((t) => (nVal) => timeToRands(t, nVal).map(Math.abs)).appLeft(reify(n));
return signal((t) => (nVal) => getRandsAtTime(t, nVal).map(Math.abs)).appLeft(reify(n));
};
export const randrun = (n) => {
return signal((t) => {
return signal((t, controls) => {
// Without adding 0.5, the first cycle is always 0,1,2,3,...
const rands = timeToRands(t.floor().add(0.5), n);
const rands = getRandsAtTime(t.floor().add(0.5), n, controls.randSeed);
const nums = rands
.map((n, i) => [n, i])
.sort((a, b) => (a[0] > b[0]) - (a[0] < b[0]))
@@ -347,6 +406,37 @@ export const scramble = register('scramble', (n, pat) => {
return _rearrangeWith(_irand(n)._segment(n), n, pat);
});
/**
* Modify a pattern by applying a function to the `randomSeed` control if present
*
* @param {Function} func Function from seed (or undefined) to seed (or undefined)
* @param {Pattern} pat Pattern to update
* @returns Pattern
*/
export const withSeed = (func, pat) => {
return new Pattern((state) => {
let { randSeed, ...controls } = state.controls;
randSeed = func(randSeed);
return pat.query(state.setControls({ ...controls, randSeed }));
}, pat._steps);
};
/**
* Change the seed for random signals. Normally, random signals depend on time,
* so two patterns at the same time will have the same random values. Specifying
* a new seed changes the signal output by `rand`. This also affects other functions
* that use randomness, like `shuffle` and `sometimes`.
*
* @name seed
* @param {number} n A new seed. Can be any number.
* @example
* $: s("hh*4").degrade();
* $: s("bd*4").degrade().seed(1); // Will degrade different events from the hi-hat
*/
export const seed = register('seed', (n, pat) => {
return withSeed(() => n, pat);
});
/**
* A continuous pattern of random numbers, between 0 and 1.
*
@@ -356,7 +446,7 @@ export const scramble = register('scramble', (n, pat) => {
* s("bd*4,hh*8").cutoff(rand.range(500,8000))
*
*/
export const rand = signal(timeToRand);
export const rand = signal((t, controls) => getRandsAtTime(t, 1, controls.randSeed));
/**
* A continuous pattern of random numbers, between -1 and 1
*/
@@ -533,36 +623,32 @@ export const wchooseCycles = (...pairs) => _wchooseWith(rand.segment(1), ...pair
export const wrandcat = wchooseCycles;
function _perlin(t) {
function _perlin(t, seed = 0) {
let ta = Math.floor(t);
let tb = ta + 1;
const smootherStep = (x) => 6.0 * x ** 5 - 15.0 * x ** 4 + 10.0 * x ** 3;
const interp = (x) => (a) => (b) => a + smootherStep(x) * (b - a);
const v = interp(t - ta)(timeToRand(ta))(timeToRand(tb));
const ra = getRandsAtTime(ta, 1, seed);
const rb = getRandsAtTime(tb, 1, seed);
const v = interp(t - ta)(ra)(rb);
return v;
}
export const perlinWith = (tpat) => {
return tpat.fmap(_perlin);
};
function _berlin(t) {
function _berlin(t, seed = 0) {
const prevRidgeStartIndex = Math.floor(t);
const nextRidgeStartIndex = prevRidgeStartIndex + 1;
const prevRidgeBottomPoint = timeToRand(prevRidgeStartIndex);
const nextRidgeTopPoint = timeToRand(nextRidgeStartIndex) + prevRidgeBottomPoint;
const prevRidgeBottomPoint = getRandsAtTime(prevRidgeStartIndex, 1, seed);
const height = getRandsAtTime(nextRidgeStartIndex, 1, seed);
const nextRidgeTopPoint = prevRidgeBottomPoint + height;
const currentPercent = (t - prevRidgeStartIndex) / (nextRidgeStartIndex - prevRidgeStartIndex);
const interp = (a, b, t) => {
return a + (b - a) * t;
return a + t * (b - a);
};
return interp(prevRidgeBottomPoint, nextRidgeTopPoint, currentPercent) / 2;
}
export const berlinWith = (tpat) => {
return tpat.fmap(_berlin);
};
/**
* Generates a continuous pattern of [perlin noise](https://en.wikipedia.org/wiki/Perlin_noise), in the range 0..1.
*
@@ -572,7 +658,7 @@ export const berlinWith = (tpat) => {
* s("bd*4,hh*8").cutoff(perlin.range(500,8000))
*
*/
export const perlin = perlinWith(time.fmap((v) => Number(v)));
export const perlin = signal((t, controls) => _perlin(t, controls.randSeed));
/**
* Generates a continuous pattern of [berlin noise](conceived by Jame Coyne and Jade Rowland as a joke but turned out to be surprisingly cool and useful,
@@ -584,7 +670,7 @@ export const perlin = perlinWith(time.fmap((v) => Number(v)));
* n("0!16".add(berlin.fast(4).mul(14))).scale("d:minor")
*
*/
export const berlin = berlinWith(time.fmap((v) => Number(v)));
export const berlin = signal((t, controls) => _berlin(t, controls.randSeed));
export const degradeByWith = register(
'degradeByWith',
@@ -882,3 +968,50 @@ export const whenKey = register('whenKey', function (input, func, pat) {
export const keyDown = register('keyDown', function (pat) {
return pat.fmap(_keyDown);
});
/**
* A pattern measuring the duration of events,
* in cycles per event. `cyclesPer` doesn't have structure itself, but takes structure, and therefore
* event durations, from the pattern that it is combined with.
* For example `cyclesPer.struct("1 1 [1 1] 1")` would give the same as `"0.25 0.25 [0.125 0.125] 0.25"`.
* See also its reciprocal, `per`, also known as `perCycle`.
* @example
* // Shorter events are lower in pitch
* sound("saw saw [saw saw] saw")
* .note(cyclesPer.range(50, 100))
* @example
* sound("bd sd [bd bd] sd*4 [- sd] [bd [bd bd]]")
* .note(cyclesPer.add(20))
*/
export const cyclesPer = new Pattern(function (state) {
return [new Hap(undefined, state.span, state.span.duration)];
});
/**
* A pattern measuring the 'shortness' of events, or in other words, the duration of pattern events,
* in events per cycle. `per` doesn't have structure itself, but takes structure, and therefore
* event durations, from the pattern that it is combined with.
* For example `per.struct("1 1 [1 1] 1")` would give the same as `"4 4 [8 8] 4"`.
* See also its reciprocal, `cyclesPer`.
* @synonyms perCycle
* @example
* // Shorter events are more distorted
* n("0 0*2 0 0*2 0 [0 0 0]@2").sound("bd")
* .distort(per.div(2))
*/
export const per = new Pattern(function (state) {
return [new Hap(undefined, state.span, Fraction(1).div(state.span.duration))];
});
export const perCycle = per;
/**
* Like `per` but measures the shortness of events according to an exponential curve. In
* particular, where the event duration halves, the
* returned value increases by one. `perx.struct("1 1 [1 [1 1]] 1")` would therefore be
* the same as `"3 3 [4 [5 5]] 3"`.
*/
export const perx = new Pattern(function (state) {
const n = Fraction(1).div(state.span.duration);
return [new Hap(undefined, state.span, Math.log(n) / Math.log(2) + 1)];
});
+12 -14
View File
@@ -586,6 +586,18 @@ describe('Pattern', () => {
.map((a) => a.value),
).toStrictEqual(['c', 'b', 'a']);
});
it('Does not reverse the order of cycles', () => {
expect(fastcat('a', 'b', 'c', 'd').slow(2).rev().fast(2).sortHapsByPart().firstCycle()).toStrictEqual(
fastcat('b', 'a', 'd', 'c').firstCycle(),
);
});
});
describe('revv()', () => {
it('Does reverse the order of cycles', () => {
expect(fastcat('a', 'b', 'c', 'd').slow(2).revv().fast(2).sortHapsByPart().firstCycle()).toStrictEqual(
fastcat('d', 'c', 'b', 'a').firstCycle(),
);
});
});
describe('sequence()', () => {
it('Can work like fastcat', () => {
@@ -728,20 +740,6 @@ describe('Pattern', () => {
);
});
});
describe('signal()', () => {
it('Can make saw/saw2', () => {
expect(saw.struct(true, true, true, true).firstCycle()).toStrictEqual(
sequence(0, 1 / 4, 1 / 2, 3 / 4).firstCycle(),
);
expect(saw2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(-1, -0.5, 0, 0.5).firstCycle());
});
it('Can make isaw/isaw2', () => {
expect(isaw.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.75, 0.5, 0.25).firstCycle());
expect(isaw2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.5, 0, -0.5).firstCycle());
});
});
describe('_setContext()', () => {
it('Can set the hap context', () => {
expect(
+61
View File
@@ -0,0 +1,61 @@
/*
signal.test.mjs - <short description TODO>
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/test/pattern.test.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Fraction from 'fraction.js';
import { describe, it, expect, vi } from 'vitest';
import { saw, saw2, isaw, isaw2, per, perx, cyclesPer } from '../signal.mjs';
import { fastcat, sequence, State, TimeSpan, Hap } from '../index.mjs';
const st = (begin, end) => new State(ts(begin, end));
const ts = (begin, end) => new TimeSpan(Fraction(begin), Fraction(end));
const hap = (whole, part, value, context = {}) => new Hap(whole, part, value, context);
const third = Fraction(1, 3);
const twothirds = Fraction(2, 3);
const sameFirst = (a, b) => {
return expect(a.sortHapsByPart().firstCycle()).toStrictEqual(b.sortHapsByPart().firstCycle());
};
describe('signal()', () => {
it('Can make saw/saw2', () => {
expect(saw.struct(true, true, true, true).firstCycle()).toStrictEqual(
sequence(0, 1 / 4, 1 / 2, 3 / 4).firstCycle(),
);
expect(saw2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(-1, -0.5, 0, 0.5).firstCycle());
});
it('Can make isaw/isaw2', () => {
expect(isaw.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.75, 0.5, 0.25).firstCycle());
expect(isaw2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.5, 0, -0.5).firstCycle());
});
});
describe('cyclesPer', () => {
it('gives cycles per hap', () => {
sameFirst(
cyclesPer.struct(true, true, true, fastcat(true, true)),
sequence(0.25, 0.25, 0.25, fastcat(0.125, 0.125)).fmap(Fraction),
);
});
});
describe('per', () => {
it('gives haps per cycle', () => {
sameFirst(per.struct(true, true, true, fastcat(true, true)), sequence(4, 4, 4, fastcat(8, 8)).fmap(Fraction));
});
});
describe('perx', () => {
it('gives exponential haps per cycle', () => {
sameFirst(
perx.struct(true, true, true, fastcat(true, fastcat(true, true))),
sequence(3, 3, 3, fastcat(4, fastcat(5, 5))),
);
});
});
-11
View File
@@ -1,11 +0,0 @@
let time;
export function getTime() {
if (!time) {
throw new Error('no time set! use setTime to define a time source');
}
return time();
}
export function setTime(func) {
time = func;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel/csound",
"version": "1.2.6",
"version": "1.2.7",
"description": "csound bindings for strudel",
"main": "index.mjs",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel/draw",
"version": "1.2.5",
"version": "1.2.6",
"description": "Helpers for drawing with Strudel",
"main": "index.mjs",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel/embed",
"version": "1.1.1",
"version": "1.1.2",
"description": "Embeddable Web Component to load a Strudel REPL into an iframe",
"main": "embed.js",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel/gamepad",
"version": "1.2.5",
"version": "1.2.6",
"description": "Gamepad Inputs for strudel",
"main": "index.mjs",
"type": "module",
+1
View File
@@ -35,6 +35,7 @@ export async function initHydra(options = {}) {
hydra.synth.s0.init({ src: canvas });
}
}
return hydra;
}
export function clearHydra() {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel/hydra",
"version": "1.2.5",
"version": "1.2.6",
"description": "Hydra integration for strudel",
"main": "hydra.mjs",
"type": "module",
+190 -22
View File
@@ -5,9 +5,23 @@ This program is free software: you can redistribute it and/or modify it under th
*/
import * as _WebMidi from 'webmidi';
import { Pattern, isPattern, logger, ref } from '@strudel/core';
import {
Hap,
Pattern,
TimeSpan,
getCps,
getIsStarted,
getPattern,
getTime,
getTriggerFunc,
isPattern,
logger,
ref,
reify,
} from '@strudel/core';
import { noteToMidi, getControlName } from '@strudel/core';
import { Note } from 'webmidi';
import { getAudioContext } from '@strudel/webaudio';
import { scheduleAtTime } from '../superdough/helpers.mjs';
// if you use WebMidi from outside of this package, make sure to import that instance:
@@ -477,21 +491,13 @@ Pattern.prototype.midi = function (midiport, options = {}) {
});
};
let listeners = {};
const refs = {};
/**
* MIDI input: Opens a MIDI input port to receive MIDI control change messages.
* @param {string | number} input MIDI device name or index defaulting to 0
* @returns {Function}
* @example
* let cc = await midin('IAC Driver Bus 1')
* note("c a f e").lpf(cc(0).range(0, 1000)).lpq(cc(1).range(0, 10)).sound("sawtooth")
* Initialize a midi device
*/
export async function midin(input) {
async function _initialize(input) {
if (isPattern(input)) {
throw new Error(
`midin: does not accept Pattern as input. Make sure to pass device name with single quotes. Example: midin('${
`[midi] Midi input cannot be a pattern. Make sure to pass device name with single quotes. Example: midin('${
WebMidi.outputs?.[0]?.name || 'IAC Driver Bus 1'
}')`,
);
@@ -500,29 +506,191 @@ export async function midin(input) {
const device = getDevice(input, WebMidi.inputs);
if (!device) {
throw new Error(
`midiin: device "${input}" not found.. connected devices: ${getMidiDeviceNamesString(WebMidi.inputs)}`,
`[midi] Midi device "${input}" not found.. connected devices: ${getMidiDeviceNamesString(WebMidi.inputs)}`,
);
}
if (initial) {
const otherInputs = WebMidi.inputs.filter((o) => o.name !== device.name);
logger(
`Midi enabled! Using "${device.name}". ${
`[midi] Midi enabled! Using "${device.name}". ${
otherInputs?.length ? `Also available: ${getMidiDeviceNamesString(otherInputs)}` : ''
}`,
);
}
// ensure refs for this input are initialized
if (!refs[input]) {
refs[input] = {};
}
const cc = (cc) => ref(() => refs[input][cc] || 0);
return device;
}
/**
* MIDI input: Opens a MIDI input port to receive MIDI control change messages.
*
* The output is a function that accepts a midi cc value to query as well as (optionally) a midi channel
*
* @name midin
* @param {string | number} input MIDI device name or index defaulting to 0
* @returns {function(number, number=): Pattern} A function from (cc, channel?) to a pattern.
* When queried, the pattern will produces the most recently received midi value (normalized to 0 to 1)
* that came through that cc number (and channel, if provided)
* @example
* const cc = await midin('IAC Driver Bus 1')
* note("c a f e").lpf(cc(0).range(0, 1000)).lpq(cc(1).range(0, 10)).sound("sawtooth")
* @example
* const allCC = await midin('IAC Driver Bus 1')
* const cc = (ccNum) => allCC(ccNum, 2) // just channel 2
* note("c a f e").s("saw")
* .when(cc(0).gt(0), x => x.postgain(0))
*/
let listeners = {};
const refs = {};
const refsByChan = {};
export async function midin(input) {
const device = await _initialize(input);
refs[input] ??= {};
refsByChan[input] ??= {};
const cc = (cc, chan) => {
if (chan !== undefined) {
return ref(() => refsByChan[input][cc]?.[chan] || 0);
}
return ref(() => refs[input][cc] || 0);
};
listeners[input] && device.removeListener('midimessage', listeners[input]);
listeners[input] = (e) => {
const cc = e.dataBytes[0];
const v = e.dataBytes[1];
refs[input] && (refs[input][cc] = v / 127);
const [ccNum, v] = e.dataBytes;
const chan = e.message.channel;
const scaled = v / 127;
refsByChan[input][ccNum] ??= {};
refsByChan[input][ccNum][chan] = scaled;
refs[input][ccNum] = scaled;
};
device.addListener('midimessage', listeners[input]);
return cc;
}
/**
* MIDI keyboard: Opens a MIDI input port to receive MIDI keyboard messages.
*
* The note length is fixed as Superdough is not currently set up for undetermined
* note durations
*
* @name midikeys
* @param {string | number} input MIDI device name or index defaulting to 0
* @returns {function((number | Pattern)=): Pattern} A function that produces a pattern.
* When queried, the pattern will produces the most recently played midi notes and velocities,
* lasting for the specified duration
* @example
* const kb = await midikeys('Arturia KeyStep 32')
* kb().s("tri").lpf(80).lpe(6).lpd(0.1).room(2).delay(0.35)
* @example
* const kb = await midikeys('Arturia KeyStep 32')
* kb("0.5 1")
* .s("saw")
* .add(note(rand.mul(0.3)))
* .lpf(1000).lpe(2).room(0.5)
*/
const kHaps = {};
const kListeners = {};
function _triggerKeyboard(input, cps, now, latencyCycles) {
const pattern = getPattern();
const trigger = getTriggerFunc();
if (!pattern || !trigger) {
return false;
}
const t = now + latencyCycles;
const eps = 1e-6;
const haps = pattern.queryArc(t - eps, t + eps, { _cps: cps });
// Only keep haps coming from `midikeys`
const kbHaps = haps.filter((hap) => hap.value?.midikey?.startsWith(`${input}_`));
const ctxNow = getAudioContext().currentTime;
if (!kbHaps.length) {
return false;
}
kbHaps.forEach((hap) => {
if (!hap.hasOnset()) {
return;
}
const t = ctxNow + (hap.whole.begin - now) / cps;
const duration = hap.duration / cps;
trigger(hap, t - ctxNow, duration, cps, t);
});
return true;
}
export async function midikeys(input) {
const device = await _initialize(input);
if (!kHaps[input]) {
kHaps[input] = [];
}
kListeners[input] && device.removeListener('midimessage', kListeners[input]);
kListeners[input] = (e) => {
const { dataBytes, message } = e;
const noteon = message.command === 9;
let noteoff = message.command === 8;
// Don't enqueue or trigger midi notes if scheduler is not started
const notStarted = !getIsStarted();
// Ignore non-note messages (e.g. CC, pitchbend, modwheel, etc.)
const notANote = !noteon && !noteoff;
if (notStarted || notANote) {
return;
}
const [note, velocity] = dataBytes;
noteoff ||= noteon && velocity === 0; // handle devices which may use velocity = 0 to signal noteoff
const key = `${input}_${note}`;
const cps = getCps() ?? 0.5;
const triggerAvailable = !!(getPattern() && getTriggerFunc());
const latencySeconds = triggerAvailable ? 0.01 : 0.06; // avoid missing notes due to cyclist / trigger latency
const now = getTime();
const t = now + latencySeconds * cps;
const span = new TimeSpan(t, t);
let value = { midikey: key };
if (noteoff) {
/* TODO: It's a big effort, but we could modify superdough to allow for situations where
we don't know the hap duration in advance. This would mean, for example, that if the hap
is flagged as such a special note-on event, we have all effects be persistent & all ADSR
envelopes stop at the S stage [and store references to them by `midikey`]
If this is implemented, then getting full keyboard functionality should be as simple
as sending the corresponding note-off event below and triggering `release` on each of those
referenced effects/envelopes
value = { ...value, noteoff: true };
If this is achieved, we can remove the noteLength parameter
*/
return;
} else {
value = { ...value, note: Math.round(note), velocity: velocity / 127 };
}
kHaps[input].push(new Hap(span, span, value, {}));
if (!noteoff && triggerAvailable) {
// If we have access to a trigger function, we call it to immediately
// dispatch to the audio engine, rather than waiting for cyclist to catch these haps
const triggered = _triggerKeyboard(input, cps, now, latencySeconds * cps);
if (triggered) {
kHaps[input] = [];
}
}
};
device.addListener('midimessage', kListeners[input]);
const kb = (noteLength = 0.5) => {
const nlPat = reify(noteLength);
const query = (state) => {
const haps = kHaps[input].flatMap((hap) => {
const lenHaps = nlPat.query(state.setSpan(hap.wholeOrPart()));
return lenHaps.map((lenHap) => {
const nl = lenHap.value ?? 0.5;
const whole = new TimeSpan(hap.whole.begin, hap.whole.begin.add(nl));
const part = new TimeSpan(hap.part.begin, hap.part.begin.add(nl));
const context = hap.combineContext(lenHap);
return new Hap(whole, part, hap.value, context);
});
});
if (state.controls.cyclist) {
// Notes have been sent; clear them
kHaps[input] = [];
}
return haps;
};
return new Pattern(query);
};
return kb;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel/midi",
"version": "1.2.6",
"version": "1.2.7",
"description": "Midi API for strudel",
"main": "index.mjs",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel/mini",
"version": "1.2.5",
"version": "1.2.6",
"description": "Mini notation for strudel",
"main": "index.mjs",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mondolang",
"version": "1.1.1",
"version": "1.1.2",
"description": "a language for functional composition that translates to js",
"main": "mondo.mjs",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel/mondo",
"version": "1.1.5",
"version": "1.1.6",
"description": "mondo notation for strudel",
"main": "mondough.mjs",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel/motion",
"version": "1.2.5",
"version": "1.2.6",
"description": "DeviceMotion API for strudel",
"main": "index.mjs",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel/mqtt",
"version": "1.2.5",
"version": "1.2.6",
"description": "MQTT API for strudel",
"main": "mqtt.mjs",
"type": "module",
+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
@@ -1,6 +1,6 @@
{
"name": "@strudel/osc",
"version": "1.3.0",
"version": "1.3.1",
"description": "OSC messaging for strudel",
"main": "osc.mjs",
"bin": "./server.js",
Executable → Regular
View File
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel/reference",
"version": "1.2.1",
"version": "1.2.2",
"description": "Headless reference of all strudel functions",
"main": "index.mjs",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel/repl",
"version": "1.2.7",
"version": "1.2.8",
"description": "Strudel REPL as a Web Component",
"module": "index.mjs",
"publishConfig": {
+2 -1
View File
@@ -1,11 +1,12 @@
import { silence } from '@strudel/core';
import { getDrawContext } from '@strudel/draw';
import { transpiler } from '@strudel/transpiler';
import { getAudioContext, webaudioOutput } from '@strudel/webaudio';
import { getAudioContext, webaudioOutput, initAudioOnFirstClick } from '@strudel/webaudio';
import { StrudelMirror, codemirrorSettings } from '@strudel/codemirror';
import { prebake } from './prebake.mjs';
if (typeof HTMLElement !== 'undefined') {
initAudioOnFirstClick();
class StrudelRepl extends HTMLElement {
static observedAttributes = ['code'];
settings = codemirrorSettings.get();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel/sampler",
"version": "0.2.3",
"version": "0.2.4",
"description": "",
"keywords": [
"tidalcycles",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel/serial",
"version": "1.2.5",
"version": "1.2.6",
"description": "Webserial API for strudel",
"main": "serial.mjs",
"type": "module",
+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') {
+8 -7
View File
@@ -6,6 +6,8 @@ import {
getADSRValues,
getPitchEnvelope,
getVibratoOscillator,
onceEnded,
releaseAudioNode,
} from '@strudel/webaudio';
import gm from './gm.mjs';
@@ -164,19 +166,18 @@ 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);
bufferSource.stop(envEnd);
const stop = (releaseTime) => {};
bufferSource.onended = () => {
bufferSource.disconnect();
vibratoOscillator?.stop();
node.disconnect();
onceEnded(bufferSource, () => {
releaseAudioNode(bufferSource);
vibratoHandle?.stop();
onended();
};
return { node, stop };
});
return { node, stop, nodes: { source: [bufferSource], ...vibratoHandle?.nodes } };
},
{ type: 'soundfont', prebake: true, fonts },
);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel/soundfonts",
"version": "1.2.6",
"version": "1.2.7",
"description": "Soundsfont support for strudel",
"main": "index.mjs",
"publishConfig": {
+14
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 = () => {
@@ -5,6 +14,11 @@ export const setDefaultAudioContext = () => {
return audioContext;
};
export const setAudioContext = (context) => {
audioContext = context;
return audioContext;
};
export const getAudioContext = () => {
if (!audioContext) {
return setDefaultAudioContext();
+10 -11
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) {
@@ -25,7 +24,7 @@ if (typeof DelayNode !== 'undefined') {
}
}
AudioContext.prototype.createFeedbackDelay = function (wet, time, feedback) {
BaseAudioContext.prototype.createFeedbackDelay = function (wet, time, feedback) {
return new FeedbackDelayNode(this, wet, time, feedback);
};
}
+206 -109
View File
@@ -1,7 +1,8 @@
import { getAudioContext } from './audioContext.mjs';
import { clamp, nanFallback, midiToFreq, noteToMidi } from './util.mjs';
import { getNoiseBuffer } from './noise.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,10 @@ export function getCompressor(ac, threshold, ratio, knee, attack, release) {
attack: attack ?? 0.005,
release: release ?? 0.05,
};
return new DynamicsCompressorNode(ac, options);
Object.entries(options).forEach(([key, value]) => {
node[key].value = value;
});
return node;
}
// changes the default values of the envelope based on what parameters the user has defined
@@ -162,7 +184,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,
@@ -189,7 +213,7 @@ export function applyParameterModulators(audioContext, param, start, end, envelo
getParamADSR(param, attack, decay, sustain, release, min, max, start, holdEnd, curve);
}
const lfo = getParamLfo(audioContext, param, start, end, lfoValues);
return { lfo, disconnect: () => lfo?.disconnect() };
return lfo;
}
export function createFilter(context, start, end, params, cps, cycle) {
let {
@@ -214,10 +238,12 @@ 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;
Object.entries({ Q: q, frequency }).forEach(([key, value]) => {
filter[key].value = value;
});
frequencyParam = filter.frequency;
}
const envelopeValues = [params.attack, params.decay, params.sustain, params.release];
@@ -240,6 +266,7 @@ export function createFilter(context, start, end, params, cps, cycle) {
rate = cps * sync;
}
const hasLFO = [depth, depthfrequency, skew, shape, rate].some((v) => v !== undefined);
let lfo;
if (hasLFO) {
depth = depth ?? 1;
const time = cycle / cps;
@@ -255,10 +282,10 @@ export function createFilter(context, start, end, params, cps, cycle) {
time,
curve: 1,
};
getParamLfo(context, frequencyParam, start, end, lfoValues);
lfo = getParamLfo(context, frequencyParam, start, end, lfoValues);
}
return filter;
return { filter, lfo };
}
// stays 1 until .5, then fades out
@@ -282,9 +309,12 @@ export function drywet(dry, wet, wetAmount = 0) {
wet_gain.connect(mix);
return {
node: mix,
onended: () => {
dry_gain.disconnect(mix);
wet_gain.disconnect(mix);
teardown: () => {
releaseAudioNode(dry_gain);
releaseAudioNode(wet_gain);
// it is not the responsability of drywet
// to call `releaseAudioNode` on
// the 2 external args dry and wet
dry.disconnect(dry_gain);
wet.disconnect(wet_gain);
},
@@ -323,12 +353,12 @@ export function getVibratoOscillator(param, value, t) {
gain.gain.value = vibmod * 100;
vibratoOscillator.connect(gain);
gain.connect(param);
vibratoOscillator.onended = () => {
gain.disconnect(param);
vibratoOscillator.disconnect(gain);
};
onceEnded(vibratoOscillator, () => {
releaseAudioNode(gain);
releaseAudioNode(vibratoOscillator);
});
vibratoOscillator.start(t);
return vibratoOscillator;
return { stop: (t) => vibratoOscillator.stop(t), nodes: { vib: [vibratoOscillator], vib_gain: [gain] } };
}
}
@@ -348,25 +378,17 @@ export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) {
constantNode.connect(zeroGain);
// Schedule the `onComplete` callback to occur at `stopTime`
constantNode.onended = () => {
// Ensure garbage collection
try {
zeroGain.disconnect();
} catch {
// pass
}
try {
constantNode.disconnect();
} catch {
// pass
}
onceEnded(constantNode, () => {
releaseAudioNode(zeroGain);
releaseAudioNode(constantNode);
onComplete();
};
});
constantNode.start(startTime);
constantNode.stop(stopTime);
return constantNode;
}
const mod = (freq, range = 1, type = 'sine') => {
const mod = (freq, type = 'sine') => {
const ctx = getAudioContext();
let osc;
if (noises.includes(type)) {
@@ -378,69 +400,94 @@ const mod = (freq, range = 1, type = 'sine') => {
osc.type = type;
osc.frequency.value = freq;
}
osc.start();
const g = gainNode(range);
osc.connect(g); // -range, range
return { node: g, stop: (t) => osc.stop(t), osc: osc };
return osc;
};
const fm = (frequencyparam, harmonicityRatio, modulationIndex, wave = 'sine') => {
const fm = (frequencyparam, harmonicityRatio, wave = 'sine') => {
const carrfreq = frequencyparam.value;
const modfreq = carrfreq * harmonicityRatio;
const modgain = modfreq * modulationIndex;
return mod(modfreq, modgain, wave);
return { osc: mod(modfreq, wave), freq: modfreq };
};
export function applyFM(param, value, begin) {
const {
fmh: fmHarmonicity = 1,
fmi: fmModulationIndex,
fmenv: fmEnvelopeType = 'exp',
fmattack: fmAttack,
fmdecay: fmDecay,
fmsustain: fmSustain,
fmrelease: fmRelease,
fmvelocity: fmVelocity,
fmwave: fmWaveform = 'sine',
duration,
} = value;
let modulator;
let stop = () => {};
if (fmModulationIndex) {
const ac = getAudioContext();
const envGain = ac.createGain();
const fmmod = fm(param, fmHarmonicity, fmModulationIndex, fmWaveform);
modulator = fmmod.node;
stop = fmmod.stop;
if (![fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity].some((v) => v !== undefined)) {
// no envelope by default
modulator.connect(param);
} else {
const [attack, decay, sustain, release] = getADSRValues([fmAttack, fmDecay, fmSustain, fmRelease]);
const holdEnd = begin + duration;
getParamADSR(
envGain.gain,
attack,
decay,
sustain,
release,
0,
1,
begin,
holdEnd,
fmEnvelopeType === 'exp' ? 'exponential' : 'linear',
);
modulator.connect(envGain);
envGain.connect(param);
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++) {
let control;
if (i === j + 1) {
// Standard fm3 -> fm2 -> fm1 -> param usage
const iS = i === 1 ? '' : i;
control = `fmi${iS}`;
} else {
control = `fmi${i}${j}`;
}
const amt = value[control];
if (!amt) continue;
let io = [];
for (let [isMod, idx] of [
[true, i], // source
[false, j], // target
]) {
if (idx === 0) {
io.push(param);
continue;
}
if (!fms[idx]) {
const idxS = idx === 1 ? '' : idx;
const { osc, freq } = fm(param, value[`fmh${idxS}`] ?? 1, value[`fmwave${idxS}`] ?? 'sine');
toStop.push(osc);
const toCleanup = [osc]; // nodes we want to cleanup after oscillator `stop`
const adsr = ['attack', 'decay', 'sustain', 'release'].map((s) => value[`fm${s}${idxS}`]);
let output = osc;
if (adsr.some((v) => v !== undefined)) {
const envGain = ac.createGain();
const [attack, decay, sustain, release] = getADSRValues(adsr);
const holdEnd = begin + value.duration;
const fmEnvelopeType = value[`fmenv${idxS}`] ?? 'exp';
getParamADSR(
envGain.gain,
attack,
decay,
sustain,
release,
0,
1,
begin,
holdEnd,
fmEnvelopeType === 'exp' ? 'exponential' : 'linear',
);
toCleanup.push(envGain);
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 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(
`[superdough] control ${control} failed to connect FM ${i} to target ${j} due to missing frequency parameter (likely because fm${j} is noise)`,
'warning',
);
continue;
}
io[0].connect(io[1]);
}
fmmod.osc.onended = () => {
envGain.disconnect();
modulator.disconnect();
fmmod.osc.disconnect();
};
}
return { stop };
return {
nodes,
stop: (t) => toStop.forEach((m) => m?.stop(t)),
};
}
// Saturation curves
@@ -553,10 +600,60 @@ export const getFrequencyFromValue = (value, defaultNote = 36) => {
return Number(freq);
};
export const destroyAudioWorkletNode = (node) => {
if (node == null) {
return;
}
node.disconnect();
node.parameters.get('end')?.setValueAtTime(0, 0);
// This helper should be used instead of the `node.onended = callback` pattern
// It adds a mechanism to help minimize gc retention
export const onceEnded = (node, callback) => {
const onended = callback;
node.onended = function cleanup() {
onended && onended();
this.onended = null;
};
};
export const releaseAudioNode = (node) => {
if (node == null) return;
// check we received an AudioNode
if (!(node instanceof AudioNode)) {
throw new Error('releaseAudioNode can only release an AudioNode');
}
// https://developer.mozilla.org/en-US/docs/Web/API/AudioNode/disconnect
node.disconnect();
// make sure all AudioScheduledSourceNodes are in a stopped state
// https://developer.mozilla.org/en-US/docs/Web/API/AudioScheduledSourceNode
if (node instanceof AudioScheduledSourceNode) {
if (process.env.NODE_ENV === 'development' && node.onended && node.onended.name !== 'cleanup') {
logger(
`[superdough] Deprecation warning: it seems your code path is setting 'node.onended = callback' instead of using the onceEnded helper`,
);
}
try {
node.stop();
} catch (e) {
// At the stage, `start` was not called on the node
// but an `onended` callback releasing resources may exist
// and we want it to fire :
// - we force a start/stop cycle so that `onended` gets called
// - we `lock` the node so that no-one can start it
node.start(node.context.currentTime + 5); // will never happen
node.stop();
}
}
// https://www.w3.org/TR/webaudio-1.1/#AudioNode-actively-processing
// An AudioWorkletNode is actively processing when its AudioWorkletProcessor's [[callable process]]
// returns true and either its active source flag is true or
// any AudioNode connected to one of its inputs is actively processing.
if (node instanceof AudioWorkletNode) {
// while `end` is not native to the web audio API, it is common practice in superdough
// to use that param in the worklets to trigger returning false from the processor
node.parameters.get('end')?.setValueAtTime(0, 0);
}
};
// Once the `anchor` node has ended, release all nodes in `toCleanup`
export const cleanupOnEnd = (anchor, toCleanup) => {
onceEnded(anchor, () => toCleanup.forEach((n) => releaseAudioNode(n)));
};
+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 };
};
+84
View File
@@ -0,0 +1,84 @@
/*
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');
export const isPoolable = (node) => !!node[POOL_KEY];
const getNodeTime = (node) => {
return node.context?.currentTime ?? 0;
};
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;
}
const key = node[POOL_KEY];
if (key == null) return;
const now = getNodeTime(node);
getParams(node).forEach((param) => param.cancelScheduledValues(now));
const pool = nodePools.get(key) ?? [];
pool.push(new WeakRef(node));
nodePools.set(key, pool);
};
// Audio worklets are given a grace period to survive (`return true`) after
// being released. This concludes at time `end + 0.5`. We test here whether we are
// within some safe distance of that (`end + 0.45`) and if so, permit the node to be
// released. This helps to prevent race conditions between node termination and node
// re-use
const isNodeAlive = (node) => {
// Skip check if node is not a worklet
if (!(node instanceof AudioWorkletNode)) return true;
const now = getNodeTime(node);
const end = node?.parameters?.get('end').value ?? 0;
return now < end + 0.45;
};
// 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;
let found = false;
while (pool.length) {
const ref = pool.pop();
node = ref?.deref();
if (node != null && isNodeAlive(node)) {
found = true;
break;
}
}
if (!found) {
node = factory();
}
node[POOL_KEY] = key;
return node;
};
+5 -2
View File
@@ -1,4 +1,4 @@
import { drywet } from './helpers.mjs';
import { drywet, onceEnded, releaseAudioNode } from './helpers.mjs';
import { getAudioContext } from './audioContext.mjs';
let noiseCache = {};
@@ -65,9 +65,12 @@ export function getNoiseOscillator(type = 'white', t, density = 0.02) {
export function getNoiseMix(inputNode, wet, t) {
const noiseOscillator = getNoiseOscillator('pink', t);
const noiseMix = drywet(inputNode, noiseOscillator.node, wet);
noiseOscillator.node.onended = () => noiseMix.onended();
onceEnded(noiseOscillator.node, () => {
releaseAudioNode(noiseOscillator.node);
});
return {
node: noiseMix.node,
stop: (time) => noiseOscillator?.stop(time),
teardown: noiseMix.teardown,
};
}
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "superdough",
"version": "1.2.6",
"version": "1.2.7",
"description": "simple web audio synth and sampler intended for live coding. inspired by superdirt and webdirt.",
"main": "index.mjs",
"type": "module",
@@ -36,6 +36,7 @@
"vite-plugin-bundle-audioworklet": "workspace:*"
},
"dependencies": {
"@kabelsalat/lib": "^0.4.1",
"nanostores": "^0.11.3"
},
"engines": {
+2 -2
View File
@@ -2,7 +2,7 @@ import reverbGen from './reverbGen.mjs';
import { clamp } from './util.mjs';
if (typeof AudioContext !== 'undefined') {
AudioContext.prototype.adjustLength = function (duration, buffer, speed = 1, offsetAmount = 0) {
BaseAudioContext.prototype.adjustLength = function (duration, buffer, speed = 1, offsetAmount = 0) {
const sampleOffset = Math.floor(clamp(offsetAmount, 0, 1) * buffer.length);
const newLength = buffer.sampleRate * duration;
const newBuffer = this.createBuffer(buffer.numberOfChannels, buffer.length, buffer.sampleRate);
@@ -23,7 +23,7 @@ if (typeof AudioContext !== 'undefined') {
return newBuffer;
};
AudioContext.prototype.createReverb = function (duration, fade, lp, dim, ir, irspeed, irbegin) {
BaseAudioContext.prototype.createReverb = function (duration, fade, lp, dim, ir, irspeed, irbegin) {
const convolver = this.createConvolver();
convolver.generate = (d = 2, fade = 0.1, lp = 15000, dim = 1000, ir, irspeed, irbegin) => {
convolver.duration = d;
+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();
+34 -30
View File
@@ -1,7 +1,14 @@
import { getBaseURL, getCommonSampleInfo } from './util.mjs';
import { registerSound, registerWaveTable } from './index.mjs';
import { getAudioContext } from './audioContext.mjs';
import { getADSRValues, getParamADSR, getPitchEnvelope, getVibratoOscillator } from './helpers.mjs';
import {
getADSRValues,
getParamADSR,
getPitchEnvelope,
getVibratoOscillator,
onceEnded,
releaseAudioNode,
} from './helpers.mjs';
import { logger } from './logger.mjs';
const bufferCache = {}; // string: Promise<ArrayBuffer>
@@ -58,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) => {
@@ -236,12 +244,6 @@ export async function fetchSampleMap(url) {
* sd: '808sd/SD0010.WAV'
* }, 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/');
* s("[bd ~]*2, [~ hh]*2, ~ sd")
* @example
* samples('shabda:noise,chimp:2')
* s("noise <chimp:0*2 chimp:1>")
* @example
* samples('shabda/speech/fr-FR/f:chocolat')
* s("chocolat*4")
*/
export const samples = async (sampleMap, baseUrl = sampleMap._base || '', options = {}) => {
@@ -286,19 +288,21 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) {
const { bufferSource, sliceDuration, offset } = await getSampleBufferSource(value, bank, resolveUrl);
// asny stuff above took too long?
if (ac.currentTime > t) {
logger(`[sampler] still loading sound "${s}:${n}"`, 'highlight');
// console.warn('sample still loading:', s, n);
return;
}
if (!bufferSource) {
logger(`[sampler] could not load "${s}:${n}"`, 'error');
return;
}
// async stuff above took too long?
if (ac.currentTime > t) {
logger(`[sampler] loading sound "${s}:${n}" took too long`, 'highlight');
// AudioBufferSourceNode will never be used. discard it
releaseAudioNode(bufferSource);
return;
}
// vibrato
let vibratoOscillator = getVibratoOscillator(bufferSource.detune, value, t);
const vibratoHandle = getVibratoOscillator(bufferSource.detune, value, t);
const time = t + nudge;
bufferSource.start(time, offset);
@@ -319,19 +323,19 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) {
const out = ac.createGain(); // we need a separate gain for the cutgroups because firefox...
node.connect(out);
bufferSource.onended = function () {
bufferSource.disconnect();
vibratoOscillator?.stop();
node.disconnect();
out.disconnect();
onceEnded(bufferSource, function () {
releaseAudioNode(bufferSource);
vibratoHandle?.stop();
releaseAudioNode(node);
releaseAudioNode(out);
onended();
};
});
let envEnd = holdEnd + release + 0.01;
bufferSource.stop(envEnd);
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) {
+523 -237
View File
@@ -7,25 +7,50 @@ This program is free software: you can redistribute it and/or modify it under th
import './feedbackdelay.mjs';
import './reverb.mjs';
import './vowel.mjs';
import { nanFallback, _mod, cycleToSeconds, pickAndRename } from './util.mjs';
import { clamp, nanFallback, _mod, cycleToSeconds, pickAndRename } from './util.mjs';
import workletsUrl from './worklets.mjs?audioworklet';
import { createFilter, gainNode, getCompressor, getDistortion, getLfo, getWorklet, effectSend } from './helpers.mjs';
import { getNodeFromPool, isPoolable, releaseNodeToPool } from './nodePools.mjs';
import {
createFilter,
effectSend,
gainNode,
getCompressor,
getDistortion,
getFrequencyFromValue,
getLfo,
getWorklet,
releaseAudioNode,
webAudioTimeout,
} from './helpers.mjs';
import { map } from 'nanostores';
import { logger } from './logger.mjs';
import { connectLFO, connectEnvelope, connectBusModulator } from './modulators.mjs';
import { loadBuffer } from './sampler.mjs';
import { getAudioContext } from './audioContext.mjs';
import { SuperdoughAudioController } from './superdoughoutput.mjs';
import { resetSeenKeys } from './wavetable.mjs';
export const DEFAULT_MAX_POLYPHONY = 128;
const DEFAULT_AUDIO_DEVICE_NAME = 'System Standard';
let maxPolyphony = DEFAULT_MAX_POLYPHONY;
export let maxPolyphony = DEFAULT_MAX_POLYPHONY;
/**
* Set the max polyphony. If notes are ringing out via `release` then they will
* start to die out in first-in-first-out order once the max polyphony has been hit
*
* @name setMaxPolyphony
* @param {number} Max polyphony. Defaults to 128
* @example
* setMaxPolyphony(4)
* n(irand(24).seg(8)).scale("C#3:minor").room(1).release(4).gain(0.5)
*
*/
export function setMaxPolyphony(polyphony) {
maxPolyphony = parseInt(polyphony) ?? DEFAULT_MAX_POLYPHONY;
}
let multiChannelOrbits = false;
export let multiChannelOrbits = false;
export function setMultiChannelOrbits(bool) {
multiChannelOrbits = bool == true;
}
@@ -43,6 +68,17 @@ export function applyGainCurve(val) {
return gainCurveFunc(val);
}
/**
* Apply a function to all gains provided in patterns. Can be used to rescale gain to be
* quadratic, exponential, etc. rather than linear
*
* @name setGainCurve
* @param {Function} function to apply to all gain values
* @example
* setGainCurve((x) => x * x) // quadratic gain
* s("bd*4").gain(0.5) // equivalent to 0.25 gain normally
*
*/
export function setGainCurve(newGainCurveFunc) {
gainCurveFunc = newGainCurveFunc;
}
@@ -152,6 +188,7 @@ let defaultDefaultValues = {
distortvol: 1,
distorttype: 0,
delay: 0,
busgain: 1,
byteBeatExpression: '0',
delayfeedback: 0.5,
delaysync: 3 / 16,
@@ -159,6 +196,9 @@ let defaultDefaultValues = {
i: 1,
velocity: 1,
fft: 8,
tremolodepth: 1,
tremolophase: 0,
release: 0.01,
};
const defaultDefaultDefaultValues = Object.freeze({ ...defaultDefaultValues });
@@ -203,11 +243,13 @@ export function registerWorklet(url) {
}
let workletsLoading;
function loadWorklets() {
export function loadWorklets() {
if (!workletsLoading) {
const audioCtx = getAudioContext();
const allWorkletURLs = externalWorklets.concat([workletsUrl]);
workletsLoading = Promise.all(allWorkletURLs.map((workletURL) => audioCtx.audioWorklet.addModule(workletURL)));
workletsLoading = Promise.all(allWorkletURLs.map((workletURL) => audioCtx.audioWorklet.addModule(workletURL))).then(
() => (workletsLoading = undefined),
);
}
return workletsLoading;
@@ -224,6 +266,7 @@ export async function initAudio(options = {}) {
setMaxPolyphony(maxPolyphony);
setMultiChannelOrbits(multiChannelOrbits);
resetSeenKeys();
if (typeof window === 'undefined') {
return;
}
@@ -245,8 +288,9 @@ export async function initAudio(options = {}) {
logger('[superdough] failed to set audio interface', 'warning');
}
}
await audioCtx.resume();
if ((!audioCtx) instanceof OfflineAudioContext) {
await audioCtx.resume();
}
if (disableWorklets) {
logger('[superdough]: AudioWorklets disabled with disableWorklets');
return;
@@ -263,8 +307,8 @@ let audioReady;
export async function initAudioOnFirstClick(options) {
if (!audioReady) {
audioReady = new Promise((resolve) => {
document.addEventListener('click', async function listener() {
document.removeEventListener('click', listener);
document.addEventListener('mousedown', async function listener() {
document.removeEventListener('mousedown', listener);
await initAudio(options);
resolve();
});
@@ -274,40 +318,43 @@ export async function initAudioOnFirstClick(options) {
}
let controller;
function getSuperdoughAudioController() {
export function getSuperdoughAudioController() {
if (controller == null) {
controller = new SuperdoughAudioController(getAudioContext());
}
return controller;
}
export function setSuperdoughAudioController(newController) {
controller = newController;
return controller;
}
export function connectToDestination(input, channels) {
const controller = getSuperdoughAudioController();
controller.output.connectToDestination(input, channels);
}
function getPhaser(time, end, frequency = 1, depth = 0.5, centerFrequency = 1000, sweep = 2000) {
function getPhaser(begin, end, frequency = 1, depth = 0.5, centerFrequency = 1000, sweep = 2000) {
const ac = getAudioContext();
const lfoGain = getLfo(ac, time, end, { frequency, depth: sweep * 2 });
const lfo = getLfo(ac, { frequency, depth: sweep * 2, begin, end });
//filters
const numStages = 2; //num of filters in series
let fOffset = 0;
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;
filter.Q.value = 2 - Math.min(Math.max(depth * 2, 0), 1.9);
lfoGain.connect(filter.detune);
lfo.connect(filter.detune);
fOffset += 282;
if (i > 0) {
filterChain[i - 1].connect(filter);
}
filterChain.push(filter);
}
return filterChain[filterChain.length - 1];
return { filterChain, lfo };
}
function getFilterType(ftype) {
@@ -320,7 +367,7 @@ export let analysers = {},
analysersData = {};
export function getAnalyserById(id, fftSize = 1024, smoothingTimeConstant = 0.5) {
if (!analysers[id]) {
if (!analysers[id] || analysers[id].context != getAudioContext()) {
// make sure this doesn't happen too often as it piles up garbage
const analyserNode = getAudioContext().createAnalyser();
analyserNode.fftSize = fftSize;
@@ -361,7 +408,37 @@ function mapChannelNumbers(channels) {
return (Array.isArray(channels) ? channels : [channels]).map((ch) => ch - 1);
}
class Chain {
constructor() {
this.audioNodes = [];
this.tails = [];
}
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();
@@ -382,7 +459,6 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
// duration is passed as value too..
value.duration = hapDuration;
// calculate absolute time
if (t < ac.currentTime) {
console.warn(
`[superdough]: cannot schedule sounds in the past (target: ${t.toFixed(2)}, now: ${ac.currentTime.toFixed(2)})`,
@@ -391,49 +467,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: phaser,
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,
@@ -443,14 +494,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,
FX = [],
FXrelease,
} = value;
delaytime = delaytime ?? cycleToSeconds(delaysync, cps);
@@ -465,17 +512,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
@@ -489,8 +532,6 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
activeSoundSources.delete(chainID);
}
let audioNodes = [];
if (['-', '~', '_'].includes(s)) {
return;
}
@@ -499,21 +540,33 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
value.s = s;
}
const chain = new Chain(); // connection manager which tracks audio nodes for releasing
// get source AudioNode
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) => n?.disconnect());
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?`);
@@ -528,176 +581,358 @@ 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 }));
// gain stage
chain.push(gainNode(gain));
chain.connect(sourceNode);
// filter
const ftype = getFilterType(value.ftype);
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,
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 (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';
let lp = () => createFilter(ac, t, end, lpParams, cps, cycle);
chain.push(lp());
if (ftype === '24db') {
chain.push(lp());
// Kabelsalat
if (fx.workletSrc !== undefined) {
const workletNode = getWorklet(ac, 'generic-processor', {}, { outputChannelCount: [2] });
chain.connect(workletNode);
const workletSrc = fx.workletSrc
.replace(/\bpat\[(\d+)\]/g, (_, i) => fx.workletInputs[i])
.replaceAll('sFreq', getFrequencyFromValue(value))
.replaceAll('sGate', `cc('strudel-gate-${chainID}')`);
/* global compileKabel */
const { src, ugens, registers } = compileKabel(workletSrc);
workletNode.port.postMessage({ src, schema: { ugens, registers }, start: t, gateEnd: end, end: endWithRelease });
}
if (fx.stretch !== undefined) {
const phaseVocoder = getWorklet(ac, 'phase-vocoder-processor', { pitchFactor: fx.stretch });
chain.connect(phaseVocoder);
fxNodes['stretch'] = [phaseVocoder];
}
if (fx.transient !== undefined) {
const transProcessor = getWorklet(
ac,
'transient-processor',
{},
{
processorOptions: {
attack: fx.transient,
sustain: fx.transsustain,
begin: t,
end: endWithRelease,
},
},
);
chain.connect(transProcessor);
fxNodes['transient'] = transProcessor;
}
// gain stage
const initialGain = gainNode(gain);
fxNodes['gain'] = [initialGain];
chain.connect(initialGain);
// filter
const ftype = getFilterType(value.ftype);
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';
let hp = () => createFilter(ac, t, end, hpParams, cps, cycle);
chain.push(hp());
if (ftype === '24db') {
chain.push(hp());
}
}
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';
let bp = () => createFilter(ac, t, end, bpParams, cps, cycle);
chain.push(bp());
if (ftype === '24db') {
chain.push(bp());
}
}
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 (phaser !== undefined && phaserdepth > 0) {
const phaserFX = getPhaser(t, endWithRelease, phaser, phaserdepth, phasercenter, phasersweep);
chain.push(phaserFX);
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) {
@@ -712,33 +947,84 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
}
roomIR = await loadBuffer(url, ac, ir, 0);
}
orbitBus.getReverb(roomsize, roomfade, roomlp, roomdim, roomIR, irspeed, irbegin);
const send = orbitBus.sendReverb(post, room);
audioNodes.push(send);
const roomNode = orbitBus.getReverb(roomsize, roomfade, roomlp, roomdim, roomIR, irspeed, irbegin);
nodes.main['room'] = [roomNode];
const reverbSend = orbitBus.sendReverb(post, room);
nodes.main['room_mix'] = [reverbSend];
chain.audioNodes.push(reverbSend);
}
if (bus != null) {
const busNode = audioController.getBus(bus);
const busSend = effectSend(post, busNode, busgain);
chain.audioNodes.push(busSend);
}
if (djf != null) {
orbitBus.getDjf(djf, t);
const djfNode = orbitBus.getDjf(djf, t);
nodes.main['djf'] = [djfNode];
}
// analyser
if (analyze) {
if (analyze && !(ac instanceof OfflineAudioContext)) {
const analyserNode = getAnalyserById(analyze, 2 ** (fft + 5));
const analyserSend = effectSend(post, analyserNode, 1);
audioNodes.push(analyserSend);
chain.audioNodes.push(analyserSend);
}
if (dry != null) {
dry = applyGainCurve(dry);
const dryGain = new GainNode(ac, { gain: dry });
chain.push(dryGain);
chain.connect(dryGain);
orbitBus.connectToOutput(dryGain);
} else {
orbitBus.connectToOutput(post);
}
// connect chain elements together
chain.slice(1).reduce((last, current) => last.connect(current), chain[0]);
audioNodes = audioNodes.concat(chain);
// finally, now that `nodes` is populated, set up modulators
FX.forEach((fx, idx) => {
const key = idx === FX.length - 1 ? 'main' : idx;
if (fx.lfo) {
for (const id of fx.lfo.__ids) {
const params = fx.lfo[id];
params.fxi ??= key;
const lfo = connectLFO(
id,
{
...params,
cps,
cycle,
begin: t,
end: endWithRelease,
},
nodes,
);
lfo && chain.audioNodes.push(lfo);
}
}
if (fx.env) {
for (const id of fx.env.__ids) {
const params = fx.env[id];
params.fxi ??= key;
const env = connectEnvelope(
id,
{
...params,
begin: t,
end: endWithRelease,
},
nodes,
);
env && chain.audioNodes.push(env);
}
}
if (fx.bmod) {
for (const id of fx.bmod.__ids) {
const params = fx.bmod[id];
params.fxi ??= key;
const { toCleanup } = connectBusModulator({ ...params, begin: t, end: endWithRelease }, nodes, controller);
chain.audioNodes.push(...toCleanup);
}
}
});
};
export const superdoughTrigger = (t, hap, ct, cps) => {
+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];
}
}
+114 -63
View File
@@ -1,9 +1,8 @@
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,
destroyAudioWorkletNode,
gainNode,
getADSRValues,
getFrequencyFromValue,
@@ -13,12 +12,15 @@ import {
getVibratoOscillator,
getWorklet,
noises,
onceEnded,
releaseAudioNode,
webAudioTimeout,
} from './helpers.mjs';
import { logger } from './logger.mjs';
import { getNoiseMix, getNoiseOscillator } from './noise.mjs';
import { getNodeFromPool, 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'],
@@ -51,17 +53,17 @@ export function registerSynthSounds() {
// turn down
const g = gainNode(0.3);
let sound = getOscillator(s, t, value, () => {
g.disconnect();
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;
@@ -69,6 +71,7 @@ export function registerSynthSounds() {
stop(envEnd);
return {
node,
nodes,
stop: (endTime) => {
stop(endTime);
},
@@ -110,15 +113,15 @@ export function registerSynthSounds() {
const mix = gainNode(mixGain);
o.onended = () => {
o.disconnect();
g.disconnect();
sat.disconnect();
noise.node.disconnect();
noiseGain.disconnect();
mix.disconnect();
onceEnded(o, () => {
releaseAudioNode(o);
releaseAudioNode(g);
releaseAudioNode(sat);
releaseAudioNode(noise.node);
releaseAudioNode(noiseGain);
releaseAudioNode(mix);
onended();
};
});
const node = o.connect(sat).connect(g).connect(mix);
noise.node.connect(noiseGain).connect(mix);
@@ -138,6 +141,7 @@ export function registerSynthSounds() {
return {
node,
nodes: { source: [o] },
stop: (endTime) => {
o.stop(endTime);
},
@@ -164,26 +168,26 @@ 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);
Object.entries(params).forEach(([key, value]) => {
const param = o.parameters.get(key);
const target = value !== undefined ? value : param.defaultValue;
param.value = target;
});
o.port.postMessage({ type: 'initialize' });
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);
@@ -192,11 +196,10 @@ export function registerSynthSounds() {
let timeoutNode = webAudioTimeout(
ac,
() => {
destroyAudioWorkletNode(o);
envGain.disconnect();
releaseNodeToPool(o);
onended();
fm?.stop();
vibratoOscillator?.stop();
fmHandle?.stop();
vibratoHandle?.stop();
},
begin,
end,
@@ -204,6 +207,7 @@ export function registerSynthSounds() {
return {
node: envGain,
nodes: { source: [o], ...fmHandle?.nodes, ...vibratoHandle?.nodes },
stop: (time) => {
timeoutNode.stop(time);
},
@@ -270,8 +274,7 @@ export function registerSynthSounds() {
let timeoutNode = webAudioTimeout(
ac,
() => {
destroyAudioWorkletNode(o);
envGain.disconnect();
releaseAudioNode(o);
onended();
},
begin,
@@ -280,6 +283,7 @@ export function registerSynthSounds() {
return {
node: envGain,
source: o,
stop: (time) => {
timeoutNode.stop(time);
},
@@ -330,26 +334,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,
() => {
destroyAudioWorkletNode(o);
destroyAudioWorkletNode(lfo);
envGain.disconnect();
releaseAudioNode(o);
releaseAudioNode(pw_lfo);
onended();
fm?.stop();
vibratoOscillator?.stop();
fmHandle?.stop();
vibratoHandle?.stop();
},
begin,
end,
@@ -357,6 +360,7 @@ export function registerSynthSounds() {
return {
node: envGain,
nodes: { source: [o], pw_lfo: [pw_lfo], ...fmHandle?.nodes, ...vibratoHandle?.nodes },
stop: (time) => {
timeoutNode.stop(time);
},
@@ -365,6 +369,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,
@@ -387,11 +426,11 @@ export function registerSynthSounds() {
const { duration } = value;
o.onended = () => {
o.disconnect();
g.disconnect();
onceEnded(o, () => {
releaseAudioNode(o);
releaseAudioNode(g);
onended();
};
});
const envGain = gainNode(1);
let node = o.connect(g).connect(envGain);
@@ -402,6 +441,7 @@ export function registerSynthSounds() {
stop(envEnd);
return {
node,
nodes: { source: [o] },
stop: (endTime) => {
stop(endTime);
},
@@ -469,8 +509,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';
}
@@ -481,29 +530,31 @@ 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) {
noiseMix = getNoiseMix(o, noise, t);
}
o.onended = () => {
o.disconnect();
noiseMix?.node.disconnect();
onceEnded(o, () => {
noiseMix?.teardown();
releaseAudioNode(o);
releaseAudioNode(noiseMix?.node);
onended();
};
});
o.start(t);
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);
},
+23 -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] },
@@ -45,7 +47,9 @@ if (typeof GainNode !== 'undefined') {
throw new Error('vowel: unknown vowel ' + letter);
}
const { gains, qs, freqs } = vowelFormant[letter];
const makeupGain = ac.createGain();
this.makeupGain = ac.createGain();
this.filters = [];
this.gains = [];
for (let i = 0; i < 5; i++) {
const gain = ac.createGain();
gain.gain.value = gains[i];
@@ -53,17 +57,30 @@ if (typeof GainNode !== 'undefined') {
filter.type = 'bandpass';
filter.Q.value = qs[i];
filter.frequency.value = freqs[i];
this.connect(filter);
super.connect(filter);
filter.connect(gain);
gain.connect(makeupGain);
this.filters.push(filter);
gain.connect(this.makeupGain);
this.gains.push(gain);
}
makeupGain.gain.value = 8; // how much makeup gain to add?
this.connect = (target) => makeupGain.connect(target);
this.makeupGain.gain.value = 8; // how much makeup gain to add?
return this;
}
connect(target) {
this.makeupGain.connect(target);
}
disconnect() {
releaseAudioNode(this.makeupGain);
this.filters.forEach(releaseAudioNode);
this.gains.forEach(releaseAudioNode);
super.disconnect();
this.makeupGain = null;
this.filters = null;
this.gains = null;
}
}
AudioContext.prototype.createVowelFilter = function (letter) {
BaseAudioContext.prototype.createVowelFilter = function (letter) {
return new VowelNode(this, letter);
};
}
+44 -29
View File
@@ -3,15 +3,15 @@ import { getBaseURL, getCommonSampleInfo } from './util.mjs';
import {
applyFM,
applyParameterModulators,
destroyAudioWorkletNode,
getADSRValues,
getFrequencyFromValue,
getParamADSR,
getPitchEnvelope,
getVibratoOscillator,
getWorklet,
webAudioTimeout,
releaseAudioNode,
} from './helpers.mjs';
import { getNodeFromPool, releaseNodeToPool } from './nodePools.mjs';
import { logger } from './logger.mjs';
export const Warpmode = Object.freeze({
@@ -40,6 +40,11 @@ export const Warpmode = Object.freeze({
});
const seenKeys = new Set();
export function resetSeenKeys() {
seenKeys.clear();
}
async function getPayload(url, label, frameLen = 2048) {
const key = `${url},${frameLen}`;
if (!seenKeys.has(key)) {
@@ -225,24 +230,26 @@ 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);
Object.entries(params).forEach(([key, value]) => {
const param = source.parameters.get(key);
const target = value !== undefined ? value : param.defaultValue;
param.value = target;
});
source.port.postMessage({ type: 'initialize', payload });
if (ac.currentTime > t) {
logger(`[wavetable] still loading sound "${s}:${n}"`, 'highlight');
return;
@@ -309,22 +316,30 @@ 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,
() => {
destroyAudioWorkletNode(source);
vibratoOscillator?.stop();
fm?.stop();
node.disconnect();
wtPosModulators?.disconnect();
wtWarpModulators?.disconnect();
releaseNodeToPool(source);
vibratoHandle?.stop();
fmHandle?.stop();
releaseAudioNode(wtPosModulators);
releaseAudioNode(wtWarpModulators);
onended();
},
t,
+240 -63
View File
@@ -5,12 +5,18 @@
import OLAProcessor from './ola-processor';
import FFT from './fft.js';
import { getDistortionAlgorithm } from './helpers.mjs';
import * as ugens from '@kabelsalat/lib/src/ugens.js';
const UGENS = new Map(Object.entries(ugens));
const blockSize = 128;
const PI = Math.PI;
const TWO_PI = 2 * PI;
const INVSR = 1 / sampleRate;
const timeToCoeff = (t) => 1 - Math.exp(-INVSR / t);
const dbToLin = (db) => Math.pow(10, db / 20);
const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
const mod = (n, m) => ((n % m) + m) % m;
const lerp = (a, b, n) => n * (b - a) + a;
@@ -121,8 +127,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 },
];
}
@@ -140,7 +146,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) {
@@ -158,6 +165,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]];
@@ -458,22 +466,31 @@ registerProcessor('distort-processor', DistortProcessor);
class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.port.onmessage = (e) => {
const { type, payload } = e.data || {};
if (type === 'initialize') {
this.initialize(payload);
}
};
this.initialize();
}
initialize(_options) {
this.phase = [];
}
static get parameterDescriptors() {
return [
{
name: 'begin',
defaultValue: 0,
defaultValue: -1,
max: Number.POSITIVE_INFINITY,
min: 0,
min: -1,
},
{
name: 'end',
defaultValue: 0,
defaultValue: -1,
max: Number.POSITIVE_INFINITY,
min: 0,
min: -1,
},
{
@@ -508,12 +525,18 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
];
}
process(_input, outputs, params) {
if (currentTime <= params.begin[0]) {
return true;
}
if (currentTime >= params.end[0]) {
// this.port.postMessage({ type: 'onended' });
const begin = params.begin[0];
const end = params.end[0];
const beginDefined = begin >= 0;
const endDefined = end >= 0;
// We give a 0.5s grace period (for node pooling) before termination
const shouldTerminate = endDefined && currentTime >= end + 0.5;
const ended = endDefined && currentTime >= end;
const notStarted = currentTime <= begin;
if (shouldTerminate) {
return false;
} else if (ended || notStarted || !beginDefined) {
return true;
}
const output = outputs[0];
const voices = params.voices[0]; // k-rate
@@ -963,7 +986,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 },
];
}
@@ -1005,9 +1030,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
@@ -1025,7 +1056,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 },
@@ -1039,7 +1072,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;
}
@@ -1118,8 +1151,8 @@ const tablesCache = {};
class WavetableOscillatorProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [
{ name: 'begin', defaultValue: 0, min: 0, max: Number.POSITIVE_INFINITY },
{ name: 'end', defaultValue: 0, min: 0, max: Number.POSITIVE_INFINITY },
{ name: 'begin', defaultValue: -1, min: -1, max: Number.POSITIVE_INFINITY },
{ name: 'end', defaultValue: -1, min: -1, max: Number.POSITIVE_INFINITY },
{ name: 'frequency', defaultValue: 440, min: Number.EPSILON },
{ name: 'detune', defaultValue: 0 },
{ name: 'freqspread', defaultValue: 0.18, min: 0 },
@@ -1134,37 +1167,28 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
constructor(options) {
super(options);
this.frameLen = 0;
this.numFrames = 0;
this.phase = [];
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) {
@@ -1309,25 +1333,23 @@ 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]) {
const begin = parameters.begin[0];
const end = parameters.end[0];
const beginDefined = begin >= 0;
const endDefined = end >= 0;
// We give a 0.5s grace period (for node pooling) before termination
const shouldTerminate = endDefined && currentTime >= end + 0.5;
const ended = endDefined && currentTime >= end;
const notStarted = currentTime <= begin;
if (shouldTerminate) {
return false;
}
if (currentTime <= parameters.begin[0]) {
} else if (ended || notStarted || !beginDefined) {
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;
@@ -1361,14 +1383,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;
@@ -1383,3 +1403,160 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
}
registerProcessor('wavetable-oscillator-processor', WavetableOscillatorProcessor);
class TransientProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [];
}
constructor(options) {
super();
this.gainCoeff = timeToCoeff(0.2);
this.avgGain = 1;
let {
attackTime = 0.003,
sustainTime = 0.08,
attack = 0,
sustain = 0,
sensitivity = 0.1,
mix = 1,
begin = 0,
end = 0,
} = options.processorOptions;
attackTime = clamp(attackTime, 0.0005, 0.05);
sustainTime = clamp(sustainTime, 0.01, 0.5);
this.attackCoeff = timeToCoeff(attackTime);
this.sustainCoeff = timeToCoeff(sustainTime);
this.attackAmt = clamp(attack, -1, 1);
this.sustainAmt = clamp(sustain, -1, 1);
this.scaling = 0.5 + 5 * clamp(sensitivity, 0, 1);
this.mix = clamp(mix, 0, 1);
this.begin = begin;
this.end = end;
this.attackEnv = new Float32Array(2); // assume stereo
this.sustainEnv = new Float32Array(2);
}
process(inputs, outputs, _params) {
const input = inputs[0];
const output = outputs[0];
if (currentTime >= this.end) {
return false;
}
if (currentTime <= this.begin) {
return true;
}
const channels = input.length;
if (channels > this.attackEnv.length) {
this.attackEnv = new Float32Array(channels);
this.sustainEnv = new Float32Array(channels);
}
let avgGain = this.avgGain;
for (let ch = 0; ch < channels; ch++) {
let attEnv = this.attackEnv[ch];
let susEnv = this.sustainEnv[ch];
for (let n = 0; n < blockSize; n++) {
const sample = input[ch][n];
const x = Math.abs(sample);
attEnv = lerp(attEnv, x, this.attackCoeff);
susEnv = lerp(susEnv, x, this.sustainCoeff);
const peakiness = clamp((this.scaling * (attEnv - susEnv)) / (susEnv + 1e-6), -1.5, 1.5);
const attScale = peakiness > 0 ? peakiness : 0;
const susScale = peakiness < 0 ? -peakiness : 0;
const attackGain = dbToLin(this.attackAmt * attScale * 18);
const sustainGain = dbToLin(this.sustainAmt * susScale * 36);
const gain = clamp(attackGain * sustainGain, 0, 8);
avgGain = lerp(avgGain, gain, this.gainCoeff);
const makeup = avgGain > 1e-3 ? 1 / avgGain : 1;
const wet = sample * gain * makeup;
let y = lerp(sample, wet, this.mix);
y /= 1 + Math.abs(y); // soft clip
output[ch][n] = y;
}
this.attackEnv[ch] = attEnv;
this.sustainEnv[ch] = susEnv;
}
this.avgGain = avgGain;
return true;
}
}
registerProcessor('transient-processor', TransientProcessor);
class GenericProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.playPos = 0;
const channels = 16;
this.outputs = new Array(channels).fill(0);
this.sources = new Array(channels).fill(0);
this.gateEnded = false;
this.started = false;
this.port.onmessage = (event) => {
let {
src,
schema: { ugens, registers },
start,
gateEnd,
end,
} = event.data;
this.start = start;
this.gateEnd = gateEnd;
this.end = end;
this.registers = new Array(registers).fill(0);
this.src = `o.fill(0); // reset outputs\n${src}`;
this.nodes = [];
for (let i = 0; i < ugens.length; i++) {
const ugen = ugens[i];
const nodeClass = UGENS.get(ugen.type);
const node = new nodeClass(i, ugen, sampleRate);
if (node.type === 'cc' && ugen.inputs?.[0]?.includes('strudel-gate')) {
node.setValue(1);
this.gateNode = node;
}
this.nodes[i] = node;
}
this.genSample = new Function(
'time',
'nodes',
'input',
'r', // registers
'o', // outputs
's', // sources
this.src,
);
};
}
process(inputs, outputs) {
const input = inputs[0]?.[0];
if (currentTime >= this.end) {
return false;
} else if (this.genSample === undefined || currentTime < this.start) {
// pending
return true;
}
this.started = true;
if (!this.gateEnded && currentTime > this.gateEnd) {
this.gateNode?.setValue(0);
this.gateEnded = true;
}
const output = outputs[0];
const outL = output[0];
const outR = output[1];
for (let n = 0; n < blockSize; n++) {
this.genSample(this.playPos, this.nodes, input ? input[n] : 0, this.registers, this.outputs, this.sources);
const left = this.outputs[0];
const right = this.outputs[1];
// Spread to stereo if possible; else mixdown to mono
if (outR) {
outL[n] = left;
outR[n] = right;
} else {
outL[n] = 0.5 * (left + right);
}
this.playPos += 1 / sampleRate;
}
return true;
}
}
registerProcessor('generic-processor', GenericProcessor);
+5 -3
View File
@@ -3,6 +3,7 @@ import { midiToFreq, noteToMidi } from './util.mjs';
import { registerSound } from './superdough.mjs';
import { getAudioContext } from './audioContext.mjs';
import { buildSamples } from './zzfx_fork.mjs';
import { onceEnded, releaseAudioNode } from './helpers.mjs';
export const getZZFX = (value, t) => {
let {
@@ -83,12 +84,13 @@ export function registerZZFXSounds() {
wave,
(t, value, onended) => {
const { node: o } = getZZFX({ s: wave, ...value }, t);
o.onended = () => {
o.disconnect();
onceEnded(o, () => {
releaseAudioNode(o);
onended();
};
});
return {
node: o,
nodes: { source: [o] },
stop: () => {},
};
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "supradough",
"version": "1.2.4",
"version": "1.2.5",
"description": "platform agnostic synth and sampler intended for live coding. a reimplementation of superdough.",
"main": "index.mjs",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel/tonal",
"version": "1.2.5",
"version": "1.2.6",
"description": "Tonal functions for strudel",
"main": "index.mjs",
"publishConfig": {
+11 -2
View File
@@ -5,7 +5,16 @@ This program is free software: you can redistribute it and/or modify it under th
*/
import { Note, Interval, Scale } from '@tonaljs/tonal';
import { register, _mod, logger, isNote, noteToMidi, removeUndefineds, getAccidentalsOffset } from '@strudel/core';
import {
_mod,
errorLogger,
getAccidentalsOffset,
isNote,
logger,
noteToMidi,
register,
removeUndefineds,
} from '@strudel/core';
import { stepInNamedScale, nearestNumberIndex } from './tonleiter.mjs';
const octavesInterval = (octaves) => (octaves <= 0 ? -1 : 1) + octaves * 7 + 'P';
@@ -294,7 +303,7 @@ export const scale = register(
}
if (offset != 0) scaleNote = Note.transpose(scaleNote, Interval.fromSemitones(offset));
} catch (err) {
logger(`[tonal] ${err.message}`, 'error');
errorLogger(err, 'tonal');
return; // will be removed
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel/transpiler",
"version": "1.2.5",
"version": "1.2.6",
"description": "Transpiler for strudel user code. Converts syntactically correct but semantically meaningless JS into evaluatable strudel code.",
"main": "index.mjs",
"type": "module",
@@ -26,6 +26,20 @@ describe('transpiler', () => {
it('adds await to bare samples call', () => {
expect(transpiler("samples('xxx');", simple).output).toEqual("await samples('xxx');");
});
it('handles mini strings in K(...)', () => {
expect(transpiler('K("bd sd")', simple).output).toEqual("worklet('pat[0]', m('bd sd', 2));");
});
it('treats K(...) as kabelsalat', () => {
expect(transpiler('K(1+2)', simple).output).toEqual("worklet('1 + 2');");
});
it('automatically calls functions in K(...)', () => {
expect(transpiler('K(() => { return 1 + 2 })', simple).output).toEqual(
"worklet('(() => {\\n return 1 + 2\\n})()');",
);
});
it('handles strudel S(...) inside kabelsalat K(...)', () => {
expect(transpiler('K(S("bd".fast(4)))', simple).output).toEqual("worklet('pat[0]', m('bd', 4).fast(4));");
});
/* it('parses dynamic imports', () => {
expect(
transpiler("const { default: foo } = await import('https://bar.com/foo.js');", {
@@ -42,4 +56,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);
});
});
+256 -1
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);
@@ -110,7 +119,64 @@ export function transpiler(input, options = {}) {
return this.replace(labelToP(node));
}
},
leave(node, parent, prop, index) {},
leave(node, parent, prop, index) {
if (!isKabelCall(node)) return;
let [expr, ...rest] = node.arguments;
if (!expr) throw new Error('K(...) requires an expression');
if (shouldCallKabelExpression(expr)) {
expr = {
type: 'CallExpression',
callee: expr,
arguments: [],
optional: false,
};
}
const { template, patternExprs } = extractPatternPlaceholders(expr);
if (patternExprs.length) {
const workletArgs = [{ type: 'Literal', value: template }, ...patternExprs, ...rest];
let callee = node.callee;
if (callee.type === 'ChainExpression') callee = callee.expression;
if (callee.type === 'MemberExpression') {
return this.replace({
type: 'CallExpression',
callee: workletMemberAst(callee.object),
arguments: workletArgs,
optional: false,
});
}
return this.replace({
type: 'CallExpression',
callee: { type: 'Identifier', name: 'worklet' },
arguments: workletArgs,
optional: false,
});
}
const kabelSrc = genExprSource(expr);
const workletArgs = [{ type: 'Literal', value: kabelSrc }, ...rest];
let callee = node.callee;
if (callee.type === 'ChainExpression') callee = callee.expression;
if (callee.type === 'MemberExpression') {
return this.replace({
type: 'CallExpression',
callee: workletMemberAst(callee.object),
arguments: workletArgs,
optional: false,
});
}
return this.replace({
type: 'CallExpression',
callee: { type: 'Identifier', name: 'worklet' },
arguments: workletArgs,
optional: false,
});
},
});
let { body } = ast;
@@ -146,6 +212,166 @@ export function transpiler(input, options = {}) {
return { output, miniLocations, widgets };
}
function isKabelCall(node) {
if (node.type !== 'CallExpression') return false;
let callee = node.callee;
if (callee.type === 'ChainExpression') callee = callee.expression;
if (callee.type === 'MemberExpression') return !callee.computed && callee.property?.name === 'K';
return callee.type === 'Identifier' && callee.name === 'K';
}
function shouldCallKabelExpression(expr) {
if (expr.type !== 'ArrowFunctionExpression' && expr.type !== 'FunctionExpression') {
return false;
}
if (expr.params.length) {
return false;
}
return expr.body?.type === 'BlockStatement';
}
function genExprSource(expr) {
return escodegen.generate(expr, { format: { semicolons: false } });
}
function extractPatternPlaceholders(expr) {
const templateExpr = cloneNode(expr);
const parents = new Map();
const targets = [];
walk(templateExpr, {
enter(node, parent, prop, index) {
parents.set(node, { parent, prop, index });
const patternExpr = getStrudelPatternExpr(node);
if (patternExpr) {
targets.push({ node, patternExpr });
this.skip();
}
},
});
if (!targets.length) {
return { template: genExprSource(templateExpr), patternExprs: [] };
}
targets.sort((a, b) => getPatternNodeOrder(a.node) - getPatternNodeOrder(b.node));
const patternExprs = targets.map(({ patternExpr }) => cloneNode(patternExpr));
let currentExpr = templateExpr;
targets.forEach(({ node }, index) => {
currentExpr = replaceNode(node, placeholderAst(index), parents, currentExpr);
});
const template = genExprSource(currentExpr);
return { template, patternExprs };
}
function getStrudelPatternExpr(node) {
if (isStrudelPatternWrap(node)) {
const arg = node.arguments?.[0];
if (!arg) {
throw new Error('S(...) requires an argument');
}
return arg;
}
if (isMiniCall(node)) {
return node;
}
return null;
}
function isStrudelPatternWrap(node) {
if (node.type !== 'CallExpression') {
return false;
}
const callee = node.callee;
if (callee.type === 'Identifier') {
return callee.name === 'S';
}
if (callee.type === 'MemberExpression' && !callee.computed) {
return callee.property?.name === 'S';
}
return false;
}
function getMinilangName() {
const minilang = languages.get('minilang');
return minilang?.name || 'm';
}
// Used to identify transpiled `m(...)` calls for proper conversion
// to, say, kabelsalat placeholders
function isMiniCall(node) {
if (node.type !== 'CallExpression') {
return false;
}
const callee = node.callee;
if (callee.type !== 'Identifier') {
return false;
}
if (callee.name !== getMinilangName()) {
return false;
}
const firstArg = node.arguments?.[0];
return firstArg?.type === 'Literal' && typeof firstArg.value === 'string';
}
// If `start` is available, we use it. If it's already been transpiled
// to `m(...)`, use the provided offset
function getPatternNodeOrder(node) {
if (typeof node.start === 'number') {
return node.start;
}
if (isMiniCall(node)) {
const offsetArg = node.arguments?.[1];
if (offsetArg?.type === 'Literal' && typeof offsetArg.value === 'number') {
return offsetArg.value;
}
}
return 0;
}
function placeholderAst(index) {
return {
type: 'MemberExpression',
object: { type: 'Identifier', name: 'pat' },
property: { type: 'Literal', value: index },
computed: true,
optional: false,
};
}
function replaceNode(node, replacement, parents, currentRoot) {
const info = parents.get(node);
if (!info || !info.parent) {
return replacement;
}
const { parent, prop, index } = info;
if (Array.isArray(parent[prop])) {
parent[prop][index] = replacement;
} else {
parent[prop] = replacement;
}
parents.set(replacement, { parent, prop, index });
return currentRoot;
}
function cloneNode(node) {
return JSON.parse(JSON.stringify(node));
}
function workletMemberAst(objectExpr) {
return {
type: 'MemberExpression',
object: objectExpr,
property: { type: 'Identifier', name: 'worklet' },
computed: false,
optional: false,
};
}
function isStringWithDoubleQuotes(node, locations, code) {
if (node.type !== 'Literal') {
return false;
@@ -327,3 +553,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;
}
@@ -1,7 +1,7 @@
{
"name": "vite-plugin-bundle-audioworklet",
"main": "./vite-plugin-bundle-audioworklet.js",
"version": "0.1.1",
"version": "0.1.2",
"description": "",
"keywords": [
"vite",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel/web",
"version": "1.2.6",
"version": "1.2.7",
"description": "Easy to setup, opiniated bundle of Strudel for the browser.",
"module": "web.mjs",
"publishConfig": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel/webaudio",
"version": "1.2.6",
"version": "1.2.7",
"description": "Web Audio helpers for Strudel",
"main": "index.mjs",
"type": "module",
+176 -3
View File
@@ -5,10 +5,21 @@ This program is free software: you can redistribute it and/or modify it under th
*/
import * as strudel from '@strudel/core';
import { superdough, getAudioContext, setLogger, doughTrigger, registerWorklet } from 'superdough';
import {
superdough,
getAudioContext,
setLogger,
doughTrigger,
registerWorklet,
setAudioContext,
initAudio,
setSuperdoughAudioController,
resetGlobalEffects,
errorLogger,
} from 'superdough';
import './supradough.mjs';
import { workletUrl } from 'supradough';
import { SuperdoughAudioController } from 'superdough/superdoughoutput.mjs';
registerWorklet(workletUrl);
const { Pattern, logger, repl } = strudel;
@@ -26,9 +37,76 @@ export const webaudioOutput = (hap, _deadline, hapDuration, cps, t) => {
return superdough(hap2value(hap), t, hapDuration, cps, hap.whole?.begin.valueOf());
};
export async function renderPatternAudio(
pattern,
cps,
begin,
end,
sampleRate,
maxPolyphony,
multiChannelOrbits,
downloadName = undefined,
) {
let audioContext = getAudioContext();
await audioContext.close();
audioContext = new OfflineAudioContext(2, ((end - begin) / cps) * sampleRate, sampleRate);
setAudioContext(audioContext);
setSuperdoughAudioController(new SuperdoughAudioController(audioContext));
await initAudio({
maxPolyphony,
multiChannelOrbits,
});
logger('[webaudio] preloading');
// Calling superdough(...) in ascending onset time order is important
// for controls that depend on the audio graph state like `cut`
let haps = pattern
.queryArc(begin, end, { _cps: cps })
.sort((a, b) => a.whole.begin.valueOf() - b.whole.begin.valueOf());
for (const hap of haps) {
if (hap.hasOnset()) {
try {
await superdough(
hap2value(hap),
(hap.whole.begin.valueOf() - begin) / cps,
hap.duration / cps,
cps,
(hap.whole?.begin.valueOf() - begin) / cps,
);
} catch (err) {
errorLogger(err, 'webaudio');
}
}
}
logger('[webaudio] start rendering');
return audioContext
.startRendering()
.then((renderedBuffer) => {
const wavBuffer = audioBufferToWav(renderedBuffer);
const blob = new Blob([wavBuffer], { type: 'audio/wav' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
downloadName = downloadName ? `${downloadName}.wav` : `${new Date().toISOString()}.wav`;
a.download = `${downloadName}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
})
.finally(async () => {
setAudioContext(null);
setSuperdoughAudioController(null);
resetGlobalEffects();
});
}
export function webaudioRepl(options = {}) {
const audioContext = options.audioContext ?? getAudioContext();
setAudioContext(audioContext);
options = {
getTime: () => getAudioContext().currentTime,
getTime: () => audioContext.currentTime,
defaultOutput: webaudioOutput,
...options,
};
@@ -38,3 +116,98 @@ export function webaudioRepl(options = {}) {
Pattern.prototype.dough = function () {
return this.onTrigger(doughTrigger, 1);
};
function audioBufferToWav(buffer, opt) {
opt = opt || {};
var numChannels = buffer.numberOfChannels;
var sampleRate = buffer.sampleRate;
var format = opt.float32 ? 3 : 1;
var bitDepth = format === 3 ? 32 : 16;
var result;
if (numChannels === 2) {
result = interleave(buffer.getChannelData(0), buffer.getChannelData(1));
} else {
result = buffer.getChannelData(0);
}
return encodeWAV(result, format, sampleRate, numChannels, bitDepth);
}
function encodeWAV(samples, format, sampleRate, numChannels, bitDepth) {
var bytesPerSample = bitDepth / 8;
var blockAlign = numChannels * bytesPerSample;
var buffer = new ArrayBuffer(44 + samples.length * bytesPerSample);
var view = new DataView(buffer);
/* RIFF identifier */
writeString(view, 0, 'RIFF');
/* RIFF chunk length */
view.setUint32(4, 36 + samples.length * bytesPerSample, true);
/* RIFF type */
writeString(view, 8, 'WAVE');
/* format chunk identifier */
writeString(view, 12, 'fmt ');
/* format chunk length */
view.setUint32(16, 16, true);
/* sample format (raw) */
view.setUint16(20, format, true);
/* channel count */
view.setUint16(22, numChannels, true);
/* sample rate */
view.setUint32(24, sampleRate, true);
/* byte rate (sample rate * block align) */
view.setUint32(28, sampleRate * blockAlign, true);
/* block align (channel count * bytes per sample) */
view.setUint16(32, blockAlign, true);
/* bits per sample */
view.setUint16(34, bitDepth, true);
/* data chunk identifier */
writeString(view, 36, 'data');
/* data chunk length */
view.setUint32(40, samples.length * bytesPerSample, true);
if (format === 1) {
// Raw PCM
floatTo16BitPCM(view, 44, samples);
} else {
writeFloat32(view, 44, samples);
}
return buffer;
}
function interleave(inputL, inputR) {
var length = inputL.length + inputR.length;
var result = new Float32Array(length);
var index = 0;
var inputIndex = 0;
while (index < length) {
result[index++] = inputL[inputIndex];
result[index++] = inputR[inputIndex];
inputIndex++;
}
return result;
}
function writeFloat32(output, offset, input) {
for (var i = 0; i < input.length; i++, offset += 4) {
output.setFloat32(offset, input[i], true);
}
}
function floatTo16BitPCM(output, offset, input) {
for (var i = 0; i < input.length; i++, offset += 2) {
var s = Math.max(-1, Math.min(1, input[i]));
output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
}
}
function writeString(view, offset, string) {
for (var i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel/xen",
"version": "1.2.5",
"version": "1.2.6",
"description": "Xenharmonic API for strudel",
"main": "index.mjs",
"type": "module",
+28 -2
View File
@@ -41,7 +41,7 @@ importers:
version: 2.2.7
'@vitest/coverage-v8':
specifier: 3.0.4
version: 3.0.4(vitest@3.0.4(@types/debug@4.1.12)(@types/node@22.10.10)(@vitest/ui@3.0.4)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.7.0))
version: 3.0.4(vitest@3.0.4)
'@vitest/ui':
specifier: ^3.0.4
version: 3.0.4(vitest@3.0.4)
@@ -234,6 +234,9 @@ importers:
packages/core:
dependencies:
'@kabelsalat/web':
specifier: ^0.4.1
version: 0.4.1
fraction.js:
specifier: ^5.2.1
version: 5.2.1
@@ -518,6 +521,9 @@ importers:
packages/superdough:
dependencies:
'@kabelsalat/lib':
specifier: ^0.4.1
version: 0.4.1
nanostores:
specifier: ^0.11.3
version: 0.11.3
@@ -1969,6 +1975,15 @@ packages:
resolution: {integrity: sha512-yYxMVH7Dqw6nO0d5NIV8OQWnitU8k6vXH8NtgqAfIa/IUqRMxRv/NUJJ08VEKbAakwxlgBl5PJdrU0dMPStsnw==}
engines: {node: '>=v12.0.0'}
'@kabelsalat/core@0.4.0':
resolution: {integrity: sha512-5zV8nh8HffW8aexObXs5pE0xgL0jb1cHc3o8UN6AvK0mBt87fjZvW65E4rMslHyq+OuCscoBJW7B3MbYfWrwMQ==}
'@kabelsalat/lib@0.4.1':
resolution: {integrity: sha512-gBCjrZKD9huTKNJuBC6BXM4PMQSg8otL8A/vp8j98P9v6yWTX1TuyaqdLg/1PrIAMV4hlWoav9ZD3YMFlrvouw==}
'@kabelsalat/web@0.4.1':
resolution: {integrity: sha512-ASkFhePJLx3GjadYOueI7sHKXBbRPk/a1pfslFeQNI9gU7yZq/KrnkmOmLrgrtixAsy7gyi1vWqkmRRvH3Ki6w==}
'@lerna/create@8.1.9':
resolution: {integrity: sha512-DPnl5lPX4v49eVxEbJnAizrpMdMTBz1qykZrAbBul9rfgk531v8oAt+Pm6O/rpAleRombNM7FJb5rYGzBJatOQ==}
engines: {node: '>=18.0.0'}
@@ -9261,6 +9276,17 @@ snapshots:
dependencies:
lodash: 4.17.21
'@kabelsalat/core@0.4.0': {}
'@kabelsalat/lib@0.4.1':
dependencies:
'@kabelsalat/core': 0.4.0
'@kabelsalat/web@0.4.1':
dependencies:
'@kabelsalat/core': 0.4.0
'@kabelsalat/lib': 0.4.1
'@lerna/create@8.1.9(encoding@0.1.13)(typescript@5.7.3)':
dependencies:
'@npmcli/arborist': 7.5.4
@@ -10554,7 +10580,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitest/coverage-v8@3.0.4(vitest@3.0.4(@types/debug@4.1.12)(@types/node@22.10.10)(@vitest/ui@3.0.4)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.7.0))':
'@vitest/coverage-v8@3.0.4(vitest@3.0.4)':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
File diff suppressed because it is too large Load Diff
+1
View File
@@ -20,6 +20,7 @@ const skippedExamples = [
'accelerationX',
'defaultmidimap',
'midimaps',
'bmod',
];
describe('runs examples', () => {
+5
View File
@@ -131,6 +131,10 @@ const midin = () => {
return (ccNum) => strudel.ref(() => 0); // returns ref with default value 0
};
const midikeys = async () => {
return () => strudel.silence;
};
const sysex = ([id, data]) => {};
// TODO: refactor to evalScope
@@ -150,6 +154,7 @@ evalScope(
*/
{
midin,
midikeys,
sysex,
// gist,
// euclid,
+1
View File
@@ -1,4 +1,5 @@
{
"name": "dbpatch",
"dependencies": {
"csv": "^6.3.11"
},
+1
View File
@@ -16,5 +16,6 @@ export default defineConfig({
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress}.config.*',
'**/shared.test.mjs',
],
setupFiles: './vitest.setup.mjs',
},
});
+7
View File
@@ -0,0 +1,7 @@
import { afterEach } from 'vitest';
import { useRNG } from './packages/core/signal.mjs';
afterEach(() => {
// Avoid bleed between tests
useRNG('legacy');
});
+30
View File
@@ -0,0 +1,30 @@
// this script loads all merged PRs within the given page range
// it can be used to update the CHANGELOG.md file in a semi-automated way
// the problem: codeberg doesn't support loading merged PRs, so we have to filter them in memory
// luckily, we can sort after "recentupdate", which means we can do incremental changelog generation
// todo: support setting a "last_updated" date, so the script would automatically check how far it has to go
async function main() {
let pageStart = 1;
let pageEnd = 1;
let prs = [];
for (let p = pageStart; p <= pageEnd; p++) {
console.log(`load page ${p}/${pageEnd}`);
const res = await fetch(
`https://codeberg.org/api/v1/repos/uzu/strudel/pulls?state=closed&sort=recentupdate&page=${p}`,
);
const pulls = await res.json();
const merged = pulls.filter((pull) => pull.merged);
prs = prs.concat(merged);
}
const output = prs
.sort((a, b) => new Date(b.closed_at) - new Date(a.closed_at))
.map(
(pull) => `- ${pull.closed_at} ${pull.title} by @${pull.user.login || '?'} in: [#${pull.number}](${pull.url}) `,
)
.join('\n');
console.log('-------------');
console.log(output);
}
main();
+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)
+2 -2
View File
@@ -10,8 +10,8 @@ import bundleAudioWorkletPlugin from 'vite-plugin-bundle-audioworklet';
import tailwind from '@astrojs/tailwind';
import AstroPWA from '@vite-pwa/astro';
const site = `https://strudel.cc/`; // root url without a path
const base = '/'; // base path of the strudel site
const site = process.env.STRUDEL_SITE || `https://strudel.cc/`; // root url without a path
const base = process.env.STRUDEL_BASE || '/'; // base path of the strudel site
const baseNoTrailing = base.endsWith('/') ? base.slice(0, -1) : base;
// this rehype plugin fixes relative links
+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;
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL
<base href={BASE_URL} />
<!-- Scrollable a11y code helper -->
<script {`${baseNoTrailing}/make-scrollable-code-focusable.js`} is:inline></script>
<script src={`${baseNoTrailing}/make-scrollable-code-focusable.js`} is:inline></script>
<script src="/src/pwa.ts"></script>
<!-- this does not work for some reason: -->
+1 -1
View File
@@ -24,7 +24,7 @@ const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL
<base href={BASE_URL} />
<!-- Scrollable a11y code helper -->
<script src{`${baseNoTrailing}/make-scrollable-code-focusable.js`} is:inline></script>
<script src={`${baseNoTrailing}/make-scrollable-code-focusable.js`} is:inline></script>
<script src="/src/pwa.ts"></script>
<!-- this does not work for some reason: -->
+1
View File
@@ -76,6 +76,7 @@ export const SIDEBAR: Sidebar = {
{ text: 'MIDI & OSC', link: 'learn/input-output' },
],
More: [
{ text: 'FAQ', link: 'learn/faq' },
{ text: 'Recipes', link: 'recipes/recipes' },
{ text: 'Mini-Notation', link: 'learn/mini-notation' },
{ text: 'Visual Feedback', link: 'learn/visual-feedback' },
+1 -1
View File
@@ -1,4 +1,4 @@
const ALLOW_MANY = ['by', 'url', 'genre', 'license'];
const ALLOW_MANY = ['by', 'url', 'genre', 'license', 'tag'];
export function getMetadata(raw_code) {
if (raw_code == null) {
+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>
+339
View File
@@ -0,0 +1,339 @@
---
title: Frequently Asked Questions
layout: ../../layouts/MainLayout.astro
---
import { MiniRepl } from '../../docs/MiniRepl';
import { JsDoc } from '../../docs/JsDoc';
# Frequently Asked Questions
This page contains frequently asked questions, with answers. Usually, the topic is explained in more detail in a section which is linked in the answer.
## Is Strudel/Tidal free?
Yes - there is no charge, this is a collective open source project, and the music you make with it is your own. However if you can, please make a one-off or regular donation to our [opencollective fund](https://opencollective.com/tidalcycles), that supports the software and cultural development of Strudel and other Uzu languages.
While there is no charge there are some caveats, e.g.:
- the source code must stay free, i.e. you cannot distribute strudel or tidal as part of projects with incompatible licenses - see the [license](https://www.gnu.org/licenses/agpl-3.0.en.html) for details.
- the contributed examples and tracks are also separately licensed, and must not e.g. be used to train AI models without permission.
## How do I try out the latest features?
The main, stable strudel website is [strudel.cc](https://strudel.cc/). There is also [warm.strudel.cc](https://warm.strudel.cc), known as "warm strudel", which has the latest development features. You might find warm strudel has bug fixes and features that the main website doesn't, but it will often be less stable and probably not suitable for important performances.
Alternatively, you can run strudel locally to try out the latest features. You can find development-oriented [instructions for that here](https://codeberg.org/uzu/strudel/src/branch/main/CONTRIBUTING.md#project-setup).
You can see the [latest changes here](https://codeberg.org/uzu/strudel/pulls?q=&type=all&sort=recentupdate&state=closed&labels=&milestone=0&project=0&assignee=0&poster=0), as 'pull requests'.
## How to record or export audio?
Strudel is not a digital audio workstation and does not operate following the same principles shared by most traditional audio softwares. However, there are multiple ways to record the audio -- and video -- output of Strudel:
- Use the 'export' tab to render and download as an audio file.
- capture the raw stereo signal coming out of your web browser. You will need an external audio editor/DAW such as Reaper/Audacity/Ardour, etc.
- use the alternative SuperDirt audio engine. Read [this page](/learn/input-output/#oscsuperdirtstrudeldirt) to know more about it.
- capture the audio/video stream using a capture tool such as [OBS](https://obsproject.com/fr), which is designed for live streaming, but also works very well for recording.
- don't record anything and code it again in front of your friends.
## Can I use strudel with my IDE?
Yes you can. There are experimental modes, made by community members, for several IDEs such as:
- VS Code: [Strudel VS](https://marketplace.visualstudio.com/items?itemName=cmillsdev.strudelvs): an experimental mode for Microsoft VSCode. A revived version of [TidalStrudel](https://marketplace.visualstudio.com/items?itemName=roipoussiere.tidal-strudel), which is defunct.
- nvim: [strudel.nvim](https://github.com/gruvw/strudel.nvim)
## How can I use my own samples?
There are multiple ways to load your sample collection. Some methods are good for quick experimentation, some others are good to share your audio collection with other musicians:
- Import folders [from the interface](/learn/samples/#from-disk-via-import-sounds-folder). These are stored locally in your web browser, and not uploaded.
- Serve a folder of samples locally using the [strudel 'sampler' commandline tool](https://strudel.cc/learn/samples/#from-disk-via-strudelsampler). This can be most reliable method, but requires [nodejs](https://nodejs.org) to be installed.
- Host your sound library online on the web and [load them from an URL](/learn/samples/#loading-custom-samples)
## Can I use Strudel with AI/LLM tools?
You are free to do what you like with Strudel, within the terms of the free/open source AGPLv3 license.
However as a community we are interested in exploring human creativity. AI is _way_ over-hyped right now,
including by people with very shady motives. Many in the community are very wary of people training models
on their tunes that they've poured their love into. So please keep discussion and questions around AI and
LLMs to channels dedicated to the topic and be fully respectful of other people's work.
Furthermore, tools like ChatGPT generally give wrong answers. Please don't ask the community to fix those
answers for you, as generally they will be timewasting nonsense.
Human questions are always welcome!
## Where can I download loads of patterns to train my LLM?
You cannot, as there is no such place. For details regarding our stance towards AI/LLM, see [above](/learn/faq/#can-i-use-strudel-with-aillm-tools)
## How to run offline?
Strudel works offline just fine! There are multiple techniques for this, see [this explanation](learn/pwa/#using-strudel-offline).
## How to change tempo? How do I translate BPM to cpm?
Strudel works in cycles, rather than beats, but if you assume a certain number of beats per cycle, you can convert between them.
For example, if you have your tempo in beats per minute and use 4 beats per cycle (e.g. if your track is in 4/4ths) then you can do `setcpm(BPM/4)`
where BPM is your beats per minute.
If you have a different number of beats per bar or are using more or less beats per cycle (e.g. If you want to put only half a bar or
two bars into one cycle), adjust accordingly.
## Where can I see all the functions?
If you pop open the sidetab of strudel.cc (small white < on the right hand side), there is a tab "reference" which lists all the functions of strudel.
## Where can I see all the samples and synths?
If you pop open the sidetab of strudel.cc (small white < on the right hand side), there is a tab "sounds" which lists all the drum machines, samples and synths currently loaded.
## How do I use this exactly like a DAW?
Strudel has different design aims for a DAW, and so treating it like one will likely be frustrating. DAWs are geared towards
sequencing notes over time in predictable ways, whereas Strudel and similar Uzu languages are geared towards combining and
transforming patterns in ways that can be hard to predict.
If you want to emulate the functionality of a DAW in Strudel, you'll have to identify the operations
executed by the DAW (sequencing, repeating, applying filters and envelopes) and write code that is equivalent to these
operations. For example in Strudel, the 'arrange' and 'pick' methods are useful for sequencing patterns over time (see question on these later in this document).
You might still find that the typical DAW workflow is not really adapted to live coding because, despite
both being ways of making music on the computer, they are two very different tools. You could then adapt your way of proceeding
to the medium of code, which might mean leaving more place to serendipity and writing code that you don't predict the output of.
## Why doesn't everyone just use a DAW?
There is no easy answer to this question. Here are some thoughts:
- Live coding tools such as Strudel are excellent for improvising music and visuals using a computer. DAWs are valuable and robust companions for other activities such as producing, mastering and mixing audio, among other usages. Using a tool does not exclude from using any another tool, just build a toolbox.
- Live coding has developed over decades as a distinct creative practice. For example, live coding artists like to show their screens while playing in front of an audience. It is an essential part of what they do, of the way they share their activity with everybody.
- Code is a human language, it is made for other humans to read it. You can read the code and enjoy the music too. It has meaning, value, and there might even be something poetic/important about it! - Strudel is free and open source, you can inspect the code, reshape it, contribute to it if you can/want. It is not opaque and this matters for many people. There is no black box, no obscure abstractions, no business model, no user tracking or hidden features. We need open tools in the arts! - Live coders don't all shy away from using DAWs. Many use them all of the time, especially when it makes their life easier for... live coding!
- Code is an artistic material like any other. There is something valuable in the process of making music through code. More generally speaking, it is nice to tackle creative problems through the use of a programming language: creative thinking, building up your own solutions, DIY approach to music-making, unexpected outcome of algorithms, funny human errors, etc.
- There are pianos and trumpets in your DAW: why do people continue playing the piano or the trumpet? Think of live coding tools as instruments that you activate through the act of programming.
## How can I interface Strudel with my favorite music software? What can I do with it?
Strudel can send [MIDI and OSC](/learn/input-output/), which are protocols for communicating musical information.
Other music software (or hardware!) can then listen to these messages and process them according to its capabilities.
A simple example would be to send livecoded audio to a DAW like Ardour on different tracks and then use it to mix them.
You could also send the MIDI of a sequenced pattern to Musescore and then have it transcribe your livecoded work as a musical score.
You could also send MIDI to your hardware synths, if you like their sound.
## How do I use this in my closed source webgame or other software?
You don't. You need to license your game to a free/open source license fulfill the [AGPLv3 license](https://codeberg.org/uzu/strudel/src/branch/main/LICENSE) Strudel is distributed under.
## How to play different patterns simultaneously?
Using the $ operator, several patterns can be played at once:
<MiniRepl
client:idle
tune={`$: s("bd*4").bank("tr707")
$: s("- sd").bank("tr909")`}
/>
See also [stack](intro/#combining-patterns)
## Is it possible to mute a pattern?
With an additional underscore, a pattern can be muted.
<MiniRepl
client:idle
tune={`$: s("bd*4").bank("tr707")
_$: s("- sd").bank("tr909")`}
/>
See also [hush](/learn/conditional-modifiers/#hush)
## How can I arrange in Strudel using `mask`?
With mini-notation, using the `<>` and `!` operators, you can try something like
```
.mask("<0!24 1!40>")
```
It mutes a pattern for 24 cycles and plays it for 40. You would gain 64 cycles total, a multiple of 2/4/8 commonly used in western music.
If each cycle is a bar, as a starting point, you could write a mask like that for any pattern:
```
.mask("<0!16 0!16 0!16 0!16 0!16 0!10>")
```
It mutes it throughout.
For arranging, you could add the same mask to each part and replace some zeroes with ones in your different masks to make parts play.
If you use `.mask()` on different patterns mess up your counting, then patterns do not align anymore.
On the other hand, doing that on purpose is one of the things that could be considered a strength of tidalcycles and Strudel.
You can make things quite lively and more organic with a little (controlled) interference, according to your own taste.
And you are free to arrange in cycles like 3, 6 or 9 too.
To modify everything at once, you could try all and when, for example:
```
all(x=>x.when("<0!7 1>", x=>x.lpf(saw.range(200, 2000))))
```
This would lowpass filter sweep everything every 8 cycles.
## How can I arrange in Strudel using `arrange` or `pick`?
Take [Pachelbel's Canon in D](https://en.wikipedia.org/wiki/Pachelbel%27s_Canon#Analysis) as an example which has 4 voices (one cello and 3 violins) which have repeating patterns, as seen in the link above.
The following snipped defines the patterns as constants which can then be used for the different voices. `arrange` takes multiple arguments, which are each a number of cycles and a pattern which is played for the number of cycles, wrapped in `[]` If the pattern is shorter than the number, it is repeated.
<MiniRepl client:idle tune={`const cello = note(
"<[d3 a2 b2 f#2] [g2 d3 g2 a2]>")
.color("grey").sound("gm_tremolo_strings:3")
const violin_p1 = note(
"<[f#5 e5 d5 c#5] [b4 a4 b4 c#5]>")
.color("blue")
const violin_p2 = note(
"<[d5 c#5 b4 a4] [ g4 f#4 g4 f#4]>")
.color("green")
const violin_p3 = note(
"<[d4 f#4 a4 g4 f#4 d4 f#4 e4] [d4 b3 d4 a4 g4 b4 a4 g4]>")
.color("purple")
const violin_p4 = note(
"<[f#4 d4 e4 c#5 d5 f#5 a5 a4] [b4 g4 a4 f#4 d4 d5 [d5@3 c#5]@2]>")
.color("red")
cello$: arrange(
[2, silence],
[18,cello])
violin1$: arrange(
[4,silence],
[2,violin_p1], [2,violin_p2],
[2,violin_p3], [2,violin_p4],
[2,violin_p1], [2,violin_p2],
[2,violin_p3], [2,violin_p4]
).sound("gm_tremolo_strings:0")
violin2$: arrange(
[6,silence], [2,violin_p1],
[2,violin_p2], [2,violin_p3],
[2,violin_p4], [2,violin_p1],
[2,violin_p2], [2,violin_p3]
).sound("gm_tremolo_strings:1")
violin3$: arrange(
[8,silence],
[2,violin_p1], [2,violin_p2],
[2,violin_p3], [2,violin_p4],
[2,violin_p1], [2,violin_p2]
).sound("gm_tremolo_strings:2")
all(x => x.release(.2))
`} />
Alternatively, you can also put the different patterns for the violins into one single array (`const violins = [violin_p1, violin_p2, violin_p3, violin_p4]`) and use a pattern as an index to `pick` the nth element of that array. This replaces the voices defined above. Here you use `0@2` to specifiy that the first item (i.e. with index `0`) is played for `2` cycles.
`pick` has better highlighting than `arrange`:
<MiniRepl client:idle tune={`const cello = note(
"<[d3 a2 b2 f#2] [g2 d3 g2 a2]>")
.color("grey").sound("gm_tremolo_strings:3")
const violin_p1 = note(
"<[f#5 e5 d5 c#5] [b4 a4 b4 c#5]>")
.color("blue")
const violin_p2 = note(
"<[d5 c#5 b4 a4] [ g4 f#4 g4 f#4]>")
.color("green")
const violin_p3 = note(
"<[d4 f#4 a4 g4 f#4 d4 f#4 e4] [d4 b3 d4 a4 g4 b4 a4 g4]>")
.color("purple")
const violin_p4 = note(
"<[f#4 d4 e4 c#5 d5 f#5 a5 a4] [b4 g4 a4 f#4 d4 d5 [d5@3 c#5]@2]>")
.color("red")
const violins = [violin_p1, violin_p2, violin_p3, violin_p4]
cello$: "<~@2 0@18>".pick([cello])
violin1$: "<~@4 0@2 1@2 2@2 3@2 0@2 1@2 2@2 3@2>".pick(violins)
.sound("gm_tremolo_strings:0")
violin2$: "<~@6 0@2 1@2 2@2 3@2 0@2 1@2 2@2>".pick(violins)
.sound("gm_tremolo_strings:1")
violin3$: "<~@8 0@2 1@2 2@2 3@2 0@2 1@2 >".pick(violins)
.sound("gm_tremolo_strings:2")
all(x => x.release(.2))
`} />
The `pick` method also works with jsons which have named elements, which makes it easier to read, see the [here](/learn/conditional-modifiers/#pick). `pickRestart` restarts the pattern upon picking it which can make a difference if the duration of the pick indexes doesn't line up with the patterns which are picked - which is not the case here.
Try adding `.punchcard()` after the `release(.2)` for a visualization.
## I saw Switch Angel using functions which I cannot find in the reference (e.g. `trancegate`). How do I make it work?
Methods like `trancegate()`, `rlpf()` and `acidenv()` are currently not pattern methods which come natively with strudel.
They are part of a script/prebake for strudel which was written by Switch Angel and published [here](https://github.com/switchangel/strudel-scripts)
You can find the instructions how to use that script in the readme.md there.
## Is there difference between `n` and `note`?
They are not aliases of each other, in contrast to `s` and `sound`.
The method `note` is used to reference a certain note (either as its name, such as `c` or `b2` or the midi number `69`, for example `note("c3 e3 g3")`).
On the other hand, `n` is a way to reference the nth index of something. This something can be a scale (eg `n("0 2 4").scale("C:major")`) , but it can also be a particular note in a chord (see https://strudel.cc/recipes/recipes/#arpeggios for an example) .
The method `n` can also be used for something completely unrelated to notes, in particular the nth sample from a sample map `s("hh*8").bank("RolandTR909").n("0 1 2 3")`.
<MiniRepl
client:idle
tune={`
n("<[0 1 2 3@3 -@2] [3 2 1 0@3 -@2] >")
.scale("A:minor:pentatonic")
.s("gm_acoustic_guitar_steel").n("<0 1 2 3>/2")`}
/>
Note that `n` is not the only way that functions use indices, some take numbered patterns instead.
## Is there a cheat sheet for all symbols?
Yes!
```
' marks start and end of strings, is different from "
" marks start and end of single line patterns in mini notation, is different from '
` marks start and end of patterns with line breaks in mini notation, is different from '
[] used for patterns in mini notation, each item in it has the same length
<> used for patterns, alternates between items each cycle
{} historically used for polyrhythmic patterns. {a b c}%4 is the same as <a b c>*4.
@3 elongates the item by a factor of 3 (other numbers work too, even non-integer, but for numbers between 0 and 1 you need a leading zero like this: @0.5)
@ after an item: elongates the item once (multiple @ work too c @ @ is the same as c@3)
_ after an item: also elongates an item once (multiple _ work too c _ _ is the same as c@3), see below for a different usage.
. this divides equal parts of a pattern and is called a foot. Can be used instead of [] like this: "1 6 7 8 . 2 . 3 . 4" is the same as "[1 6 7 8] 2 3 4"
- silence
~ also silence
x not silence (for the use in struct, any non-silence symbol works there)
b decrease by one semitone, i.e. flat, works for steps of scales, note names (but not midi numbers) and chord names
s increase by one semitone, i.e. sharp, works for steps of scales, note names (but not midi numbers) but not chord names
# increase by one semitone, i.e. sharp works for steps of scales, note names (but not midi numbers) and chord names
# also used in mondo notation
*3 play the sample or pattern at thrice the speed, fast(3)
!3 play the sample or pattern three times
/2 play the sample or pattern at half speed, slow(2)
? play the pattern sometimes
| once per cycle, choose randomly a pattern of those separated by i.e. chooseCycles()
, play all items separated by it at the same time, i.e. stack()
: is used to separate multiple parameters, such as adsr(".1:.1:.5:.2"), this is is an operator which creates a list of these objects.
$: at the start of a line, defines a member of the stack. is the only stack name that should occur multiple names
_ before a stack name: mutes the stack, i.e. hush(), for example _$: s("bd"), see above for a different usage.
```
+5 -1
View File
@@ -16,10 +16,14 @@ It is also possible to pattern other things with Strudel, such as software and h
Strudel supports MIDI without any additional software (thanks to [webmidi](https://npmjs.com/package/webmidi)), just by adding methods to your pattern:
## midiin(inputName?)
## midin(inputName?)
<JsDoc client:idle name="midin" h={0} />
## midikeys(inputName?)
<JsDoc client:idle name="midikeys" h={0} />
## midi(outputName?,options?)
Either connect a midi device or use the IAC Driver (Mac) or Midi Through Port (Linux) for internal midi messages.
+37 -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,11 +48,14 @@ 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
- `@tag`: custom tag
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
@@ -60,8 +63,8 @@ Some of them accepts several values, using the comma or new line separator, or d
```js
/*
@by Sam Tagada
Jimmy
@by John Doe
Jane Doe
@genre pop, jazz
@url https://example.com
@url https://example.org
@@ -72,11 +75,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
@@ -90,3 +93,21 @@ If a tag doesn't accept a list, it can take multi-line values:
the sofa in the living room.
*/
```
# Searching meta-data in the online repl
Meta-data can be used in the search field of the patterns tab in the online repl.
For example to search for all patterns by a specific author use the search term
```
by: Ada L
```
or search for patterns with a specific genre like
```
genre: unicorns
```
Hint: If no meta-data property is provided in the search all patterns with a `@title`, `@by` or `@tag` matching the search term will be shown.
@@ -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.
+31 -29
View File
@@ -13,7 +13,7 @@ Let's take a closer look about how sounds are implemented in the webaudio output
All sounds are registered in the sound map, using the the `registerSound` function:
```ts
```js
function registerSound(
name: string, // The name of the sound that should be given to `s`, e.g. `mysaw`
// The function called by the scheduler to trigger the sound:
@@ -35,34 +35,36 @@ When `registerSound` is called, it registers `{ onTrigger, data }` under the giv
This might be a bit abstract, so here is a minimal example:
```js
registerSound(
'mysaw',
(time, value, onended) => {
let { freq } = value; // destructure control params
const ctx = getAudioContext();
// create oscillator
const o = new OscillatorNode(ctx, { type: 'sawtooth', frequency: Number(freq) });
o.start(time);
// add gain node to level down osc
const g = new GainNode(ctx, { gain: 0.3 });
// connect osc to gain
const node = o.connect(g);
// this function can be called from outside to stop the sound
const stop = (time) => o.stop(time);
// ended will be fired when stop has been fired
o.addEventListener('ended', () => {
o.disconnect();
g.disconnect();
onended();
});
return { node, stop };
},
{ type: 'synth' },
);
// use the sound
freq(220, 440, 330).s('mysaw');
```
<MiniRepl
client:idle
tune={`
registerSound(
'mysaw',
(time, value, onended) => {
let { freq } = value; // destructure control params
const ctx = getAudioContext();
// create oscillator
const o = new OscillatorNode(ctx, { type: 'sawtooth', frequency: Number(freq) });
o.start(time);
// add gain node to level down osc
const g = new GainNode(ctx, { gain: 0.3 });
// connect osc to gain
const node = o.connect(g);
// this function can be called from outside to stop the sound
const stop = (time) => o.stop(time);
// ended will be fired when stop has been fired
o.addEventListener('ended', () => {
o.disconnect();
g.disconnect();
onended();
});
return { node, stop };
},
{ type: 'synth' },
);
// use the sound
freq("220 440 330").s('mysaw');`}
/>
You can actually use this code in the [REPL](https://strudel.cc/) and it'll work.
After evaluating the code, you should see `mysaw` in listed in the sounds tab.

Some files were not shown because too many files have changed in this diff Show More