Compare commits

..

640 Commits

Author SHA1 Message Date
Jade (Rose) Rowland f610965f43 Publish
- @strudel/codemirror@1.3.0
 - @strudel/csound@1.3.0
 - @strudel/midi@1.3.0
 - @strudel/repl@1.3.0
 - @strudel/soundfonts@1.3.0
 - superdough@1.3.0
 - @strudel/web@1.3.0
 - @strudel/webaudio@1.3.0
2026-01-17 19:12:20 -08:00
Jade (Rose) Rowland 17ef84ed70 sp 2026-01-17 19:11:08 -08:00
Jade (Rose) Rowland f0cb96dcad patch 2026-01-17 19:02:25 -08:00
Jade (Rose) Rowland f4f04a7d57 Publish
- @strudel/codemirror@1.2.8
 - @strudel/csound@1.2.8
 - @strudel/midi@1.2.8
 - @strudel/osc@1.3.2
 - @strudel/repl@1.2.9
 - @strudel/soundfonts@1.2.8
 - superdough@1.2.5
 - supradough@1.2.4
 - @strudel/web@1.2.8
 - @strudel/webaudio@1.2.9
2026-01-17 19:01:10 -08:00
Jade (Rose) Rowland a3ac9646f5 inc webaudio 2026-01-17 18:54:39 -08:00
Jade (Rose) Rowland 12b89f9f88 rollback supradough 2026-01-17 16:12:51 -08:00
Jade (Rose) Rowland 166985a055 spr 2026-01-17 16:00:34 -08:00
Jade (Rose) Rowland 85e787214b rollback_supradough 2026-01-17 15:59:38 -08:00
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
Switch Angel AKA Jade Rose fb790de26c Merge branch 'main' into pre_eval 2025-11-26 08:05:05 +01:00
Aria ec6f6a1f1c Merge pull request 'Fix Sampler port trampling' (#1717) from Dayglo/strudel:fix-port-trampling into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1717
Reviewed-by: Aria <glossing@noreply.codeberg.org>
2025-11-26 07:39:27 +01:00
W-A-James a126bc9b86 Fix lint 2025-11-25 22:20:55 -05:00
Aria a5c7fcc136 Add back options code (will resolve in another PR) 2025-11-25 22:16:23 -05:00
Aria d70b758065 Remove base url from sample server and properly handle url schemes 2025-11-25 22:16:11 -05:00
W-A-James 9fdba6374c remove extraneous eslint ignore directive 2025-11-25 22:14:19 -05:00
W-A-James af90baea33 Fix trampling of port env variable 2025-11-25 22:14:15 -05: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 743e239e52 Merge pull request 'Feat: Hook up octave and fix example' (#1773) from glossing/hook-up-octave into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1773
2025-11-25 20:59:08 +01:00
Aria f44a96a5ce Merge branch 'main' into glossing/hook-up-octave 2025-11-25 20:50:49 +01:00
Aria b94a686394 Merge pull request '[perf] fix connect-leak in tremolo param' (#1780) from jeromew/strudel:fix-perf8 into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1780
Reviewed-by: Aria <glossing@noreply.codeberg.org>
2025-11-25 20:48:07 +01:00
Aria 728552b669 Merge branch 'main' into fix-perf8 2025-11-25 20:30:18 +01:00
Aria 8b427bc1dd Add oct synonym 2025-11-25 13:19:39 -06:00
Aria 8529516c66 Add ability to turn mini parsing off with midi-off decorator 2025-11-25 13:05:27 -06:00
Aria ae47d767ce Merge branch 'main' into glossing/hook-up-octave 2025-11-25 20:02:43 +01: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
Switch Angel AKA Jade Rose cfeff26d42 Merge pull request 'filter modulation improvements!' (#1769) from filter_mod_improvements into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1769
2025-11-24 17:51:19 +01:00
jeromew e60ecba393 Merge branch 'fix-perf8' of https://codeberg.org/jeromew/strudel into fix-perf8 2025-11-24 16:36:14 +00:00
jeromew f0d49e0e22 [perf] fix connect-leak from audioworklet=>param in lfo/tremolo 2025-11-24 16:35:48 +00:00
Jade (Rose) Rowland 7ee6b933cc add tests 2025-11-24 11:33:21 -05:00
jeromew a3947d1ccf [perf] fix connect-leak from audioworklet=>param in lfo/tremolo 2025-11-24 16:33:20 +00:00
Jade (Rose) Rowland e86850b6d8 frequency mod 2025-11-24 11:19:49 -05:00
jeromew 89a6efb69e [perf] level 5 connect-leak on vowel 2025-11-24 16:10:00 +00:00
Jade (Rose) Rowland 31c694b596 Merge branch 'main' into filter_mod_improvements 2025-11-24 10:44:11 -05:00
Jade (Rose) Rowland 7a4babf6e1 format 2025-11-24 10:40:16 -05:00
Jade (Rose) Rowland d5e5ab83ac fix style 2025-11-24 10:39:37 -05: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
Jade (Rose) Rowland 20016fd9aa update hash 2025-11-23 20:25:13 -05:00
Aria 7bc6fcbf2a Add channel support to midi in 2025-11-23 18:24:48 -06:00
Switch Angel AKA Jade Rose 132a124644 Merge pull request 'Add o as a synonym for orbit' (#1766) from jade/orbitsynonym into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1766
Reviewed-by: Aria <glossing@noreply.codeberg.org>
2025-11-24 01:20:52 +01:00
Jade (Rose) Rowland 4caacb0de1 only eval if script 2025-11-23 19:12:28 -05:00
Jade (Rose) Rowland 8325b4cfc4 Merge branch 'main' into jade/orbitsynonym 2025-11-23 19:06:16 -05:00
Jade (Rose) Rowland 3b2df0b725 synonym: 2025-11-23 19:05:57 -05:00
Jade (Rose) Rowland f80fdbc38a Merge branch 'pre_eval' of ssh://codeberg.org/uzu/strudel into pre_eval 2025-11-23 18:59:26 -05:00
Jade (Rose) Rowland 3a7b237765 change variable name 2025-11-23 18:59:16 -05:00
Switch Angel AKA Jade Rose f5af55b63b Merge branch 'main' into pre_eval 2025-11-24 00:47:37 +01:00
Jade (Rose) Rowland d5dfbd7aa4 cleanup 2025-11-23 18:45:13 -05:00
Jade (Rose) Rowland 6221099af8 working 2025-11-23 18:09:38 -05:00
froos 22f1512697 Merge pull request '[perf] fix connect-leak in fm modulation' (#1758) from jeromew/strudel:fix-perf6 into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1758
2025-11-23 22:23:24 +01:00
Aria 6c0d550b6b Fix example 2025-11-23 12:25:40 -06:00
Aria cf12e3e1d8 Hook up the octave control 2025-11-23 12:21:55 -06:00
froos 9a05a679aa Merge pull request 'Fix interoperability issue between all and await initHydra()' (#1663) from jeromew/strudel:jeromew-hydra-all into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1663
2025-11-23 09:47:22 +01:00
Switch Angel AKA Jade Rose 321e76166a Merge branch 'main' into filter_mod_improvements 2025-11-23 06:35:22 +01:00
Jade (Rose) Rowland bde17594ce test 2025-11-23 00:33:52 -05:00
Jade (Rose) Rowland 7bbb653963 fixed 2025-11-22 23:42:20 -05:00
Jade (Rose) Rowland 30e672a027 wip 2025-11-22 22:49:23 -05:00
Aria f7f3aa7668 Merge pull request 'Bug fix: Swap l/r gains with temp variable' (#1768) from glossing/stereo-bug into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1768
2025-11-23 04:23:04 +01:00
Aria adc3a9ac6c Swap with temp variable 2025-11-22 20:51:30 -06:00
Switch Angel AKA Jade Rose 2518b2aed2 Merge pull request 'FIX: prevent LFO filter modulation from popping from negative values' (#1767) from lpmod_improvements into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1767
2025-11-23 00:52:27 +01:00
Jade (Rose) Rowland 1d5f3a4f30 prevent filter modulation pops 2025-11-22 18:21:24 -05:00
Jade (Rose) Rowland 2efd56e331 add synonym 2025-11-22 18:10:28 -05:00
Switch Angel AKA Jade Rose 5fa6cb4653 Merge pull request 'prefix "S" for solo' (#1481) from solo into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1481
2025-11-23 00:03:31 +01:00
Jade (Rose) Rowland 19fd0fc649 rm unessecary file 2025-11-22 17:17:41 -05:00
Jade (Rose) Rowland e72e26eb81 add comment 2025-11-22 17:11:17 -05:00
Jade (Rose) Rowland 1c4b05d55f solo search n = 1 2025-11-22 16:40:31 -05:00
jeromew d453c861d3 Merge branch 'fix-perf6' of https://codeberg.org/jeromew/strudel into fix-perf6 2025-11-21 09:01:02 +00:00
jeromew f637b8b0dd [perf] fix connect-leak in fm modulation 2025-11-21 09:00:17 +00:00
Aria 06bf17f444 Merge pull request 'Bug Fix: Use frac due to negative frequencies from FM' (#1759) from glossing/droponelettertosaveears into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1759
2025-11-21 01:56:10 +01:00
Aria 21543956c6 Also for wavetable 2025-11-20 18:30:53 -06:00
Aria 4758effdc6 Use frac due to negative frequencies from FM 2025-11-20 18:22:44 -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
froos 6f58e74bea Merge pull request '[perf] fix connect-leak added by #1742 when noise() is not used' (#1757) from jeromew/strudel:fix-perf5 into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1757
2025-11-20 19:48:15 +01:00
jeromew 907f03f3bf [perf] fix connect-leak in fm modulation 2025-11-20 17:40:51 +00:00
jeromew 0435711051 [perf] fix connect-leak added by #1742 when noise() is not used 2025-11-20 17:36:00 +00:00
froos 8f6718b9e8 Merge pull request '[perf] fix connect-leak in delay effect' (#1755) from jeromew/strudel:fix-perf4 into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1755
2025-11-20 14:50:59 +01:00
froos aa77255d98 Merge pull request '[perf] fix connect leak when .noise() is in the mix' (#1742) from jeromew/strudel:fix-perf3 into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1742
2025-11-20 14:49:40 +01:00
jeromew 7267990e20 Fix codeformat 2025-11-20 13:35:37 +00:00
jeromew 387e5db0fe Merge branch 'fix-perf3' of https://codeberg.org/jeromew/strudel into fix-perf3 2025-11-20 13:24:03 +00:00
jeromew d9f63b8dfa [perf] fix connect leak when .noise() is in the mix 2025-11-20 13:21:09 +00:00
jeromew 8ed72b4573 [perf] fix connect-leak in delay effect 2025-11-20 13:05:41 +00: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
Felix Roos dd2c808cd7 hotfix: reduce sounds-tab click to play latency 2025-11-19 21:55:35 +01: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
Alex McLean 389a63a850 Merge pull request 'wchooseCycles has now notes in an example' (#1748) from scrappy_fiddler/strudel:documentation into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1748
2025-11-18 23:52:43 +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
scrappy_fiddler 99beb4454d update snapshot with note example 2025-11-17 20:49:26 +01:00
scrappy_fiddler 30cc46ea66 wchooseCycles has now notes in an example 2025-11-17 20:04:40 +01:00
Aria ac3f477f30 Merge branch 'main' into glossing/fmsss 2025-11-16 22:32:38 -06:00
Aria ea7dc7a732 Merge pull request 'Feature: Partials' (#1659) from glossing/strudel:glossing/partials into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1659
2025-11-17 05:31:53 +01:00
Aria 573cd1ae41 Merge branch 'main' into glossing/fmsss 2025-11-16 22:23:31 -06:00
Aria 641bd2f356 Properly patternify everything 2025-11-16 22:20:17 -06:00
Aria f5ed6dbe79 Top level functions 2025-11-16 20:28:22 -06:00
Aria 7a3bea6f90 Working version 2025-11-16 19:38:59 -06:00
Aria cc10122b71 Testing out approach to allow list patterns in partials 2025-11-16 19:20:42 -06:00
Aria b66c0d18b3 Merge branch 'main' into glossing/partials 2025-11-16 18:44:04 -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
froos 0331c91b80 Merge pull request 'README: update superdough documentation' (#1741) from TristanMlct/strudel:update-readme-superdough into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1741
2025-11-16 22:06:12 +01:00
Aria 4d5ebfc204 Merge pull request 'Feature: Envelope worklet' (#1524) from glossing/strudel:glossing/snappy-envs into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1524
2025-11-16 22:00:42 +01:00
Aria aca500ba73 Merge branch 'main' into glossing/random 2025-11-16 14:42:20 -06:00
Aria 921cb17d33 Merge branch 'main' into glossing/snappy-envs 2025-11-16 14:39:40 -06:00
Aria 832fd04a3d Merge pull request 'Worklet optimizations' (#1730) from glossing/optimizations into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1730
Reviewed-by: Switch Angel AKA Jade Rose <daslyfe@noreply.codeberg.org>
2025-11-16 21:08:53 +01:00
Aria d9c21489f8 Merge branch 'main' into glossing/optimizations 2025-11-16 20:13:31 +01:00
Aria 3962558c09 Some final tweaks 2025-11-16 13:12:47 -06:00
jeromew bfa8fa3c75 [perf] fix connect leak when .noise() is in the mix 2025-11-16 09:23:11 +00:00
Tristan Mlct 91799f2014 README: update superdough documentation 2025-11-15 15:06:44 +01:00
froos f6bb3869de Merge pull request '[perf] disconnect Offline AudioNode connections in generateReverb' (#1740) from jeromew/strudel:fix-perf2 into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1740
2025-11-15 14:59:28 +01:00
froos b9e9c9e9e6 Merge pull request '[perf] disconnect send effect AudioNode when room is used' (#1736) from jeromew/strudel:fix-perf1 into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1736
2025-11-15 14:57:46 +01:00
jeromew 92d87093e0 [perf] disconnect Offline AudioNode connections in generateReverb 2025-11-15 13:39:24 +00:00
jeromew 3e05525dc2 [perf] disconnect send effect AudioNode when room is used 2025-11-14 22:06:39 +00:00
Aria 22807b3833 Use weak references for sound sources 2025-11-14 16:05:09 -06:00
froos 0960741156 Merge pull request '[supradough] fix: unstable filter' (#1593) from supradough-filter-fix into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1593
2025-11-12 21:22:33 +01:00
Alex McLean bbb977afcc Merge pull request 'Fix link syntax in project-start' (#1724) from Kissaki/strudel:kissaki-patch-1 into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1724
2025-11-12 21:12:06 +01:00
Alex McLean 81a4981a68 Merge pull request 'Fix web README sample code' (#1725) from Kissaki/strudel:kissaki-patch-2 into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1725
2025-11-12 21:06:12 +01:00
Alex McLean 90d82c8988 Merge pull request 'Fix typo: 'studel' -> 'strudel'' (#1726) from drhayes/strudel:main into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1726
2025-11-12 20:59:39 +01:00
Aria 2875814a36 Square instead of self-multiply; more voices; more cleanup 2025-11-12 13:56:14 -06:00
Aria 157b05536a Final cleanup 2025-11-12 13:36:01 -06:00
Aria f8c9425569 Simplify spread 2025-11-12 13:13:37 -06:00
Aria be3a88b09c Optimize phase wrap; make voices k-rate 2025-11-12 12:23:40 -06:00
Aria aabf122161 Further optimizations, including detuner 2025-11-12 11:53:38 -06:00
Aria 3c6de3be6d First pass of worklets optimizations 2025-11-12 10:48:20 -06:00
froos 15f7b7c1a2 Merge pull request 'fix for node 24 support in tests - #1718' (#1719) from ausav/strudel:ausav.nodever into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1719
2025-11-12 15:23:36 +01:00
froos b6e382508f Merge pull request 'soundAlias example fix' (#1720) from PepsiiMan/strudel:docfix into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1720
2025-11-11 09:00:47 +01:00
drhayes 3e7ae88043 Fix typo: 'studel' -> 'strudel' 2025-11-10 17:02:55 +01:00
froos 897a2016d0 Merge pull request 'fix: Make Supradough package build' (#1711) from munshkr/strudel:fix/1702-supradough-build into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1711
2025-11-10 08:18:35 +01:00
froos 7a1c1a75b1 Merge pull request 'added docs for spaces in scale names' (#1715) from ondras/strudel:scale-docs into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1715
2025-11-10 08:18:00 +01:00
ondras 7f06f11e96 Merge branch 'main' into scale-docs 2025-11-10 08:08:04 +01:00
Switch Angel AKA Jade Rose 5acb00795e Merge pull request 'Feature: LFOs for Filters' (#1636) from glossing/strudel:glossing/filter-lfo into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1636
2025-11-10 02:44:00 +01:00
Switch Angel AKA Jade Rose f4b68fa2e6 Merge branch 'main' into glossing/filter-lfo 2025-11-10 02:20:20 +01:00
PepsiiMan 1b37f1ed1d soundAlias example fix 2025-11-06 15:33:06 +01:00
ondras f8943dbc94 Merge branch 'main' into scale-docs 2025-11-06 08:16:34 +01:00
Ondřej Žára a3f82f90a9 test fix 2025-11-06 08:15:55 +01:00
Switch Angel AKA Jade Rose 670f75b7e8 Merge pull request 'improvement: sync midi messages to audio context clock' (#1708) from sync_midi_timing into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1708
2025-11-05 17:06:59 +01:00
Jade (Rose) Rowland 79875ebc50 fix branch 2025-11-05 01:26:36 -05:00
Switch Angel AKA Jade Rose a1bd905c06 Merge branch 'main' into sync_midi_timing 2025-11-05 07:25:06 +01:00
Alex Victoria 247bf9b930 fix for node 24 support #1718 2025-11-04 20:54:21 -06:00
froos 8f24ce223f Merge pull request 'Add stick button mappings to gamepad implementation and improve docs' (#1696) from kaosuryoko/strudel:gamepad-stick-button into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1696
2025-11-04 19:36:13 +01:00
froos 51648d3e6e Merge pull request 'Refactor sound stopping and triggering logic in SoundsTab component' (#1688) from IJOL/strudel:bugfix/multi-sample-loading into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1688
2025-11-04 19:29:34 +01:00
froos d4e67e1c20 Merge pull request 'Add setting to toggle pattern auto-start on pattern change' (#1690) from moumar/strudel:pattern-auto-start-setting into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1690
2025-11-04 19:13:20 +01:00
froos edf4739cc6 Merge pull request 'Voicings JSDoc' (#1686) from sharkeys_lunchbox/strudel:main into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1686
2025-11-04 18:58:50 +01:00
froos 3e636bd6f0 Merge branch 'main' into main 2025-11-04 18:55:34 +01:00
Ondřej Žára 4df2cc3086 added docs for spaces in scale names 2025-11-03 22:03:39 +01:00
moumar 809eb7132c codeformat 2025-11-02 15:20:31 +01:00
froos dd6429007e Merge branch 'main' into bugfix/multi-sample-loading 2025-11-02 14:56:30 +01:00
froos e211d5a6bb Merge branch 'main' into pattern-auto-start-setting 2025-11-02 14:44:31 +01:00
Damián Silvani 404d9999f3 fix: Update github.com links 2025-11-02 09:21:40 -03:00
Damián Silvani bc1c83ed64 fix: Add missing vite.config.js to supradough package 2025-11-02 09:21:25 -03:00
Jade (Rose) Rowland 91e1cc136e working 2025-11-01 23:34:10 -04:00
Switch Angel AKA Jade Rose 92e3a80e65 Merge pull request 'FIX: Loading local samples uses too much memory' (#1706) from jade/fix_sample_loading into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1706
2025-11-01 07:50:39 +01:00
Jade (Rose) Rowland 4f046ae883 cf 2025-11-01 02:40:40 -04:00
Jade (Rose) Rowland 2b575d50b9 temporarily disable sample autocomplete 2025-11-01 02:39:43 -04:00
Jade (Rose) Rowland 7b92874909 fix broken array 2025-11-01 01:55:05 -04:00
Jade (Rose) Rowland 10bfe32a2d rm console 2025-11-01 01:48:42 -04:00
Jade (Rose) Rowland a41b07c67a fixed build 2025-11-01 01:44:32 -04:00
Jade (Rose) Rowland 20027d7208 Merge branch 'jade/fix_sample_loading' of ssh://codeberg.org/uzu/strudel into jade/fix_sample_loading 2025-11-01 01:32:35 -04:00
Jade (Rose) Rowland f38792c569 format 2025-11-01 01:32:24 -04:00
Switch Angel AKA Jade Rose 2c2bfd7070 Merge branch 'main' into jade/fix_sample_loading 2025-11-01 06:31:25 +01:00
Jade (Rose) Rowland 484c5e6cdd working 2025-11-01 01:26:25 -04:00
Jason Dufair a036c4f921 Merge remote-tracking branch 'upstream/main' 2025-10-30 18:57:36 -04:00
froos 4c35f6a77b Merge pull request 'Bug Fix: Handle scale-for-notes when n also supplied' (#1625) from glossing/strudel:glossing/scale-with-note-and-n into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1625
2025-10-28 22:58:24 +01:00
froos e92f4938cd Merge branch 'main' into glossing/scale-with-note-and-n 2025-10-28 22:54:34 +01:00
froos 12204c9053 Merge pull request 'Repurpose vim shortcuts for usability' (#1624) from dtricks/strudel:feat/vim-shortcuts into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1624
2025-10-28 22:51:15 +01:00
froos 980bf17059 Merge branch 'main' into feat/vim-shortcuts 2025-10-28 22:38:04 +01:00
froos f2009a33aa Merge pull request 'Add support for euclidian in mondo with bd&3:8' (#1630) from TristanCacqueray/strudel:mondian into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1630
2025-10-28 22:35:19 +01:00
froos a65a68b828 Merge branch 'main' into mondian 2025-10-28 22:20:41 +01:00
Jason Dufair c0e7971be7 Merge remote-tracking branch 'upstream/main' 2025-10-27 09:26:47 -04:00
kaosuryoko b491c42af5 Merge branch 'main' into gamepad-stick-button 2025-10-27 00:36:50 +01:00
Greg Ervin ee43d85f29 Add stick button mappings to gamepad implementation and improve docs 2025-10-26 13:31:13 -05:00
moumar fcd83cce55 Re-evaluate pattern if activated 2025-10-26 14:07:36 +01:00
moumar fddf2ff2a7 Set pattern auto-start to true by default 2025-10-26 14:06:31 +01:00
Jason Dufair 6d4787ee33 Test snapshot 2025-10-24 09:21:04 -04:00
Jason Dufair 1a362bd8ea Fix example 2025-10-24 09:19:02 -04: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
moumar 20bbda30f8 Add setting to toggle pattern auto-start on pattern change 2025-10-23 23:15:21 +02: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
Ignacio Ortega a8c613b7eb Refactor sound stopping and triggering logic in SoundsTab component 2025-10-23 19:04:29 +02:00
Jason Dufair fd0a910bfb Voicings JSDoc 2025-10-23 11:11:21 -04: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 c02def03b6 Don't tag when scale fails, allow mixed sharps and flats, simplify scale function 2025-10-16 16:26:33 -05:00
froos 0d5dc1f8c1 Merge branch 'main' into glossing/scale-with-note-and-n 2025-10-16 17:19:07 +02:00
jeromew 47c85f8540 fix interoperability of all with hydra
Hydra add methods on Array.prototype which breaks the Array enumeration with for...in.
Use for...of instead
cf https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for...of#difference_between_for...of_and_for...in for details on the issue

without this fix, it is impossible to use both `await initHydra()` and `all` in the same strudel code. Strudel errors with "Error: got "undefined" instead of pattern."

example code: https://strudel.cc/#YXdhaXQgaW5pdEh5ZHJhKCkKCmFsbChwaWFub3JvbGwpCgokOiBzKCJiZCIp
2025-10-16 14:06:33 +02:00
Aria fd03d1fdfb Update conditional to allow to control user waveforms 2025-10-15 15:14:31 -05:00
Aria 35016b5847 Mention range in docstring 2025-10-15 14:56:20 -05:00
Aria 0fe7edc157 Some examples 2025-10-15 14:53:07 -05:00
Aria d6de160224 Merge branch 'main' into glossing/partials 2025-10-15 13:19:57 -05:00
Aria ac7e9c6978 Typos on envelope values 2025-10-14 11:37:41 -05:00
Aria c13a726129 Merge branch 'main' into glossing/filter-lfo 2025-10-14 11:24:49 -05:00
Switch Angel AKA Jade Rose 99b3c04a76 Merge branch 'main' into glossing/filter-lfo 2025-10-13 07:04:51 +02:00
Aria 57fac57677 Remove todo 2025-10-09 22:23:28 -05:00
Aria a3b803c3f3 First pass 2025-10-09 18:28:59 -05:00
Felix Roos 319a2d7289 fix: tests 2025-10-07 23:05:00 +02:00
Felix Roos 3a7d50924d refactor: add op_precedence to express & with desugar_ops 2025-10-07 22:59:50 +02:00
Felix Roos f33db5f07f - refactor bjork -> bjorklund
- refactor e -> bjork
- flip & desugared arguments
2025-10-07 22:35:29 +02:00
Tristan de Cacqueray b7fa440bda mondough: interpret & 2025-10-07 18:10:24 +02:00
Tristan de Cacqueray 3dc9b3524c mondo: add & sugar 2025-10-07 17:19:27 +02:00
dtricks f48f8e46e5 fix empty catches 2025-10-06 15:53:44 +02:00
dtricks a79fc3db5b remove unnecessary comments 2025-10-06 15:53:44 +02:00
dtricks 30a2e14959 weird permission change 2025-10-06 15:53:44 +02:00
dtricks cfe17fcba5 wrong unicode char 2025-10-06 15:53:44 +02:00
dtricks 46ba165c7e remove gcc 2025-10-06 15:53:44 +02:00
dtricks 73a9834959 codeformat 2025-10-06 15:53:44 +02:00
dtricks be9368e8a2 feat(codemirror): add Vim :w evaluate, :q stop, and gc/gcc toggle comment via custom events; docs: add Vim shortcuts page and link from REPL manual 2025-10-06 15:53:44 +02:00
Aria 39a73f2499 Merge branch 'main' into glossing/scale-with-note-and-n 2025-10-05 18:20:30 -05:00
Aria cc4452ce58 Working version of partials and phases 2025-10-04 20:33:25 -05:00
Aria 11dd8a318a Cleanup 2025-10-04 02:13:42 -05:00
Aria 3ef6c7c921 Handle scale-for-notes when n also supplied 2025-10-04 02:07:35 -05: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
Switch Angel AKA Jade Rose f4cf77f5c6 Merge branch 'main' into solo 2025-09-30 08:09:37 +02:00
Kissaki 4d39f36868 Fix web README sample code
Looks like the code was copied, and then the wrong string was replaced when creating this code sample.
2025-09-21 18:31:59 +02:00
Kissaki c64aa3bf01 Fix link syntax in project-start 2025-09-21 18:27:05 +02:00
Felix Roos a0c2edad30 fix: add clamp function 2025-09-18 10:54:41 +02:00
Felix Roos 82c9047195 fix: unstable filter 2025-09-18 10:47:23 +02: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 e4070b86f8 More descriptive variables and comments 2025-08-29 14:33:14 -05:00
Aria 9eeb6da3e6 Codeformat 2025-08-28 14:54:09 -05:00
Aria 869141b0a3 Parity with old adsr 2025-08-28 00:31:37 -05:00
Aria ea017e7622 Working version 2025-08-27 20:11:00 -05:00
Aria 763f900c69 First pass at envelopes with curves 2025-08-27 19:19:54 -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
Felix Roos 7f103e77e3 prefix "S" for solo 2025-07-28 04:05:21 +02:00
151 changed files with 16394 additions and 8056 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:
+1
View File
@@ -0,0 +1 @@
22
+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
+5 -2
View File
@@ -1,19 +1,22 @@
# 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. Install [Node.js](https://nodejs.org/)
1. Install [Node.js](https://nodejs.org/) 18 or newer
2. Install [pnpm](https://pnpm.io/installation)
3. Install dependencies by running the following command:
```bash
@@ -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
+3
View File
@@ -20,5 +20,8 @@
"@strudel/tonal": "workspace:*",
"@strudel/transpiler": "workspace:*",
"@strudel/webaudio": "workspace:*"
},
"engines": {
"node": ">=18.0.0"
}
}
+3
View File
@@ -14,5 +14,8 @@
},
"dependencies": {
"@strudel/web": "workspace:*"
},
"engines": {
"node": ">=18.0.0"
}
}
+3
View File
@@ -18,5 +18,8 @@
"@strudel/transpiler": "workspace:*",
"@strudel/webaudio": "workspace:*",
"@strudel/tonal": "workspace:*"
},
"engines": {
"node": ">=18.0.0"
}
}
+3
View File
@@ -13,5 +13,8 @@
},
"devDependencies": {
"vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
}
}
+3
View File
@@ -32,5 +32,8 @@
},
"devDependencies": {
"vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
}
}
+211
View File
@@ -0,0 +1,211 @@
0 silly argv {
0 silly argv _: [ 'version' ],
0 silly argv private: false,
0 silly argv lernaVersion: '8.1.9',
0 silly argv '$0': 'node_modules/lerna/dist/cli.js'
0 silly argv }
1 notice cli v8.1.9
2 verbose packageConfigs Explicit "packages" configuration found in lerna.json. Resolving packages using the configured glob(s): ["packages/*"]
3 verbose rootPath /Users/jaderose/Documents/Github/cstrudel/strudel
4 info versioning independent
5 silly isAnythingCommitted
6 verbose isAnythingCommitted 1
7 silly getCurrentBranch
8 verbose currentBranch main
9 silly remoteBranchExists
10 silly isBehindUpstream
11 silly isBehindUpstream main is behind origin/main by 0 commit(s) and ahead by 4
12 silly hasTags
13 verbose hasTags true
14 silly git-describe.sync "@strudel/codemirror@1.2.7-4-ga3ac9646" => {"lastTagName":"@strudel/codemirror@1.2.7","lastVersion":"1.2.7","refCount":"4","sha":"a3ac9646","isDirty":false}
15 info Looking for changed packages since @strudel/codemirror@1.2.7
16 silly checking diff packages/codemirror
17 silly no diff found in @strudel/codemirror
18 silly checking diff packages/core
19 silly no diff found in @strudel/core
20 silly checking diff packages/csound
21 silly no diff found in @strudel/csound
22 silly checking diff packages/desktopbridge
23 silly no diff found in @strudel/desktopbridge
24 silly checking diff packages/draw
25 silly no diff found in @strudel/draw
26 silly checking diff packages/embed
27 silly no diff found in @strudel/embed
28 silly checking diff packages/gamepad
29 silly no diff found in @strudel/gamepad
30 silly checking diff packages/hs2js
31 silly no diff found in hs2js
32 silly checking diff packages/hydra
33 silly no diff found in @strudel/hydra
34 silly checking diff packages/midi
35 silly no diff found in @strudel/midi
36 silly checking diff packages/mini
37 silly no diff found in @strudel/mini
38 silly checking diff packages/mondo
39 silly no diff found in mondolang
40 silly checking diff packages/mondough
41 silly no diff found in @strudel/mondo
42 silly checking diff packages/motion
43 silly no diff found in @strudel/motion
44 silly checking diff packages/mqtt
45 silly no diff found in @strudel/mqtt
46 silly checking diff packages/osc
47 silly found diff in packages/osc/server.js
48 verbose filtered diff [ 'packages/osc/server.js' ]
49 silly checking diff packages/reference
50 silly no diff found in @strudel/reference
51 silly checking diff packages/repl
52 silly no diff found in @strudel/repl
53 silly checking diff packages/sampler
54 silly no diff found in @strudel/sampler
55 silly checking diff packages/serial
56 silly no diff found in @strudel/serial
57 silly checking diff packages/soundfonts
58 silly no diff found in @strudel/soundfonts
59 silly checking diff packages/superdough
60 silly found diff in packages/superdough/package.json
61 verbose filtered diff [ 'packages/superdough/package.json' ]
62 silly checking diff packages/supradough
63 silly found diff in packages/supradough/package.json
64 verbose filtered diff [ 'packages/supradough/package.json' ]
65 silly checking diff packages/tidal
66 silly no diff found in @strudel/tidal
67 silly checking diff packages/tonal
68 silly no diff found in @strudel/tonal
69 silly checking diff packages/transpiler
70 silly no diff found in @strudel/transpiler
71 silly checking diff packages/vite-plugin-bundle-audioworklet
72 silly no diff found in vite-plugin-bundle-audioworklet
73 silly checking diff packages/web
74 silly no diff found in @strudel/web
75 silly checking diff packages/webaudio
76 silly found diff in packages/webaudio/package.json
77 verbose filtered diff [ 'packages/webaudio/package.json' ]
78 silly checking diff packages/xen
79 silly no diff found in @strudel/xen
80 verbose updated @strudel/codemirror
81 verbose updated @strudel/csound
82 verbose updated @strudel/midi
83 verbose updated @strudel/osc
84 verbose updated @strudel/repl
85 verbose updated @strudel/soundfonts
86 verbose updated superdough
87 verbose updated supradough
88 verbose updated @strudel/web
89 verbose updated @strudel/webaudio
90 verbose git-describe undefined => "@strudel/codemirror@1.2.7-4-ga3ac9646"
91 silly git-describe parsed => {"lastTagName":"@strudel/codemirror@1.2.7","lastVersion":"1.2.7","refCount":"4","sha":"a3ac9646","isDirty":false}
92 info execute Skipping releases
93 silly lifecycle No script for "preversion" in "@strudel/monorepo", continuing
94 silly lifecycle No script for "preversion" in "@strudel/osc", continuing
95 silly lifecycle No script for "preversion" in "superdough", continuing
96 silly lifecycle No script for "preversion" in "supradough", continuing
97 verbose version supradough has no lockfile. Skipping lockfile update.
98 verbose version @strudel/osc has no lockfile. Skipping lockfile update.
99 verbose version superdough has no lockfile. Skipping lockfile update.
100 silly lifecycle No script for "version" in "supradough", continuing
101 silly lifecycle No script for "version" in "superdough", continuing
102 silly lifecycle No script for "preversion" in "@strudel/codemirror", continuing
103 silly lifecycle No script for "preversion" in "@strudel/webaudio", continuing
104 silly lifecycle No script for "version" in "@strudel/osc", continuing
105 verbose version @strudel/webaudio has no lockfile. Skipping lockfile update.
106 verbose version @strudel/codemirror has no lockfile. Skipping lockfile update.
107 silly lifecycle No script for "version" in "@strudel/codemirror", continuing
108 silly lifecycle No script for "version" in "@strudel/webaudio", continuing
109 silly lifecycle No script for "preversion" in "@strudel/csound", continuing
110 silly lifecycle No script for "preversion" in "@strudel/midi", continuing
111 silly lifecycle No script for "preversion" in "@strudel/soundfonts", continuing
112 silly lifecycle No script for "preversion" in "@strudel/web", continuing
113 verbose version @strudel/csound has no lockfile. Skipping lockfile update.
114 verbose version @strudel/midi has no lockfile. Skipping lockfile update.
115 verbose version @strudel/soundfonts has no lockfile. Skipping lockfile update.
116 verbose version @strudel/web has no lockfile. Skipping lockfile update.
117 silly lifecycle No script for "version" in "@strudel/midi", continuing
118 silly lifecycle No script for "version" in "@strudel/csound", continuing
119 silly lifecycle No script for "version" in "@strudel/soundfonts", continuing
120 silly lifecycle No script for "preversion" in "@strudel/repl", continuing
121 verbose version @strudel/repl has no lockfile. Skipping lockfile update.
122 silly lifecycle No script for "version" in "@strudel/web", continuing
123 silly lifecycle No script for "version" in "@strudel/repl", continuing
124 silly lifecycle No script for "version" in "@strudel/monorepo", continuing
125 verbose version Updating root pnpm-lock.yaml
126 silly version Skipped applying prettier to ignored file: packages/supradough/package.json
127 silly version Skipped applying prettier to ignored file: packages/superdough/package.json
128 silly version Skipped applying prettier to ignored file: packages/osc/package.json
129 silly version Skipped applying prettier to ignored file: packages/codemirror/package.json
130 silly version Skipped applying prettier to ignored file: packages/webaudio/package.json
131 silly version Skipped applying prettier to ignored file: packages/midi/package.json
132 silly version Skipped applying prettier to ignored file: packages/csound/package.json
133 silly version Skipped applying prettier to ignored file: packages/soundfonts/package.json
134 silly version Skipped applying prettier to ignored file: packages/web/package.json
135 silly version Skipped applying prettier to ignored file: packages/repl/package.json
136 silly version Skipped applying prettier to ignored file: pnpm-lock.yaml
137 silly gitAdd [
137 silly gitAdd 'packages/supradough/package.json',
137 silly gitAdd 'packages/superdough/package.json',
137 silly gitAdd 'packages/osc/package.json',
137 silly gitAdd 'packages/codemirror/package.json',
137 silly gitAdd 'packages/webaudio/package.json',
137 silly gitAdd 'packages/midi/package.json',
137 silly gitAdd 'packages/csound/package.json',
137 silly gitAdd 'packages/soundfonts/package.json',
137 silly gitAdd 'packages/web/package.json',
137 silly gitAdd 'packages/repl/package.json',
137 silly gitAdd 'pnpm-lock.yaml'
137 silly gitAdd ]
138 silly gitCommit Publish
138 silly gitCommit
138 silly gitCommit - @strudel/codemirror@1.2.8
138 silly gitCommit - @strudel/csound@1.2.8
138 silly gitCommit - @strudel/midi@1.2.8
138 silly gitCommit - @strudel/osc@1.3.2
138 silly gitCommit - @strudel/repl@1.2.9
138 silly gitCommit - @strudel/soundfonts@1.2.8
138 silly gitCommit - superdough@1.2.5
138 silly gitCommit - supradough@1.2.4
138 silly gitCommit - @strudel/web@1.2.8
138 silly gitCommit - @strudel/webaudio@1.2.9
139 verbose git [
139 verbose git 'commit',
139 verbose git '-F',
139 verbose git '/private/var/folders/hc/yf_zr55547sbcpz5rj7q85sc0000gn/T/3c85b5d4-56ae-40e9-8df7-db2bc1048402/lerna-commit.txt'
139 verbose git ]
140 silly gitTag @strudel/codemirror@1.2.8 git tag %s -m %s
141 verbose git [
141 verbose git 'tag',
141 verbose git '@strudel/codemirror@1.2.8',
141 verbose git '-m',
141 verbose git '@strudel/codemirror@1.2.8'
141 verbose git ]
142 silly gitTag @strudel/csound@1.2.8 git tag %s -m %s
143 verbose git [ 'tag', '@strudel/csound@1.2.8', '-m', '@strudel/csound@1.2.8' ]
144 silly gitTag @strudel/midi@1.2.8 git tag %s -m %s
145 verbose git [ 'tag', '@strudel/midi@1.2.8', '-m', '@strudel/midi@1.2.8' ]
146 silly gitTag @strudel/osc@1.3.2 git tag %s -m %s
147 verbose git [ 'tag', '@strudel/osc@1.3.2', '-m', '@strudel/osc@1.3.2' ]
148 silly gitTag @strudel/repl@1.2.9 git tag %s -m %s
149 verbose git [ 'tag', '@strudel/repl@1.2.9', '-m', '@strudel/repl@1.2.9' ]
150 silly gitTag @strudel/soundfonts@1.2.8 git tag %s -m %s
151 verbose git [
151 verbose git 'tag',
151 verbose git '@strudel/soundfonts@1.2.8',
151 verbose git '-m',
151 verbose git '@strudel/soundfonts@1.2.8'
151 verbose git ]
152 silly gitTag superdough@1.2.5 git tag %s -m %s
153 verbose git [ 'tag', 'superdough@1.2.5', '-m', 'superdough@1.2.5' ]
154 silly gitTag supradough@1.2.4 git tag %s -m %s
155 verbose git [ 'tag', 'supradough@1.2.4', '-m', 'supradough@1.2.4' ]
156 silly gitTag @strudel/web@1.2.8 git tag %s -m %s
157 verbose git [ 'tag', '@strudel/web@1.2.8', '-m', '@strudel/web@1.2.8' ]
158 silly gitTag @strudel/webaudio@1.2.9 git tag %s -m %s
159 verbose git [ 'tag', '@strudel/webaudio@1.2.9', '-m', '@strudel/webaudio@1.2.9' ]
160 error Error: Command failed with exit code 128: git tag superdough@1.2.5 -m superdough@1.2.5
160 error fatal: tag 'superdough@1.2.5' already exists
160 error at makeError (/Users/jaderose/Documents/Github/cstrudel/strudel/node_modules/.pnpm/execa@5.0.0/node_modules/execa/lib/error.js:59:11)
160 error at handlePromise (/Users/jaderose/Documents/Github/cstrudel/strudel/node_modules/.pnpm/execa@5.0.0/node_modules/execa/index.js:114:26)
160 error at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
160 error at async Promise.all (index 6)
160 error at async VersionCommand.gitCommitAndTagVersionForUpdates (/Users/jaderose/Documents/Github/cstrudel/strudel/node_modules/.pnpm/lerna@8.1.9_encoding@0.1.13/node_modules/lerna/dist/index.js:9957:11)
160 error at async VersionCommand.commitAndTagUpdates (/Users/jaderose/Documents/Github/cstrudel/strudel/node_modules/.pnpm/lerna@8.1.9_encoding@0.1.13/node_modules/lerna/dist/index.js:9933:18)
160 error at async Promise.all (index 0)
+4 -1
View File
@@ -73,5 +73,8 @@
"prettier": "^3.4.2",
"vitest": "^3.0.4",
"vite-plugin-bundle-audioworklet": "workspace:*"
},
"engines": {
"node": ">=18.0.0"
}
}
}
+14 -10
View File
@@ -1,8 +1,10 @@
import jsdoc from '../../doc.json';
import { autocompletion } from '@codemirror/autocomplete';
import { h } from './html';
import { Scale } from '@tonaljs/tonal';
import { soundMap } from 'superdough';
//TODO: fix tonal scale import
// import { Scale } from '@tonaljs/tonal';
// import { soundMap } from '@strudel/webaudio';
let soundMap = undefined;
import { complex } from '@strudel/tonal';
const escapeHtml = (str) => {
@@ -79,7 +81,9 @@ const hasExcludedTags = (doc) =>
['superdirtOnly', 'noAutocomplete'].some((tag) => doc.tags?.find((t) => t.originalTitle === tag));
export function bankCompletions() {
const soundDict = soundMap.get();
// TODO: FIX IMPORT
const soundDict = soundMap?.get() ?? {};
const banks = new Set();
for (const key of Object.keys(soundDict)) {
const [bank, suffix] = key.split('_');
@@ -90,13 +94,13 @@ export function bankCompletions() {
.map((name) => ({ label: name, type: 'bank' }));
}
// Attempt to get all scale names from Tonal
// Attempt to get all scale names from Tonal TODO: FIX IMPORT
let scaleCompletions = [];
try {
scaleCompletions = (Scale.names ? Scale.names() : []).map((name) => ({ label: name, type: 'scale' }));
} catch (e) {
console.warn('[autocomplete] Could not load scale names from Tonal:', e);
}
// try {
// scaleCompletions = (Scale.names ? Scale.names() : []).map((name) => ({ label: name, type: 'scale' }));
// } catch (e) {
// console.warn('[autocomplete] Could not load scale names from Tonal:', e);
// }
// Valid mode values for voicing
const modeCompletions = [
@@ -268,7 +272,7 @@ function soundHandler(context) {
const inside = text.slice(quoteIdx + 1);
const fragMatch = inside.match(SOUND_FRAGMENT_MATCH_REGEX);
const fragment = fragMatch ? fragMatch[1] : inside;
const soundNames = Object.keys(soundMap.get()).sort();
const soundNames = Object.keys(soundMap?.get() ?? {}).sort();
const filteredSounds = soundNames.filter((name) => name.includes(fragment));
let options = filteredSounds.map((name) => ({ label: name, type: 'sound' }));
const from = soundContext.to - fragment.length;
+84 -29
View File
@@ -1,32 +1,34 @@
import { closeBrackets } from '@codemirror/autocomplete';
export { toggleComment, toggleBlockComment, toggleLineComment, toggleBlockCommentByLine } from '@codemirror/commands';
// import { search, highlightSelectionMatches } from '@codemirror/search';
import { indentWithTab } from '@codemirror/commands';
import { indentWithTab, toggleLineComment } from '@codemirror/commands';
import { javascript, javascriptLanguage } from '@codemirror/lang-javascript';
import { defaultHighlightStyle, syntaxHighlighting, bracketMatching } from '@codemirror/language';
import { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { Compartment, EditorState, Prec } from '@codemirror/state';
import {
drawSelection,
EditorView,
highlightActiveLineGutter,
highlightActiveLine,
highlightActiveLineGutter,
keymap,
lineNumbers,
drawSelection,
} from '@codemirror/view';
import { repl, registerControl } from '@strudel/core';
import { Drawer, cleanupDraw } from '@strudel/draw';
import { persistentAtom } from '@nanostores/persistent';
import { logger, registerControl, repl } from '@strudel/core';
import { cleanupDraw, Drawer } from '@strudel/draw';
import { isAutoCompletionEnabled } from './autocomplete.mjs';
import { isTooltipEnabled } from './tooltip.mjs';
import { basicSetup } from './basicSetup.mjs';
import { flash, isFlashEnabled } from './flash.mjs';
import { highlightMiniLocations, isPatternHighlightingEnabled, updateMiniLocations } from './highlight.mjs';
import { keybindings } from './keybindings.mjs';
import { initTheme, activateTheme, theme } from './themes.mjs';
import { sliderPlugin, updateSliderWidgets } from './slider.mjs';
import { widgetPlugin, updateWidgets } from './widget.mjs';
import { persistentAtom } from '@nanostores/persistent';
import { basicSetup } from './basicSetup.mjs';
import { activateTheme, initTheme, theme } from './themes.mjs';
import { isTooltipEnabled } from './tooltip.mjs';
import { updateWidgets, widgetPlugin } from './widget.mjs';
import { jumpToCharacter } from './labelJump.mjs';
const extensions = {
export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands';
export const extensions = {
isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []),
isBracketMatchingEnabled: (on) => (on ? bracketMatching({ brackets: '()[]{}<>' }) : []),
isBracketClosingEnabled: (on) => (on ? closeBrackets() : []),
@@ -47,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',
@@ -94,8 +96,8 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
}),
sliderPlugin,
widgetPlugin,
// indentOnInput(), // works without. already brought with javascript extension?
// bracketMatching(), // does not do anything
// indentOnInput(), // works without. already brought with javascript
// extension? bracketMatching(), // does not do anything
syntaxHighlighting(defaultHighlightStyle),
EditorView.updateListener.of((v) => onChange(v)),
drawSelection({ cursorBlinkRate: 0 }),
@@ -118,14 +120,22 @@ 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?.()),
},
{
key: 'Ctrl-Shift-Enter',
run: () => (onReEvaluate ? onReEvaluate() : onEvaluate?.()),
}, */
key: 'Ctrl-Shift-.',
run: () => (onPanic ? onPanic() : onStop?.()),
},
{
key: 'Ctrl-Shift-Enter',
run: () => (onReEvaluate ? onReEvaluate() : onEvaluate?.()),
}, */
]),
),
],
@@ -206,7 +216,8 @@ export class StrudelMirror {
updateWidgets(this.editor, widgets);
updateMiniLocations(this.editor, this.miniLocations);
replOptions?.afterEval?.(options);
// if no painters are set (.onPaint was not called), then we only need the present moment (for highlighting)
// if no painters are set (.onPaint was not called), then we only need
// the present moment (for highlighting)
const drawTime = options.pattern.getPainters().length ? this.drawTime : [0, 0];
this.drawer.setDrawTime(drawTime);
// invalidate drawer after we've set the appropriate drawTime
@@ -245,6 +256,33 @@ export class StrudelMirror {
}
};
document.addEventListener('start-repl', this.onStartRepl);
// Handle global evaluation requests (e.g., from Vim :w)
this.onEvaluateRequest = (e) => {
try {
// Evaluate current editor on repl-evaluate
logger('[repl] evaluate via event');
this.evaluate();
e?.cancelable && e.preventDefault?.();
} catch (err) {
console.error('Error handling repl-evaluate event', err);
}
};
document.addEventListener('repl-evaluate', this.onEvaluateRequest);
document.addEventListener('repl-stop', this.onStopRequest);
// Toggle comments requested from Vim (gc)
this.onToggleComment = (e) => {
try {
// Honor selections; toggleLineComment handles both selections and
// single line
toggleLineComment(this.editor);
e?.cancelable && e.preventDefault?.();
} catch (err) {
console.error('Error handling repl-toggle-comment event', err);
}
};
document.addEventListener('repl-toggle-comment', this.onToggleComment);
}
draw(haps, time, painters) {
painters?.forEach((painter) => painter(this.drawContext, time, haps, this.drawTime));
@@ -264,13 +302,23 @@ 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();
}
// Listen for global stop requests (e.g., from Vim :q)
onStopRequest = (e) => {
try {
this.stop();
e?.cancelable && e.preventDefault?.();
} catch (err) {
console.error('Error handling repl-stop event', err);
}
};
async toggle() {
if (this.repl.scheduler.started) {
this.repl.stop();
@@ -346,11 +394,18 @@ export class StrudelMirror {
}
}
setCode(code) {
const changes = { from: 0, to: this.editor.state.doc.length, insert: code };
const changes = {
from: 0,
to: this.editor.state.doc.length,
insert: code,
};
this.editor.dispatch({ changes });
}
clear() {
this.onStartRepl && document.removeEventListener('start-repl', this.onStartRepl);
this.onEvaluateRequest && document.removeEventListener('repl-evaluate', this.onEvaluateRequest);
this.onStopRequest && document.removeEventListener('repl-stop', this.onStopRequest);
this.onToggleComment && document.removeEventListener('repl-toggle-comment', this.onToggleComment);
}
getCursorLocation() {
return this.editor.state.selection.main.head;
@@ -365,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';
+115 -4
View File
@@ -1,11 +1,12 @@
import { Prec } from '@codemirror/state';
import { defaultKeymap } from '@codemirror/commands';
import { Prec, EditorState } from '@codemirror/state';
import { keymap, ViewPlugin } from '@codemirror/view';
// import { searchKeymap } from '@codemirror/search';
import { emacs } from '@replit/codemirror-emacs';
import { vim } from '@replit/codemirror-vim';
import { vim, Vim } from '@replit/codemirror-vim';
// import { vim } from './vim_test.mjs';
import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
import { defaultKeymap } from '@codemirror/commands';
import { logger } from '@strudel/core';
const vscodePlugin = ViewPlugin.fromClass(
class {
@@ -19,6 +20,110 @@ const vscodePlugin = ViewPlugin.fromClass(
);
const vscodeExtension = (options) => [vscodePlugin].concat(options ?? []);
// Map Vim :w to trigger the same action as evaluation. We dispatch a custom
// event 'repl-evaluate' that the editor listens for, and also simulate
// Ctrl+Enter/Alt+Enter as a fallback. We log to the Strudel logger so it
// appears in the Console panel.
try {
if (Vim && typeof Vim.defineEx === 'function') {
// Map gc to toggle line comments by dispatching a custom event that our
// CodeMirror integration listens to. This avoids depending on Vim's
// internal actions and works with current selections/visual mode.
try {
Vim.defineAction('strudelToggleComment', (cm) => {
const view = cm?.view || cm;
try {
const ev = new CustomEvent('repl-toggle-comment', { detail: { source: 'vim', view }, cancelable: true });
document.dispatchEvent(ev);
} catch (e) {
console.error('strudelToggleComment dispatch failed', e);
}
});
Vim.mapCommand('gc', 'action', 'strudelToggleComment', {}, { context: 'normal' });
Vim.mapCommand('gc', 'action', 'strudelToggleComment', {}, { context: 'visual' });
} catch (e) {
console.error('Vim gc mapping failed', e);
}
// :q to pause/stop
Vim.defineEx('quit', 'q', (cm) => {
try {
const view = cm?.view || cm;
// First try dispatching our custom stop event, then fallback to Alt+.
let handled = false;
try {
const ev = new CustomEvent('repl-stop', { detail: { source: 'vim', view }, cancelable: true });
handled = document.dispatchEvent(ev) === false;
} catch (e) {
console.error('Error dispatching repl-stop event', e);
}
if (!handled) {
const altDot = new KeyboardEvent('keydown', {
key: '.',
code: 'Period',
altKey: true,
bubbles: true,
cancelable: true,
});
view?.dom?.dispatchEvent?.(altDot);
}
} catch (e) {
console.error('Error dispatching :q stop event', e);
}
});
// :w to evaluate
Vim.defineEx('write', 'w', (cm) => {
const view = cm?.view || cm; // CM6 Vim passes either an object with view or the view itself
try {
view?.focus?.();
// Let the app know this came from Vim :w
try {
logger('[vim] :w — evaluating code');
} catch (e) {
console.error('Error logging Vim :w evaluation', e);
}
// Dispatch a dedicated evaluate event first
let handled = false;
try {
const ev = new CustomEvent('repl-evaluate', { detail: { source: 'vim', view }, cancelable: true });
handled = document.dispatchEvent(ev) === false; // false means preventDefault was called
} catch (e) {
console.error('Error dispatching repl-evaluate event', e);
}
if (handled) {
return;
}
// Try Ctrl+Enter first if not handled by custom event
const ctrlEnter = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
ctrlKey: true,
bubbles: true,
cancelable: true,
});
view?.dom?.dispatchEvent?.(ctrlEnter);
// If not handled (no handler called preventDefault), try Alt+Enter as
// fallback
if (!ctrlEnter.defaultPrevented) {
const altEnter = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
altKey: true,
bubbles: true,
cancelable: true,
});
view?.dom?.dispatchEvent?.(altEnter);
}
} catch (e) {
console.error('Error dispatching :w evaluation event', e);
}
});
}
} catch (e) {
console.error('Vim ex command setup failed (defineEx missing or Vim unavailable)', e);
}
const keymaps = {
vim,
emacs,
@@ -26,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;
}
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel/codemirror",
"version": "1.2.6",
"version": "1.3.0",
"description": "Codemirror Extensions for Strudel",
"main": "index.mjs",
"publishConfig": {
@@ -54,5 +54,8 @@
},
"devDependencies": {
"vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
}
}
+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);
+715 -31
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
@@ -1288,6 +1449,193 @@ export const { fanchor } = registerControl('fanchor');
*/
// currently an alias of 'hcutoff' https://codeberg.org/uzu/strudel/issues/496
// ['hpf'],
/**
* Rate of the LFO for the lowpass filter
*
* @name lprate
* @param {number | Pattern} rate rate in hertz
* @example
* note("<c c c# c c c4>*16").s("sawtooth").lpf(600).lprate("<4 8 2 1>")
*/
export const { lprate } = registerControl('lprate');
/**
* Cycle-synced rate of the LFO for the lowpass filter
*
* @name lpsync
* @param {number | Pattern} rate rate in cycles
* @example
* note("<c c c# c c c4>*16").s("sawtooth").lpf(600).lpsync("<4 8 2 1>")
*/
export const { lpsync } = registerControl('lpsync');
/**
* Depth of the LFO for the lowpass filter
*
* @name lpdepth
* @param {number | Pattern} depth depth of modulation
* @example
* note("<c c c# c c c4>*16").s("sawtooth").lpf(600).lpdepth("<1 .5 1.8 0>")
*/
export const { lpdepth } = registerControl('lpdepth');
/**
* Depth of the LFO for the lowpass filter, in HZ
*
* @name lpdepthfrequency
* @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, lpdepthfreq } = registerControl('lpdepthfrequency', 'lpdepthfreq');
/**
* Shape of the LFO for the lowpass filter
*
* @name lpshape
* @param {number | Pattern} shape Shape of the lfo (0, 1, 2, ..)
*/
export const { lpshape } = registerControl('lpshape');
/**
* DC offset of the LFO for the lowpass filter
*
* @name lpdc
* @param {number | Pattern} dcoffset dc offset. set to 0 for unipolar
*/
export const { lpdc } = registerControl('lpdc');
/**
* Skew of the LFO for the lowpass filter
*
* @name lpskew
* @param {number | Pattern} skew How much to bend the LFO shape
*/
export const { lpskew } = registerControl('lpskew');
/**
* Rate of the LFO for the bandpass filter
*
* @name bprate
* @param {number | Pattern} rate rate in hertz
*/
export const { bprate } = registerControl('bprate');
/**
* Cycle-synced rate of the LFO for the bandpass filter
*
* @name bpsync
* @param {number | Pattern} rate rate in cycles
*/
export const { bpsync } = registerControl('bpsync');
/**
* Depth of the LFO for the bandpass filter
*
* @name bpdepth
* @param {number | Pattern} depth depth of modulation
*/
export const { bpdepth } = registerControl('bpdepth');
/**
* Depth of the LFO for the bandpass filter, in HZ
*
* @name bpdepthfrequency
* @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, bpdepthfreq } = registerControl('bpdepthfrequency', 'bpdepthfreq');
/**
* Shape of the LFO for the bandpass filter
*
* @name bpshape
* @param {number | Pattern} shape Shape of the lfo (0, 1, 2, ..)
*/
export const { bpshape } = registerControl('bpshape');
/**
* DC offset of the LFO for the bandpass filter
*
* @name bpdc
* @param {number | Pattern} dcoffset dc offset. set to 0 for unipolar
*/
export const { bpdc } = registerControl('bpdc');
/**
* Skew of the LFO for the bandpass filter
*
* @name bpskew
* @param {number | Pattern} skew How much to bend the LFO shape
*/
export const { bpskew } = registerControl('bpskew');
/**
* Rate of the LFO for the highpass filter
*
* @name hprate
* @param {number | Pattern} rate rate in hertz
*/
export const { hprate } = registerControl('hprate');
/**
* Cycle-synced rate of the LFO for the highpass filter
*
* @name hpsync
* @param {number | Pattern} rate rate in cycles
*/
export const { hpsync } = registerControl('hpsync');
/**
* Depth of the LFO for the highpass filter
*
* @name hpdepth
* @param {number | Pattern} depth depth of modulation
*/
export const { hpdepth } = registerControl('hpdepth');
/**
* Depth of the LFO for the hipass filter, in hz
*
* @name hpdepthfrequency
* @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, hpdepthfreq } = registerControl('hpdepthfrequency', 'hpdepthfreq');
/**
* Shape of the LFO for the highpass filter
*
* @name hpshape
* @param {number | Pattern} shape Shape of the lfo (0, 1, 2, ..)
*/
export const { hpshape } = registerControl('hpshape');
/**
* DC offset of the LFO for the highpass filter
*
* @name hpdc
* @param {number | Pattern} dcoffset dc offset. set to 0 for unipolar
*/
export const { hpdc } = registerControl('hpdc');
/**
* Skew of the LFO for the highpass filter
*
* @name hpskew
* @param {number | Pattern} skew How much to bend the LFO shape
*/
export const { hpskew } = registerControl('hpskew');
/**
* Applies a vibrato to the frequency of the oscillator.
*
@@ -1675,12 +2023,12 @@ export const { nudge } = registerControl('nudge');
* Sets the default octave of a synth.
*
* @name octave
* @synonyms oct
* @param {number | Pattern} octave octave number
* @example
* n("0,4,7").s('supersquare').octave("<3 4 5 6>").osc()
* @superDirtOnly
* n("0,4,7").scale("F:minor").s('supersaw').octave("<0 1 2 3>")
*/
export const { octave } = registerControl('octave');
export const { octave, oct } = registerControl('octave', 'oct');
// ['ophatdecay'],
// TODO: example
@@ -1688,6 +2036,7 @@ export const { octave } = registerControl('octave');
* An `orbit` is a global parameter context for patterns. Patterns with the same orbit will share the same global effects.
*
* @name orbit
* @synonyms o
* @param {number | Pattern} number
* @example
* stack(
@@ -1695,7 +2044,29 @@ export const { octave } = registerControl('octave');
* s("~ sd ~ sd").delay(.5).delaytime(.125).orbit(2)
* )
*/
export const { orbit } = registerControl('orbit');
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
@@ -1740,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
@@ -1750,17 +2120,64 @@ export const { semitone } = registerControl('semitone');
// TODO: synth param
export const { voice } = registerControl('voice');
// voicings // https://codeberg.org/uzu/strudel/issues/506
// chord to voice, like C Eb Fm7 G7. the symbols can be defined via addVoicings
/**
* The chord to voice
* @name chord
* @param {string | Pattern} symbols chord symbols to voice e.g., C, Eb, Fm7, G7. The symbols can be defined via addVoicings
* @example
* chord("<Am C D F Am E Am E>").voicing()
**/
export const { chord } = registerControl('chord');
// which dictionary to use for the voicings
/**
* Which dictionary to use for the voicings. This falls back to the default dictionary if not provided
*
* @name dictionary
* @param {string} dictionaryName which dictionary (having been defined with `addVoicings`) to use
* @example
* addVoicings('house', {
'': ['7 12 16', '0 7 16', '4 7 12'],
'm': ['0 3 7']
})
chord("<Am C D F Am E Am E>")
.dict('house').anchor(66)
.voicing().room(.5)
**/
export const { dictionary, dict } = registerControl('dictionary', 'dict');
// the top note to align the voicing to, defaults to c5
/** The top note to align the voicing to. Defaults to c5
*
* @name anchor
* @param {string | Pattern} anchorNote the note to align the voicings to
* @example
* anchor("<c4 g4 c5 g5>").chord("C").voicing()
**/
export const { anchor } = registerControl('anchor');
// how the voicing is offset from the anchored position
/**
* Sets how the voicing is offset from the anchored position
*
* @name offset
* @param {number | Pattern} shift the amount to shift the voicing up or down
* @example
* chord("<Am C D F Am E Am E>").offset("<0 1 2 3 4 5>") // alter the voicing each time
**/
export const { offset } = registerControl('offset');
// how many octaves are voicing steps spread apart, defaults to 1
/**
* How many octaves are voicing steps spread apart, defaults to 1
*
* @name octaves
* @param {number | Pattern} count the number of octaves
* @example
* chord("<Am C D F Am E Am E>").octaves("<2 4>").voicing()
**/
export const { octaves } = registerControl('octaves');
// below = anchor note will be removed from the voicing, useful for melody harmonization
/**
* Remove anchor note from the voicing. Useful for melody harmonization
*
* @name mode
* @param {string | Pattern} modeName one of {below | above | duck | root}
* @example
* mode("<below above duck root>").chord("C").voicing()
*
**/
export const { mode } = registerControl(['mode', 'anchor']);
/**
@@ -2072,7 +2489,6 @@ export const { tsdelay } = registerControl('tsdelay');
export const { real } = registerControl('real');
export const { imag } = registerControl('imag');
export const { enhance } = registerControl('enhance');
export const { partials } = registerControl('partials');
export const { comb } = registerControl('comb');
export const { smear } = registerControl('smear');
export const { scram } = registerControl('scram');
@@ -2121,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');
@@ -2359,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);
});
});
@@ -2392,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');
+7 -7
View File
@@ -35,18 +35,18 @@ const right = function (n, x) {
return result;
};
const _bjork = function (n, x) {
const _bjorklund = function (n, x) {
const [ons, offs] = n;
return Math.min(ons, offs) <= 1 ? [n, x] : _bjork(...(ons > offs ? left(n, x) : right(n, x)));
return Math.min(ons, offs) <= 1 ? [n, x] : _bjorklund(...(ons > offs ? left(n, x) : right(n, x)));
};
export const bjork = function (ons, steps) {
export const bjorklund = function (ons, steps) {
const inverted = ons < 0;
const absOns = Math.abs(ons);
const offs = steps - absOns;
const ones = Array(absOns).fill([1]);
const zeros = Array(offs).fill([0]);
const result = _bjork([absOns, offs], [ones, zeros]);
const result = _bjorklund([absOns, offs], [ones, zeros]);
const pattern = flatten(result[1][0]).concat(flatten(result[1][1]));
return inverted ? pattern.map((x) => 1 - x) : pattern;
};
@@ -128,7 +128,7 @@ export const bjork = function (ons, steps) {
*/
const _euclidRot = function (pulses, steps, rotation) {
const b = bjork(pulses, steps);
const b = bjorklund(pulses, steps);
if (rotation) {
return rotate(b, -rotation);
}
@@ -139,7 +139,7 @@ export const euclid = register('euclid', function (pulses, steps, pat) {
return pat.struct(_euclidRot(pulses, steps, 0));
});
export const e = register('e', function (euc, pat) {
export const bjork = register('bjork', function (euc, pat) {
if (!Array.isArray(euc)) {
euc = [euc];
}
@@ -216,6 +216,6 @@ export const euclidLegatoRot = register(['euclidLegatoRot'], function (pulses, s
* .pan(sine.slow(8))
*/
export const { euclidish, eish } = register(['euclidish', 'eish'], function (pulses, steps, perc, pat) {
const morphed = _morph(bjork(pulses, steps), new Array(pulses).fill(1), perc);
const morphed = _morph(bjorklund(pulses, steps), new Array(pulses).fill(1), perc);
return pat.struct(morphed).setSteps(steps);
});
+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';
+5 -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,11 +31,15 @@
},
"homepage": "https://strudel.cc",
"dependencies": {
"@kabelsalat/web": "^0.4.1",
"fraction.js": "^5.2.1"
},
"gitHead": "0e26d4e741500f5bae35b023608f062a794905c2",
"devDependencies": {
"vite": "^6.0.11",
"vitest": "^3.0.4"
},
"engines": {
"node": ">=18.0.0"
}
}
+252 -19
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.
@@ -2574,8 +2605,8 @@ export const { chunkBack, chunkback } = register(
* @returns Pattern
* @example
* "<0 8> 1 2 3 4 5 6 7"
* .fastChunk(4, x => x.color('red')).slow(2)
* .scale("C2:major").note()
* .fastChunk(4, x => x.color('red')).slow(2)
*/
export const { fastchunk, fastChunk } = register(
['fastchunk', 'fastChunk'],
@@ -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,11 +3681,179 @@ 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
*
* @name parray
* @returns Pattern
*/
export const parray = (pats) => {
const pack = (...xs) => xs;
let acc = pure(curry(pack, null, pats.length));
for (const p of pats) acc = acc.appBoth(reify(p));
return acc;
};
const _ensureListPattern = (list) => {
if (Array.isArray(list)) {
return parray(list);
}
return reify(list);
};
/**
* Scale the magnitude of the harmonics of one of the core synths ('sine', 'tri', 'saw', ..)
*
* Can also be used to create a new synth via `s('user').partials(...)`
*
* @name partials
* @param {number[] | Pattern} magnitudes List of [0, 1] magnitudes for partials. 0th entry is the fundamental harmonic (i.e. DC offset is skipped)
* @example
* s("user").seg(16).n(irand(8)).scale("A:major")
* .partials([1, 0, 1, 0, 0, 1])
* @example
* s("saw").seg(8).n(irand(12)).scale("G#:minor")
* .partials(binaryL(irand(256).add("1")))
*/
Pattern.prototype.partials = function (list) {
return this.withValue((v) => (l) => ({ ...v, partials: l })).appLeft(_ensureListPattern(list));
};
// Also create a top-level function
export const partials = (list) => {
return _ensureListPattern(list).as('partials');
};
/**
* Rotates the harmonics of one of the core synths ('sine', 'tri', 'saw', 'user', ..) by a list of phases
*
* @name phases
* @param {number[] | Pattern} phases List of [0, 1) phases for partials. 0th entry is the fundamental phase (i.e. DC offset is skipped)
* @example
* // Phase cancellation
* s("saw").seg(8).n(irand(12)).scale("G#1:minor")
* .partials(partials([1, 1, 1]))
* .superimpose(x => x.phases([0.5, 0.5, 0.5]))
*/
Pattern.prototype.phases = function (list) {
return this.withValue((v) => (l) => ({ ...v, phases: l })).appLeft(_ensureListPattern(list));
};
// Also create a top-level function
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);
+40 -8
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?
@@ -152,9 +173,9 @@ export function repl({
// allows muting a pattern x with x_ or _x
return silence;
}
if (id === '$') {
if (id.includes('$')) {
// allows adding anonymous patterns with $:
id = `$${anonymousIndex}`;
id = `${id}${anonymousIndex}`;
anonymousIndex++;
}
pPatterns[id] = this;
@@ -194,6 +215,7 @@ export function repl({
setcps: setCps,
setCpm,
setcpm: setCpm,
compileKabel,
});
};
@@ -215,8 +237,19 @@ export function repl({
let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptions);
if (Object.keys(pPatterns).length) {
let patterns = [];
let soloActive = false;
for (const [key, value] of Object.entries(pPatterns)) {
patterns.push(value.withState((state) => state.setControls({ id: key })));
// handle soloed patterns ex: S$: s("bd!4")
const isSolod = key.length > 1 && key.startsWith('S');
if (isSolod && soloActive === false) {
// first time we see a soloed pattern, clear existing patterns
patterns = [];
soloActive = true;
}
if (!soloActive || (soloActive && isSolod)) {
const valWithState = value.withState((state) => state.setControls({ id: key }));
patterns.push(valWithState);
}
}
if (eachTransform) {
// Explicit lambda so only element (not index and array) are passed
@@ -227,14 +260,13 @@ export function repl({
pattern = eachTransform(pattern);
}
if (allTransforms.length) {
for (let i in allTransforms) {
pattern = allTransforms[i](pattern);
for (const transform of allTransforms) {
pattern = transform(pattern);
}
}
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;
}
+218 -41
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,39 +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;
// Set first random value to be 0.5, otherwise it would be 0. https://github.com/tidalcycles/strudel/issues/1293
const timeToRand = (x) => (x === 0 ? 0.5 : 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
@@ -229,7 +287,7 @@ const timeToRands = (t, n) => timeToRandsPrime(timeToIntSeed(t), n);
export const run = (n) => saw.range(0, n).round().segment(n);
/**
* Creates a pattern from a binary number.
* Creates a binary pattern from a number.
*
* @name binary
* @param {number} n - input number to convert to binary
@@ -243,7 +301,7 @@ export const binary = (n) => {
};
/**
* Creates a pattern from a binary number, padded to n bits long.
* Creates a binary pattern from a number, padded to n bits long.
*
* @name binaryN
* @param {number} n - input number to convert to binary
@@ -259,10 +317,55 @@ export const binaryN = (n, nBits = 16) => {
return reify(n).segment(nBits).brshift(bitPos).band(pure(1));
};
/**
* Creates a binary list pattern from a number.
*
* @name binaryL
* @param {number} n - input number to convert to binary
* s("saw").seg(8)
* .partials(binaryL(irand(4096).add(1)))
*/
export const binaryL = (n) => {
const nBits = reify(n).log2(0).floor().add(1);
return binaryNL(n, nBits);
};
/**
* Creates a binary list pattern from a number, padded to n bits long.
*
* @name binaryNL
* @param {number} n - input number to convert to binary
* @param {number} nBits - pattern length, defaults to 16
*/
export const binaryNL = (n, nBits = 16) => {
return reify(n)
.withValue((v) => (bits) => {
const bList = [];
for (let i = bits - 1; i >= 0; i--) {
bList.push((v >> i) & 1);
}
return bList;
})
.appLeft(reify(nBits));
};
/**
* Creates a list of random numbers of the given length
*
* @name randL
* @param {number} n Number of random numbers to sample
* @example
* s("saw").seg(16).n(irand(12)).scale("F1:minor")
* .partials(randL(8))
*/
export const randL = (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]))
@@ -303,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.
*
@@ -312,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
*/
@@ -480,7 +614,7 @@ export const wchoose = (...pairs) => wchooseWith(rand, ...pairs);
* @example
* wchooseCycles(["bd",10], ["hh",1], ["sd",1]).s().fast(8)
* @example
* wchooseCycles(["bd bd bd",5], ["hh hh hh",3], ["sd sd sd",1]).fast(4).s()
* wchooseCycles(["c c c",5], ["a a a",3], ["f f f",1]).fast(4).note()
* @example
* // The probability can itself be a pattern
* wchooseCycles(["bd(3,8)","<5 0>"], ["hh hh hh",3]).fast(4).s()
@@ -489,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.
*
@@ -528,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,
@@ -540,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',
@@ -838,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)];
});
+8 -8
View File
@@ -1,14 +1,14 @@
import { bjork } from '../euclid.mjs';
import { bjorklund } from '../euclid.mjs';
import { describe, expect, it } from 'vitest';
import { fastcat } from '../pattern.mjs';
describe('bjork', () => {
it('should apply bjorklund to ons and steps', () => {
expect(bjork(3, 8)).toStrictEqual([1, 0, 0, 1, 0, 0, 1, 0]);
expect(bjork(-3, 8)).toStrictEqual([0, 1, 1, 0, 1, 1, 0, 1]);
expect(bjork(8, 8)).toStrictEqual([1, 1, 1, 1, 1, 1, 1, 1]);
expect(bjork(-8, 8)).toStrictEqual([0, 0, 0, 0, 0, 0, 0, 0]);
expect(bjork(5, 8)).toStrictEqual([1, 0, 1, 1, 0, 1, 1, 0]);
describe('bjorklund', () => {
it('should apply bjorklundlund to ons and steps', () => {
expect(bjorklund(3, 8)).toStrictEqual([1, 0, 0, 1, 0, 0, 1, 0]);
expect(bjorklund(-3, 8)).toStrictEqual([0, 1, 1, 0, 1, 1, 0, 1]);
expect(bjorklund(8, 8)).toStrictEqual([1, 1, 1, 1, 1, 1, 1, 1]);
expect(bjorklund(-8, 8)).toStrictEqual([0, 0, 0, 0, 0, 0, 0, 0]);
expect(bjorklund(5, 8)).toStrictEqual([1, 0, 1, 1, 0, 1, 1, 0]);
});
});
+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;
}
+7 -3
View File
@@ -7,8 +7,8 @@ This program is free software: you can redistribute it and/or modify it under th
import { logger } from './logger.mjs';
// returns true if the given string is a note
export const isNoteWithOctave = (name) => /^[a-gA-G][#bs]*[0-9]$/.test(name);
export const isNote = (name) => /^[a-gA-G][#bsf]*-?[0-9]?$/.test(name);
export const isNoteWithOctave = (name) => /^[a-gA-G][#bsf]*[0-9]*$/.test(name);
export const isNote = (name) => /^[a-gA-G][#bsf]*-?[0-9]*$/.test(name);
export const tokenizeNote = (note) => {
if (typeof note !== 'string') {
return [];
@@ -23,6 +23,10 @@ export const tokenizeNote = (note) => {
const chromas = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
const accs = { '#': 1, b: -1, s: 1, f: -1 };
export const getAccidentalsOffset = (accidentals) => {
return accidentals?.split('').reduce((o, char) => o + accs[char], 0) || 0;
};
// turns the given note into its midi number representation
export const noteToMidi = (note, defaultOctave = 3) => {
const [pc, acc, oct = defaultOctave] = tokenizeNote(note);
@@ -30,7 +34,7 @@ export const noteToMidi = (note, defaultOctave = 3) => {
throw new Error('not a note: "' + note + '"');
}
const chroma = chromas[pc.toLowerCase()];
const offset = acc?.split('').reduce((o, char) => o + accs[char], 0) || 0;
const offset = getAccidentalsOffset(acc);
return (Number(oct) + 1) * 12 + chroma + offset;
};
export const midiToFreq = (n) => {
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel/csound",
"version": "1.2.6",
"version": "1.3.0",
"description": "csound bindings for strudel",
"main": "index.mjs",
"type": "module",
@@ -38,5 +38,8 @@
},
"devDependencies": {
"vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
}
}
+4 -1
View File
@@ -25,5 +25,8 @@
"@strudel/core": "workspace:*",
"@tauri-apps/api": "^2.2.0"
},
"homepage": "https://codeberg.org/uzu/strudel#readme"
"homepage": "https://codeberg.org/uzu/strudel#readme",
"engines": {
"node": ">=18.0.0"
}
}
+4 -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",
@@ -33,5 +33,8 @@
},
"devDependencies": {
"vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
}
}
+5 -2
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",
@@ -20,5 +20,8 @@
"bugs": {
"url": "https://codeberg.org/uzu/strudel/issues"
},
"homepage": "https://codeberg.org/uzu/strudel#readme"
"homepage": "https://codeberg.org/uzu/strudel#readme",
"engines": {
"node": ">=18.0.0"
}
}
+6
View File
@@ -40,6 +40,12 @@ const pattern = sequence([
- D-Pad
- `up`, `down`, `left`, `right` (or `u`, `d`, `l`, `r` or uppercase)
- Toggle versions: `tglUp`, `tglDown`, `tglLeft`, `tglRight`(or `tglU`, `tglD`, `tglL`, `tglR`)
- Stick Buttons
- `l3`, `r3` (or `ls`, `rs`)
- Toggle versions: `tglL3`, `tglR3` (or `tglLS`, `tglRS`)
- System Buttons
- `start`, `back` (or uppercase `START`, `BACK`)
- Toggle versions: `tglStart`, `tglBack` (or `tglSTART`, `tglBACK`)
### Analog Sticks
- Left Stick
+4
View File
@@ -29,6 +29,10 @@ The gamepad module provides access to buttons and analog sticks as normalized si
| | Toggle versions: `tglLB`, `tglRB`, `tglLT`, `tglRT` |
| D-Pad | `up`, `down`, `left`, `right` (or `u`, `d`, `l`, `r` or uppercase) |
| | Toggle versions: `tglUp`, `tglDown`, `tglLeft`, `tglRight` (or `tglU`, `tglD`, `tglL`, `tglR`) |
| Stick Buttons | `l3`, 'r3' (or `ls`, `rs`) |
| | Toggle versions: `tglL3`, 'tglR3' (or `tglLs`, `tglRs`) |
| System Buttons | `start`, `back` (or uppercase `START`, `BACK`) |
| | Toggle versions: `tglStart`, `tglBack` (or `tglSTART`, `tglBACK`) |
### Analog Sticks
+4
View File
@@ -14,6 +14,10 @@ export const buttonMap = {
rt: 7,
back: 8,
start: 9,
l3: 10,
ls: 10,
r3: 11,
rs: 11,
u: 12,
up: 12,
d: 13,
+4 -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",
@@ -33,5 +33,8 @@
},
"devDependencies": {
"vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
}
}
+3
View File
@@ -34,5 +34,8 @@
"devDependencies": {
"tree-sitter-haskell": "^0.23.1",
"vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
}
}
+1
View File
@@ -35,6 +35,7 @@ export async function initHydra(options = {}) {
hydra.synth.s0.init({ src: canvas });
}
}
return hydra;
}
export function clearHydra() {
+4 -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",
@@ -40,5 +40,8 @@
"devDependencies": {
"pkg": "^5.8.1",
"vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
}
}
+248 -63
View File
@@ -5,9 +5,24 @@ This program is free software: you can redistribute it and/or modify it under th
*/
import * as _WebMidi from 'webmidi';
import { Pattern, getEventOffsetMs, 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:
export const { WebMidi } = _WebMidi;
@@ -190,7 +205,7 @@ function mapCC(mapping, value) {
}
// sends a cc message to the given device on the given channel
function sendCC(ccn, ccv, device, midichan, timeOffsetString) {
function sendCC(ccn, ccv, device, midichan, targetTime) {
if (typeof ccv !== 'number' || ccv < 0 || ccv > 1) {
throw new Error('expected ccv to be a number between 0 and 1');
}
@@ -198,19 +213,23 @@ function sendCC(ccn, ccv, device, midichan, timeOffsetString) {
throw new Error('expected ccn to be a number or a string');
}
const scaled = Math.round(ccv * 127);
device.sendControlChange(ccn, scaled, midichan, { time: timeOffsetString });
scheduleAtTime(() => {
device.sendControlChange(ccn, scaled, midichan);
}, targetTime);
}
// sends a program change message to the given device on the given channel
function sendProgramChange(progNum, device, midichan, timeOffsetString) {
function sendProgramChange(progNum, device, midichan, targetTime) {
if (typeof progNum !== 'number' || progNum < 0 || progNum > 127) {
throw new Error('expected progNum (program change) to be a number between 0 and 127');
}
device.sendProgramChange(progNum, midichan, { time: timeOffsetString });
scheduleAtTime(() => {
device.sendProgramChange(progNum, midichan);
}, targetTime);
}
// sends a sysex message to the given device on the given channel
function sendSysex(sysexid, sysexdata, device, timeOffsetString) {
function sendSysex(sysexid, sysexdata, device, targetTime) {
if (Array.isArray(sysexid)) {
if (!sysexid.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
throw new Error('all sysexid bytes must be integers between 0 and 255');
@@ -225,11 +244,13 @@ function sendSysex(sysexid, sysexdata, device, timeOffsetString) {
if (!sysexdata.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
throw new Error('all sysex bytes must be integers between 0 and 255');
}
device.sendSysex(sysexid, sysexdata, { time: timeOffsetString });
scheduleAtTime(() => {
device.sendSysex(sysexid, sysexdata);
}, targetTime);
}
// sends a NRPN message to the given device on the given channel
function sendNRPN(nrpnn, nrpv, device, midichan, timeOffsetString) {
function sendNRPN(nrpnn, nrpv, device, midichan, targetTime) {
if (Array.isArray(nrpnn)) {
if (!nrpnn.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
throw new Error('all nrpnn bytes must be integers between 0 and 255');
@@ -237,28 +258,34 @@ function sendNRPN(nrpnn, nrpv, device, midichan, timeOffsetString) {
} else if (!Number.isInteger(nrpv) || nrpv < 0 || nrpv > 255) {
throw new Error('A:sysexid must be an number between 0 and 255 or an array of such integers');
}
device.sendNRPN(nrpnn, nrpv, midichan, { time: timeOffsetString });
scheduleAtTime(() => {
device.sendNRPN(nrpnn, nrpv, midichan);
}, targetTime);
}
// sends a pitch bend message to the given device on the given channel
function sendPitchBend(midibend, device, midichan, timeOffsetString) {
function sendPitchBend(midibend, device, midichan, targetTime) {
if (typeof midibend !== 'number' || midibend < -1 || midibend > 1) {
throw new Error('expected midibend to be a number between -1 and 1');
}
device.sendPitchBend(midibend, midichan, { time: timeOffsetString });
scheduleAtTime(() => {
device.sendPitchBend(midibend, midichan);
}, targetTime);
}
// sends a channel aftertouch message to the given device on the given channel
function sendAftertouch(miditouch, device, midichan, timeOffsetString) {
function sendAftertouch(miditouch, device, midichan, targetTime) {
if (typeof miditouch !== 'number' || miditouch < 0 || miditouch > 1) {
throw new Error('expected miditouch to be a number between 0 and 1');
}
device.sendChannelAftertouch(miditouch, midichan, { time: timeOffsetString });
scheduleAtTime(() => {
device.sendChannelAftertouch(miditouch, midichan);
}, targetTime);
}
// sends a note message to the given device on the given channel
function sendNote(note, velocity, duration, device, midichan, timeOffsetString) {
function sendNote(note, velocity, duration, device, midichan, targetTime) {
if (note == null || note === '') {
throw new Error('note cannot be null or empty');
}
@@ -268,12 +295,12 @@ function sendNote(note, velocity, duration, device, midichan, timeOffsetString)
if (duration != null && (typeof duration !== 'number' || duration < 0)) {
throw new Error('duration must be a positive number');
}
const midiNumber = typeof note === 'number' ? note : noteToMidi(note);
const midiNote = new Note(midiNumber, { attack: velocity, duration });
device.playNote(midiNote, midichan, {
time: timeOffsetString,
});
scheduleAtTime(() => {
device.playNote(midiNote, midichan);
}, targetTime);
}
/**
@@ -309,7 +336,6 @@ Pattern.prototype.midi = function (midiport, options = {}) {
let midiConfig = {
// Default configuration values
isController: false, // Disable sending notes for midi controllers
latencyMs: 34, // Default latency to get audio engine to line up in ms
noteOffsetMs: 10, // Default note-off offset to prevent glitching in ms
midichannel: 1, // Default MIDI channel
velocity: 0.9, // Default velocity
@@ -333,18 +359,13 @@ Pattern.prototype.midi = function (midiport, options = {}) {
logger(`Midi device disconnected! Available: ${getMidiDeviceNamesString(outputs)}`),
});
return this.onTrigger((hap, currentTime, cps, targetTime) => {
return this.onTrigger((hap, _currentTime, cps, targetTime) => {
if (!WebMidi.enabled) {
logger('Midi not enabled');
return;
}
hap.ensureObjectValue();
//magic number to get audio engine to line up, can probably be calculated somehow
const latencyMs = midiConfig.latencyMs;
// passing a string with a +num into the webmidi api adds an offset to the current time https://webmidijs.org/api/classes/Output
const timeOffsetString = `+${getEventOffsetMs(targetTime, currentTime) + latencyMs}`;
// midi event values from hap with configurable defaults
let {
note,
@@ -380,7 +401,7 @@ Pattern.prototype.midi = function (midiport, options = {}) {
// if midimap is set, send a cc messages from defined controls
if (midicontrolMap.has(midimap)) {
const ccs = mapCC(midicontrolMap.get(midimap), hap.value);
ccs.forEach(({ ccn, ccv }) => sendCC(ccn, ccv, device, midichan, timeOffsetString));
ccs.forEach(({ ccn, ccv }) => sendCC(ccn, ccv, device, midichan, targetTime));
} else if (midimap !== 'default') {
// Add warning when a non-existent midimap is specified
logger(`[midi] midimap "${midimap}" not found! Available maps: ${[...midicontrolMap.keys()].join(', ')}`);
@@ -392,12 +413,12 @@ Pattern.prototype.midi = function (midiport, options = {}) {
// try to prevent glitching by subtracting noteOffsetMs from the duration length
const duration = (hap.duration.valueOf() / cps) * 1000 - midiConfig.noteOffsetMs;
sendNote(note, velocity, duration, device, midichan, timeOffsetString);
sendNote(note, velocity, duration, device, midichan, targetTime);
}
// Handle program change
if (progNum !== undefined) {
sendProgramChange(progNum, device, midichan, timeOffsetString);
sendProgramChange(progNum, device, midichan, targetTime);
}
// Handle sysex
@@ -407,74 +428,76 @@ Pattern.prototype.midi = function (midiport, options = {}) {
// if sysexid is an array the first byte is 0x00
if (sysexid !== undefined && sysexdata !== undefined) {
sendSysex(sysexid, sysexdata, device, timeOffsetString);
sendSysex(sysexid, sysexdata, device, targetTime);
}
// Handle control change
if (ccv !== undefined && ccn !== undefined) {
sendCC(ccn, ccv, device, midichan, timeOffsetString);
sendCC(ccn, ccv, device, midichan, targetTime);
}
// Handle NRPN non-registered parameter number
if (nrpnn !== undefined && nrpv !== undefined) {
sendNRPN(nrpnn, nrpv, device, midichan, timeOffsetString);
sendNRPN(nrpnn, nrpv, device, midichan, targetTime);
}
// Handle midibend
if (midibend !== undefined) {
sendPitchBend(midibend, device, midichan, timeOffsetString);
sendPitchBend(midibend, device, midichan, targetTime);
}
// Handle miditouch
if (miditouch !== undefined) {
sendAftertouch(miditouch, device, midichan, timeOffsetString);
sendAftertouch(miditouch, device, midichan, targetTime);
}
// Handle midicmd
if (hap.whole.begin + 0 === 0) {
// we need to start here because we have the timing info
device.sendStart({ time: timeOffsetString });
scheduleAtTime(() => {
device.sendStart();
}, targetTime);
}
if (['clock', 'midiClock'].includes(midicmd)) {
device.sendClock({ time: timeOffsetString });
scheduleAtTime(() => {
device.sendClock();
}, targetTime);
} else if (['start'].includes(midicmd)) {
device.sendStart({ time: timeOffsetString });
scheduleAtTime(() => {
device.sendStart();
}, targetTime);
} else if (['stop'].includes(midicmd)) {
device.sendStop({ time: timeOffsetString });
scheduleAtTime(() => {
device.sendStop();
}, targetTime);
} else if (['continue'].includes(midicmd)) {
device.sendContinue({ time: timeOffsetString });
scheduleAtTime(() => {
device.sendContinue();
}, targetTime);
} else if (Array.isArray(midicmd)) {
if (midicmd[0] === 'progNum') {
sendProgramChange(midicmd[1], device, midichan, timeOffsetString);
sendProgramChange(midicmd[1], device, midichan, targetTime);
} else if (midicmd[0] === 'cc') {
if (midicmd.length === 2) {
sendCC(midicmd[0], midicmd[1] / 127, device, midichan, timeOffsetString);
sendCC(midicmd[0], midicmd[1] / 127, device, midichan, targetTime);
}
} else if (midicmd[0] === 'sysex') {
if (midicmd.length === 3) {
const [_, id, data] = midicmd;
sendSysex(id, data, device, timeOffsetString);
sendSysex(id, data, device, targetTime);
}
}
}
});
};
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'
}')`,
);
@@ -483,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;
}
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel/midi",
"version": "1.2.6",
"version": "1.3.0",
"description": "Midi API for strudel",
"main": "index.mjs",
"type": "module",
@@ -35,5 +35,8 @@
},
"devDependencies": {
"vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
}
}
+4 -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",
@@ -38,5 +38,8 @@
"peggy": "^4.2.0",
"vite": "^6.0.11",
"vitest": "^3.0.4"
},
"engines": {
"node": ">=18.0.0"
}
}
+8 -5
View File
@@ -21,13 +21,14 @@ export class MondoParser {
close_curly: /^\}/,
number: /^-?[0-9]*\.?[0-9]+/, // before pipe!
// TODO: better error handling when "-" is used as rest, e.g "s [- bd]"
op: /^[*/:!@%?+-]|^\.{2}/, // * / : ! @ % ? ..
op: /^[*/:!@%?+\-&]|^\.{2}/, // * / : ! @ % ? ..
// dollar: /^\$/,
pipe: /^#/,
stack: /^[,$]/,
or: /^[|]/,
plain: /^[a-zA-Z0-9-~_^#]+/,
};
op_precedence = [['*', '/', ':', '!', '@', '%', '?', '+', '-', '..'], ['&']];
// matches next token
next_token(code, offset = 0) {
for (let type in this.token_types) {
@@ -150,9 +151,9 @@ export class MondoParser {
}
return children;
}
desugar_ops(children) {
desugar_ops(children, types) {
while (true) {
let opIndex = children.findIndex((child) => child.type === 'op');
let opIndex = children.findIndex((child) => child.type === 'op' && types.includes(child.value));
if (opIndex === -1) break;
const op = { type: 'plain', value: children[opIndex].value };
if (opIndex === children.length - 1) {
@@ -263,8 +264,10 @@ export class MondoParser {
// the type we've removed before splitting needs to be added back
children = [{ type: 'plain', value: type }, ...children];
}
children = this.desugar_ops(children);
// children = this.desugar_pipes(children, (children) => this.desugar_dollars(children));
// for each precendence group, call desugar_ops once
this.op_precedence.forEach((ops) => {
children = this.desugar_ops(children, ops);
});
children = this.desugar_pipes(children);
return children;
}),
+4 -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",
@@ -33,5 +33,8 @@
"devDependencies": {
"vite": "^6.0.11",
"vitest": "^3.0.4"
},
"engines": {
"node": ">=18.0.0"
}
}
+1
View File
@@ -117,6 +117,7 @@ describe('mondo sugar', () => {
it('should desugar x:y', () => expect(desguar('x:y')).toEqual('(: y x)'));
it('should desugar x:y:z', () => expect(desguar('x:y:z')).toEqual('(: z (: y x))'));
it('should desugar x:y*x', () => expect(desguar('bd:0*2')).toEqual('(* 2 (: 0 bd))'));
it('should desugar x&y:z', () => expect(desguar('bd&3:8')).toEqual('(& (: 8 3) bd)'));
it('should desugar a..b', () => expect(desguar('0..2')).toEqual('(.. 2 0)'));
/* it('should desugar x $ y', () => expect(desguar('x $ y')).toEqual('(x y)'));
it('should desugar x $ y z', () => expect(desguar('x $ y z')).toEqual('(x (y z))'));
+2
View File
@@ -11,6 +11,7 @@ import {
chooseIn,
degradeBy,
silence,
bjork,
} from '@strudel/core';
import { registerLanguage } from '@strudel/transpiler';
import { MondoRunner } from 'mondolang';
@@ -40,6 +41,7 @@ lib['!'] = replicate;
lib['@'] = expand;
lib['%'] = pace;
lib['?'] = degradeBy; // todo: default 0.5 not working..
lib['&'] = bjork;
lib[':'] = tail;
lib['..'] = range;
lib['def'] = () => silence;
+4 -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",
@@ -40,5 +40,8 @@
"mondo": "*",
"vite": "^6.0.11",
"vitest": "^3.0.4"
},
"engines": {
"node": ">=18.0.0"
}
}
+4 -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",
@@ -33,5 +33,8 @@
},
"devDependencies": {
"vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
}
}
+4 -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",
@@ -34,5 +34,8 @@
},
"devDependencies": {
"vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
}
}
+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.
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel/osc",
"version": "1.3.0",
"version": "1.3.2",
"description": "OSC messaging for strudel",
"main": "osc.mjs",
"bin": "./server.js",
@@ -44,5 +44,8 @@
"devDependencies": {
"pkg": "^5.8.1",
"vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
}
}
+4 -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",
@@ -33,5 +33,8 @@
"homepage": "https://codeberg.org/uzu/strudel#readme",
"devDependencies": {
"vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
}
}
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel/repl",
"version": "1.2.7",
"version": "1.3.0",
"description": "Strudel REPL as a Web Component",
"module": "index.mjs",
"publishConfig": {
@@ -48,5 +48,8 @@
"@rollup/plugin-replace": "^6.0.2",
"vite": "^6.0.11",
"vite-plugin-bundle-audioworklet": "workspace:*"
},
"engines": {
"node": ">=18.0.0"
}
}
+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();
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel/sampler",
"version": "0.2.3",
"version": "0.2.4",
"description": "",
"keywords": [
"tidalcycles",
@@ -15,5 +15,8 @@
"type": "module",
"dependencies": {
"cowsay": "^1.6.0"
},
"engines": {
"node": ">=18.0.0"
}
}
+1 -3
View File
@@ -9,6 +9,7 @@ import readline from 'readline';
import os from 'os';
const LOG = !!process.env.LOG || false;
const PORT = process.env.PORT || 5432;
const VALID_AUDIO_EXTENSIONS = ['wav', 'mp3', 'ogg'];
const isAudioFile = (f) => {
@@ -54,7 +55,6 @@ async function getBanks(directory, flat = false) {
banks[bank].push(subDir);
return subDir;
});
banks._base = `http://localhost:5432`;
return { banks, files };
}
@@ -134,8 +134,6 @@ const server = http.createServer(async (req, res) => {
readStream.pipe(res);
});
// eslint-disable-next-line
const PORT = process.env.PORT || 5432;
const IP_ADDRESS = '0.0.0.0';
let IP;
const networkInterfaces = os.networkInterfaces();
+4 -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",
@@ -33,5 +33,8 @@
},
"devDependencies": {
"vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
}
}
+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 },
);
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel/soundfonts",
"version": "1.2.6",
"version": "1.3.0",
"description": "Soundsfont support for strudel",
"main": "index.mjs",
"publishConfig": {
@@ -37,5 +37,8 @@
"devDependencies": {
"node-fetch": "^3.3.2",
"vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
}
}
+1 -1
View File
@@ -89,7 +89,7 @@ superdough({ s: 'bd', delay: 0.5 }, 0, 1);
- `decay`: seconds of decay phase
- `sustain`: gain of sustain phase
- `release`: seconds of release phase
- `deadline`: seconds until the sound should play (0 = immediate)
- `deadline`: seconds from audio context initialization before playing the sound (getAudioContextCurrentTime() = immediate)
- `duration`: seconds the sound should last. optional for one shot samples, required for synth sounds
### registerSynthSounds()
+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);
};
}
+290 -131
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
@@ -150,10 +172,31 @@ export const getADSRValues = (params, curve = 'linear', defaultValues) => {
if (a == null && d == null && s == null && r == null) {
return defaultValues ?? [envmin, envmin, envmax, releaseMin];
}
const sustain = s != null ? s : (a != null && d == null) || (a == null && d == null) ? envmax : envmin;
return [Math.max(a ?? 0, envmin), Math.max(d ?? 0, envmin), Math.min(sustain, envmax), Math.max(r ?? 0, releaseMin)];
};
export function getParamLfo(audioContext, param, start, end, lfoValues) {
let { defaultDepth = 1, depth, dcoffset, ...getLfoInputs } = lfoValues;
if (depth == null) {
const hasLFOParams = Object.values(getLfoInputs).some((v) => v != null);
depth = hasLFOParams ? defaultDepth : 0;
}
let lfo;
if (depth) {
lfo = getLfo(audioContext, {
begin: start,
end,
depth,
dcoffset,
...getLfoInputs,
});
lfo.connect(param);
}
return lfo;
}
// helper utility for applying standard modulators to a parameter
export function applyParameterModulators(audioContext, param, start, end, envelopeValues, lfoValues) {
let { amount, offset, defaultAmount = 1, curve = 'linear', values, holdEnd, defaultValues } = envelopeValues;
@@ -170,56 +213,80 @@ export function applyParameterModulators(audioContext, param, start, end, envelo
const [attack, decay, sustain, release] = getADSRValues(values, curve, defaultValues);
getParamADSR(param, attack, decay, sustain, release, min, max, start, holdEnd, curve);
}
let lfo;
let { defaultDepth = 1, depth, dcoffset, ...getLfoInputs } = lfoValues;
if (depth == null) {
const hasLFOParams = Object.values(getLfoInputs).some((v) => v != null);
depth = hasLFOParams ? defaultDepth : 0;
}
if (depth) {
lfo = getLfo(audioContext, start, end, {
depth,
dcoffset,
...getLfoInputs,
});
lfo.connect(param);
}
return { lfo, disconnect: () => lfo?.disconnect() };
const lfo = getParamLfo(audioContext, param, start, end, lfoValues);
return lfo;
}
export function createFilter(context, start, end, params, cps, cycle) {
let {
frequency,
anchor,
env,
type,
model,
q = 1,
drive = 0.69,
depth,
depthfrequency,
dcoffset = -0.5,
skew,
shape,
rate,
sync,
} = params;
export function createFilter(context, type, frequency, Q, att, dec, sus, rel, fenv, start, end, fanchor, model, drive) {
const curve = 'exponential';
const [attack, decay, sustain, release] = getADSRValues([att, dec, sus, rel], curve, [0.005, 0.14, 0, 0.1]);
let filter;
let frequencyParam;
let frequencyParam, filter;
if (model === 'ladder') {
filter = getWorklet(context, 'ladder-processor', { frequency, q: Q, drive });
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];
const [attack, decay, sustain, release] = getADSRValues(envelopeValues, 'exponential', [0.005, 0.14, 0, 0.1]);
// envelope is active when any of these values is set
const hasEnvelope = att ?? dec ?? sus ?? rel ?? fenv;
const hasEnvelope = [...envelopeValues, env].some((v) => v !== undefined);
// Apply ADSR to filter frequency
if (hasEnvelope !== undefined) {
fenv = nanFallback(fenv, 1, true);
fanchor = nanFallback(fanchor, 0, true);
const fenvAbs = Math.abs(fenv);
const offset = fenvAbs * fanchor;
if (hasEnvelope) {
env = nanFallback(env, 1, true);
anchor = nanFallback(anchor, 0, true);
const envAbs = Math.abs(env);
const offset = envAbs * anchor;
let min = clamp(2 ** -offset * frequency, 0, 20000);
let max = clamp(2 ** (fenvAbs - offset) * frequency, 0, 20000);
if (fenv < 0) [min, max] = [max, min];
getParamADSR(frequencyParam, attack, decay, sustain, release, min, max, start, end, curve);
return filter;
let max = clamp(2 ** (envAbs - offset) * frequency, 0, 20000);
if (env < 0) [min, max] = [max, min];
getParamADSR(frequencyParam, attack, decay, sustain, release, min, max, start, end, 'exponential');
}
return filter;
if (sync != null) {
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;
const modDepth = depthfrequency ?? (depth ?? 1) * frequency;
const lfoValues = {
depth: modDepth,
dcoffset,
skew,
shape,
frequency: rate ?? cps,
min: -frequency + 30,
max: 20000 - frequency,
time,
curve: 1,
};
lfo = getParamLfo(context, frequencyParam, start, end, lfoValues);
}
return { filter, lfo };
}
// stays 1 until .5, then fades out
@@ -241,7 +308,18 @@ export function drywet(dry, wet, wetAmount = 0) {
let mix = ac.createGain();
dry_gain.connect(mix);
wet_gain.connect(mix);
return mix;
return {
node: mix,
teardown: () => {
releaseAudioNode(dry_gain);
releaseAudioNode(wet_gain);
// it is not the responsability of drywet
// to call `releaseAudioNode` on
// the 2 external args dry and wet
dry.disconnect(dry_gain);
wet.disconnect(wet_gain);
},
};
}
let curves = ['linear', 'exponential'];
@@ -276,10 +354,19 @@ export function getVibratoOscillator(param, value, t) {
gain.gain.value = vibmod * 100;
vibratoOscillator.connect(gain);
gain.connect(param);
onceEnded(vibratoOscillator, () => {
releaseAudioNode(gain);
releaseAudioNode(vibratoOscillator);
});
vibratoOscillator.start(t);
return vibratoOscillator;
return { stop: (t) => vibratoOscillator.stop(t), nodes: { vib: [vibratoOscillator], vib_gain: [gain] } };
}
}
export function scheduleAtTime(callback, targetTime, audioContext = getAudioContext()) {
const currentTime = audioContext.currentTime;
webAudioTimeout(audioContext, callback, currentTime, targetTime);
}
// ConstantSource inherits AudioScheduledSourceNode, which has scheduling abilities
// a bit of a hack, but it works very well :)
export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) {
@@ -292,25 +379,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)) {
@@ -322,64 +401,94 @@ const mod = (freq, range = 1, type = 'sine') => {
osc.type = type;
osc.frequency.value = freq;
}
osc.start();
const g = new GainNode(ctx, { gain: range });
osc.connect(g); // -range, range
return { node: g, stop: (t) => osc.stop(t) };
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]);
}
}
return { stop };
return {
nodes,
stop: (t) => toStop.forEach((m) => m?.stop(t)),
};
}
// Saturation curves
@@ -479,7 +588,7 @@ export const getDistortion = (distort, postgain, algorithm) => {
};
export const getFrequencyFromValue = (value, defaultNote = 36) => {
let { note, freq } = value;
let { note, freq, octave = 0 } = value;
note = note || defaultNote;
if (typeof note === 'string') {
note = noteToMidi(note); // e.g. c3 => 48
@@ -488,14 +597,64 @@ export const getFrequencyFromValue = (value, defaultNote = 36) => {
if (!freq && typeof note === 'number') {
freq = midiToFreq(note); // + 48);
}
freq *= Math.pow(2, octave);
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;
};
+6 -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,8 +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);
onceEnded(noiseOscillator.node, () => {
releaseAudioNode(noiseOscillator.node);
});
return {
node: noiseMix,
node: noiseMix.node,
stop: (time) => noiseOscillator?.stop(time),
teardown: noiseMix.teardown,
};
}
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "superdough",
"version": "1.2.6",
"version": "1.3.0",
"description": "simple web audio synth and sampler intended for live coding. inspired by superdirt and webdirt.",
"main": "index.mjs",
"type": "module",
@@ -36,6 +36,10 @@
"vite-plugin-bundle-audioworklet": "workspace:*"
},
"dependencies": {
"@kabelsalat/lib": "^0.4.1",
"nanostores": "^0.11.3"
},
"engines": {
"node": ">=18.0.0"
}
}
+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
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,6 +106,8 @@ var applyGradualLowpass = function (input, lpFreqStart, lpFreqEnd, lpFreqEndAt,
player.start();
context.oncomplete = function (event) {
callback(event.renderedBuffer);
releaseAudioNode(filter);
releaseAudioNode(player);
};
context.startRendering();
+36 -32
View File
@@ -1,7 +1,14 @@
import { getCommonSampleInfo } from './util.mjs';
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) => {
@@ -211,7 +219,7 @@ export async function fetchSampleMap(url) {
// not a browser
return;
}
const base = url.split('/').slice(0, -1).join('/');
const base = getBaseURL(url);
if (typeof fetch === 'undefined') {
// skip fetch when in node / testing
return;
@@ -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) {
+520 -228
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 } 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;
}
@@ -146,17 +182,13 @@ let defaultDefaultValues = {
gain: 0.8,
postgain: 1,
density: '.03',
ftype: '12db',
fanchor: 0,
resonance: 1,
hresonance: 1,
bandq: 1,
channels: [1, 2],
phaserdepth: 0.75,
shapevol: 1,
distortvol: 1,
distorttype: 0,
delay: 0,
busgain: 1,
byteBeatExpression: '0',
delayfeedback: 0.5,
delaysync: 3 / 16,
@@ -164,6 +196,9 @@ let defaultDefaultValues = {
i: 1,
velocity: 1,
fft: 8,
tremolodepth: 1,
tremolophase: 0,
release: 0.01,
};
const defaultDefaultDefaultValues = Object.freeze({ ...defaultDefaultValues });
@@ -208,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;
@@ -229,6 +266,7 @@ export async function initAudio(options = {}) {
setMaxPolyphony(maxPolyphony);
setMultiChannelOrbits(multiChannelOrbits);
resetSeenKeys();
if (typeof window === 'undefined') {
return;
}
@@ -250,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;
@@ -268,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();
});
@@ -279,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) {
@@ -325,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;
@@ -366,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();
@@ -387,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)})`,
@@ -396,74 +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'),
drive = 0.69,
release = 0,
// low pass
cutoff,
lpenv,
lpattack,
lpdecay,
lpsustain,
lprelease,
resonance = getDefaultValue('resonance'),
// high pass
hpenv,
hcutoff,
hpattack,
hpdecay,
hpsustain,
hprelease,
hresonance = getDefaultValue('hresonance'),
// band pass
bpenv,
bandf,
bpattack,
bpdecay,
bpsustain,
bprelease,
bandq = getDefaultValue('bandq'),
//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,
@@ -473,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);
@@ -495,23 +512,19 @@ 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
for (let i = 0; i <= activeSoundSources.size - maxPolyphony; i++) {
const ch = activeSoundSources.entries().next();
const source = ch.value[1];
const source = ch.value[1].deref();
const chainID = ch.value[0];
const endTime = t + 0.25;
source?.node?.gain?.linearRampToValueAtTime(0, endTime);
@@ -519,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;
}
@@ -529,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, soundHandle);
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?`);
@@ -558,141 +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);
if (cutoff !== undefined) {
let lp = () =>
createFilter(
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);
// 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,
'lowpass',
cutoff,
resonance,
lpattack,
lpdecay,
lpsustain,
lprelease,
lpenv,
t,
end,
fanchor,
ftype,
drive,
'transient-processor',
{},
{
processorOptions: {
attack: fx.transient,
sustain: fx.transsustain,
begin: t,
end: endWithRelease,
},
},
);
chain.push(lp());
if (ftype === '24db') {
chain.push(lp());
chain.connect(transProcessor);
fxNodes['transient'] = transProcessor;
}
}
if (hcutoff !== undefined) {
let hp = () =>
createFilter(
// 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,
'highpass',
hcutoff,
hresonance,
hpattack,
hpdecay,
hpsustain,
hprelease,
hpenv,
t,
end,
fanchor,
fx.compressor,
fx.compressorRatio,
fx.compressorKnee,
fx.compressorAttack,
fx.compressorRelease,
);
chain.push(hp());
if (ftype === '24db') {
chain.push(hp());
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 (bandf !== undefined) {
let bp = () =>
createFilter(ac, 'bandpass', bandf, bandq, bpattack, bpdecay, bpsustain, bprelease, bpenv, t, end, fanchor);
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);
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);
orbitBus.sendDelay(post, delay);
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) {
@@ -707,32 +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);
orbitBus.sendReverb(post, room);
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;
}
+32 -6
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) {
@@ -78,11 +92,11 @@ export class Orbit {
return this.reverbNode;
}
sendReverb(node, amount) {
effectSend(node, this.reverbNode, amount);
return effectSend(node, this.reverbNode, amount);
}
sendDelay(node, amount) {
effectSend(node, this.delayNode, amount);
return effectSend(node, this.delayNode, amount);
}
duck(t, onsettime = 0, attacktime = 0.1, depth = 1) {
@@ -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];
}
}
+157 -80
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,11 +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'];
const waveforms = ['triangle', 'square', 'sawtooth', 'sine', 'user', 'one'];
const waveformAliases = [
['tri', 'triangle'],
['sqr', 'square'],
@@ -47,22 +50,20 @@ export function registerSynthSounds() {
[0.001, 0.05, 0.6, 0.01],
);
let sound = getOscillator(s, t, value);
let { node: o, stop, triggerRelease } = sound;
// turn down
const g = gainNode(0.3);
const sound = getOscillator(s, t, value, () => {
releaseAudioNode(g);
onended();
});
const { node: o, nodes, stop, triggerRelease } = sound;
const { duration } = value;
o.onended = () => {
o.disconnect();
g.disconnect();
onended();
};
const envGain = gainNode(1);
let node = o.connect(g).connect(envGain);
const node = o.connect(g).connect(envGain);
const holdEnd = t + duration;
getParamADSR(node.gain, attack, decay, sustain, release, 0, 1, t, holdEnd, 'linear');
const envEnd = holdEnd + release + 0.01;
@@ -70,6 +71,7 @@ export function registerSynthSounds() {
stop(envEnd);
return {
node,
nodes,
stop: (endTime) => {
stop(endTime);
},
@@ -111,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);
@@ -139,6 +141,7 @@ export function registerSynthSounds() {
return {
node,
nodes: { source: [o] },
stop: (endTime) => {
o.stop(endTime);
},
@@ -165,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);
@@ -193,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,
@@ -205,6 +207,7 @@ export function registerSynthSounds() {
return {
node: envGain,
nodes: { source: [o], ...fmHandle?.nodes, ...vibratoHandle?.nodes },
stop: (time) => {
timeoutNode.stop(time);
},
@@ -271,8 +274,7 @@ export function registerSynthSounds() {
let timeoutNode = webAudioTimeout(
ac,
() => {
destroyAudioWorkletNode(o);
envGain.disconnect();
releaseAudioNode(o);
onended();
},
begin,
@@ -281,6 +283,7 @@ export function registerSynthSounds() {
return {
node: envGain,
source: o,
stop: (time) => {
timeoutNode.stop(time);
},
@@ -331,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,
@@ -358,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);
},
@@ -366,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,
@@ -388,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);
@@ -403,6 +441,7 @@ export function registerSynthSounds() {
stop(envEnd);
return {
node,
nodes: { source: [o] },
stop: (endTime) => {
stop(endTime);
},
@@ -414,9 +453,13 @@ export function registerSynthSounds() {
waveformAliases.forEach(([alias, actual]) => soundMap.set({ ...soundMap.get(), [alias]: soundMap.get()[actual] }));
}
export function waveformN(partials, type) {
const real = new Float32Array(partials + 1);
const imag = new Float32Array(partials + 1);
const PI2 = 2 * Math.PI;
export function waveformN(partials, phases, type) {
const isList = typeof partials === 'object';
partials = isList ? partials : new Float32Array(partials).fill(1);
const len = partials.length;
const real = new Float32Array(len + 1);
const imag = new Float32Array(len + 1);
const ac = getAudioContext();
const osc = ac.createOscillator();
@@ -424,20 +467,29 @@ export function waveformN(partials, type) {
sawtooth: (n) => [0, -1 / n],
square: (n) => [0, n % 2 === 0 ? 0 : 1 / n],
triangle: (n) => [n % 2 === 0 ? 0 : 1 / (n * n), 0],
user: (_n) => [0, 1],
};
if (!terms[type]) {
throw new Error(`unknown wave type ${type}`);
}
real[0] = 0; // dc offset
imag[0] = 0;
let n = 1;
while (n <= partials) {
const [r, i] = terms[type](n);
real[n] = r;
imag[n] = i;
n++;
for (let n = 0; n < len; n++) {
const mag = partials[n];
const [r, i] = terms[type](n + 1); // we skip n === 0 as this is dc offset
const phase = phases?.[n] ?? 0;
// Scale by `partials`
let R = r * mag;
let I = i * mag;
// Apply rotation by the phase
if (phase !== 0) {
const c = Math.cos(PI2 * phase);
const s = Math.sin(PI2 * phase);
R = c * R - s * I;
I = s * R + c * I;
}
real[n + 1] = R;
imag[n + 1] = I;
}
const wave = ac.createPeriodicWave(real, imag);
@@ -446,38 +498,63 @@ export function waveformN(partials, type) {
}
// expects one of waveforms as s
export function getOscillator(s, t, value) {
let { n: partials, duration, noise = 0 } = value;
export function getOscillator(s, t, value, onended) {
const { duration, noise = 0 } = value;
const partials = value.partials ?? value.n;
let o;
// If no partials are given, use stock waveforms
if (!partials || s === 'sine') {
if (s === 'user' && !partials) {
logger(
`[superdough] Synth 'user' was selected, but partials not specified. Defaulting to triangle. Use pat.partials to setup custom waveform`,
);
s = 'triangle';
}
s = s === 'user' && !partials ? 'triangle' : s;
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';
}
// generate custom waveform if partials are given
else {
o = waveformN(partials, s);
o = waveformN(partials, value.phases, s);
}
// set frequency
o.frequency.value = getFrequencyFromValue(value);
o.start(t);
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);
}
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);
},
+20 -1
View File
@@ -16,13 +16,17 @@ export const tokenizeNote = (note) => {
const chromas = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
const accs = { '#': 1, b: -1, s: 1, f: -1 };
export const getAccidentalsOffset = (accidentals) => {
return accidentals?.split('').reduce((o, char) => o + accs[char], 0) || 0;
};
export const noteToMidi = (note, defaultOctave = 3) => {
const [pc, acc, oct = defaultOctave] = tokenizeNote(note);
if (!pc) {
throw new Error('not a note: "' + note + '"');
}
const chroma = chromas[pc.toLowerCase()];
const offset = acc?.split('').reduce((o, char) => o + accs[char], 0) || 0;
const offset = getAccidentalsOffset(acc);
return (Number(oct) + 1) * 12 + chroma + offset;
};
export const midiToFreq = (n) => {
@@ -105,3 +109,18 @@ export function getCommonSampleInfo(hapValue, bank) {
const label = `${s}:${index}`;
return { transpose, url, index, midi, label };
}
/** Selects entries from `source` and renames them via `map` */
export const pickAndRename = (source, map) => {
return Object.fromEntries(Object.entries(map).map(([newKey, oldKey]) => [newKey, source[oldKey]]));
};
export const getBaseURL = (url) => {
try {
// For real URLs
return new URL('.', new URL(url)).href.replace(/\/$/, ''); // removes trailing slash
} catch {
// For pseudo URLS
return url.split('/').slice(0, -1).join('/');
}
};
+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);
};
}
+47 -31
View File
@@ -1,17 +1,17 @@
import { getAudioContext, registerSound } from './index.mjs';
import { getCommonSampleInfo } from './util.mjs';
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)) {
@@ -190,6 +195,7 @@ export const tables = async (url, frameLen, json, options = {}) => {
if (url.startsWith('local:')) {
url = `http://localhost:5432`;
}
const base = getBaseURL(url);
if (typeof fetch !== 'function') {
// not a browser
return;
@@ -200,7 +206,7 @@ export const tables = async (url, frameLen, json, options = {}) => {
}
return fetch(url)
.then((res) => res.json())
.then((json) => _processTables(json, url, frameLen, options))
.then((json) => _processTables(json, base, frameLen, options))
.catch((error) => {
console.error(error);
throw new Error(`error loading "${url}"`);
@@ -224,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;
@@ -308,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,
File diff suppressed because it is too large Load Diff
+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: () => {},
};
},
+4 -2
View File
@@ -1,11 +1,12 @@
// this is dough, the superdough without dependencies
// @ts-check
// @ts-nocheck
// @ts-ignore ignore next line because sampleRate is unknown
const SAMPLE_RATE = typeof sampleRate !== 'undefined' ? sampleRate : 48000;
const PI_DIV_SR = Math.PI / SAMPLE_RATE;
const ISR = 1 / SAMPLE_RATE;
let gainCurveFunc = (val) => Math.pow(val, 2);
const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
function applyGainCurve(val) {
return gainCurveFunc(val);
@@ -151,7 +152,8 @@ export class TwoPoleFilter {
resonance = Math.max(resonance, 0);
cutoff = Math.min(cutoff, 20000);
const c = 2 * Math.sin(cutoff * PI_DIV_SR);
let c = 2 * Math.sin(cutoff * PI_DIV_SR);
c = clamp(c, 0, 1.14); // this line prevents instability TODO: test
const r = Math.pow(0.5, (resonance + 0.125) / 0.125);
const mrc = 1 - r * c;
+6 -3
View File
@@ -13,7 +13,7 @@
},
"repository": {
"type": "git",
"url": "git+https://github.com/tidalcycles/strudel.git"
"url": "git+https://codeberg.org/uzu/strudel.git"
},
"keywords": [
"tidalcycles",
@@ -25,12 +25,15 @@
"author": "Felix Roos <flix91@gmail.com>",
"license": "AGPL-3.0-or-later",
"bugs": {
"url": "https://github.com/tidalcycles/strudel/issues"
"url": "https://codeberg.org/uzu/strudel/issues"
},
"homepage": "https://github.com/tidalcycles/strudel#readme",
"homepage": "https://codeberg.org/uzu/strudel#readme",
"devDependencies": {
"vite": "^6.0.11",
"vite-plugin-bundle-audioworklet": "workspace:*",
"wav-encoder": "^1.3.0"
},
"engines": {
"node": ">=18.0.0"
}
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
import bundleAudioWorkletPlugin from 'vite-plugin-bundle-audioworklet';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [bundleAudioWorkletPlugin()],
build: {
lib: {
entry: resolve(__dirname, 'index.mjs'),
formats: ['es'],
fileName: (ext) => ({ es: 'index.mjs' })[ext],
},
target: 'esnext',
},
});
+3
View File
@@ -25,5 +25,8 @@
},
"devDependencies": {
"vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
}
}
+4 -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": {
@@ -38,5 +38,8 @@
"devDependencies": {
"vite": "^6.0.11",
"vitest": "^3.0.4"
},
"engines": {
"node": ">=18.0.0"
}
}
-7
View File
@@ -61,13 +61,6 @@ describe('tonal', () => {
.firstCycleValues.map((h) => h.note),
).toEqual(['B2', 'Eb3', 'A2', 'G3', 'F3']);
});
it('produces silence for mixed sharps and flats', () => {
expect(
n(seq('0b#', '1#b', '2#b#'))
.scale('C major')
.firstCycleValues.map((h) => h.note),
).toEqual([]);
});
it('snaps notes (upwards) to scale', () => {
const inputNotes = ['Cb', 'Eb', 'G', 'A#', 'Bb'];
const expectedNotes = ['B2', 'E3', 'G3', 'B3', 'B3'];
+59 -46
View File
@@ -5,9 +5,17 @@ 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, silence, logger, pure, isNote } from '@strudel/core';
import {
_mod,
errorLogger,
getAccidentalsOffset,
isNote,
logger,
noteToMidi,
register,
removeUndefineds,
} from '@strudel/core';
import { stepInNamedScale, nearestNumberIndex } from './tonleiter.mjs';
import { noteToMidi } from '../core/util.mjs';
const octavesInterval = (octaves) => (octaves <= 0 ? -1 : 1) + octaves * 7 + 'P';
@@ -185,17 +193,15 @@ function _convertStepToNumberAndOffset(step) {
step = String(step);
// Check to see if the step matches the expected format:
// - A number (possibly negative)
// - Some number of sharps or flats (but not both)
const match = /^(-?\d+)(#+|b+)?$/.exec(step);
// - Some number of sharps or flats
const match = /^(-?\d+)([#bsf]*)$/.exec(step);
if (!match) {
throw new Error(`invalid scale step "${step}", expected number or integer with optional # b suffixes`);
}
asNumber = Number(match[1]);
// These decorations will determine the semitone offset based on the number of
// sharps or flats
const decorations = match[2] || '';
offset = decorations[0] === '#' ? decorations.length : -decorations.length;
const accidentals = match[2] || '';
offset = getAccidentalsOffset(accidentals);
}
return [asNumber, offset];
}
@@ -226,12 +232,15 @@ function _getNearestScaleNote(scaleName, note, preferHigher = true) {
* Turns numbers into notes in the scale (zero indexed) or quantizes notes to a scale.
*
* When describing notes via numbers, note that negative numbers can be used to wrap backwards
* in the scale as well as sharps or flats (but not both) to produce notes outside of the scale.
* in the scale as well as sharps or flats to produce notes outside of the scale.
*
* Also sets scale for other scale operations, like {@link Pattern#scaleTranspose}.
*
* A scale consists of a root note (e.g. `c4`, `c`, `f#`, `bb4`) followed by semicolon (':') and then a [scale type](https://github.com/tonaljs/tonal/blob/main/packages/scale-type/data.ts).
*
* The scale name must be written without spaces (because it would be interpreted as a multi-step pattern otherwise).
* If your scale name includes spaces, replace them with colons.
*
* The root note defaults to octave 3, if no octave number is given.
*
* @name scale
@@ -253,8 +262,9 @@ function _getNearestScaleNote(scaleName, note, preferHigher = true) {
* .s("piano")
* @example
* note("C1*16").transpose(irand(36)).scale('Cb2 major').scaleTranspose(3)
* @example
* n("[0 0] [1 2] [3 4] [5 6]").scale("C:major:blues")
*/
export const scale = register(
'scale',
function (scale, pat) {
@@ -262,44 +272,47 @@ export const scale = register(
if (Array.isArray(scale)) {
scale = scale.flat().join(' ');
}
return (
pat
.fmap((value) => {
const isObject = typeof value === 'object';
// The case where the note has been defined via `n` or `pure`
if (!isObject || (isObject && ('n' in value || 'value' in value))) {
const step = isObject ? (value.n ?? value.value) : value;
delete value.n; // remove n so it won't cause trouble
if (isNote(step)) {
// legacy..
return pure(step);
return pat.withHaps((haps) => {
haps = haps.map((hap) => {
let hVal = hap.value;
const isObject = typeof hVal === 'object';
// If hVal is a pure value, place it on `n` so that we interpret it as a scale degree
hVal = isObject ? hVal : { n: hVal };
const { note, n, value, ...otherValues } = hVal;
const noteOrStep = note ?? n ?? value;
if (noteOrStep === undefined) {
logger(
`[tonal] Invalid value format for 'scale'. Value must contain n, note, or value but received keys [${Object.keys(hVal).join(', ')}]`,
'error',
);
return hap; // pass the value through unchanged
}
let scaleNote;
if (isNote(noteOrStep)) {
// Note case (quantize to scale)
scaleNote = _getNearestScaleNote(scale, noteOrStep);
hap.value = { ...otherValues, note: scaleNote };
} else {
// Step case (convert to note in scale)
try {
const [number, offset] = _convertStepToNumberAndOffset(noteOrStep);
if (otherValues.anchor) {
scaleNote = stepInNamedScale(number, scale, otherValues.anchor);
} else {
scaleNote = scaleStep(number, scale);
}
try {
const [number, offset] = _convertStepToNumberAndOffset(step);
let note;
if (isObject && value.anchor) {
note = stepInNamedScale(number, scale, value.anchor);
} else {
note = scaleStep(number, scale);
}
if (offset != 0) note = Note.transpose(note, Interval.fromSemitones(offset));
value = pure(isObject ? { ...value, note } : note);
} catch (err) {
logger(`[tonal] ${err.message}`, 'error');
return silence;
}
return value;
if (offset != 0) scaleNote = Note.transpose(scaleNote, Interval.fromSemitones(offset));
} catch (err) {
errorLogger(err, 'tonal');
return; // will be removed
}
// The case where the note has been defined via `note`
else {
const note = _getNearestScaleNote(scale, value.note);
return pure(isObject ? { ...value, note } : note);
}
})
.outerJoin()
// legacy:
.withHap((hap) => hap.setContext({ ...hap.context, scale }))
);
}
hap.value = isObject ? { ...otherValues, note: scaleNote } : scaleNote;
// Tag with scale for downsteam scale-aware operations
return hap.setContext({ ...hap.context, scale });
});
return removeUndefineds(haps);
});
},
true,
true, // preserve step count
+4 -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",
@@ -39,5 +39,8 @@
"devDependencies": {
"vite": "^6.0.11",
"vitest": "^3.0.4"
},
"engines": {
"node": ">=18.0.0"
}
}
@@ -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",
@@ -12,5 +12,8 @@
"type": "module",
"devDependencies": {
"vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
}
}
+1 -1
View File
@@ -62,7 +62,7 @@ initStrudel();
document.getElementById('play').addEventListener('click',
() => evaluate('note("c a f e").jux(rev)')
);
document.getElementById('play').addEventListener('stop',
document.getElementById('stop').addEventListener('click',
() => hush()
);
```
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel/web",
"version": "1.2.6",
"version": "1.3.0",
"description": "Easy to setup, opiniated bundle of Strudel for the browser.",
"module": "web.mjs",
"publishConfig": {
@@ -43,5 +43,8 @@
"@rollup/plugin-replace": "^6.0.2",
"vite": "^6.0.11",
"vite-plugin-bundle-audioworklet": "workspace:*"
},
"engines": {
"node": ">=18.0.0"
}
}

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