Compare commits

..

206 Commits

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

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