Compare commits

..

209 Commits

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

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

example code: https://strudel.cc/#YXdhaXQgaW5pdEh5ZHJhKCkKCmFsbChwaWFub3JvbGwpCgokOiBzKCJiZCIp
2025-10-16 14:06:33 +02:00
Aria fd03d1fdfb Update conditional to allow to control user waveforms 2025-10-15 15:14:31 -05:00
Aria 35016b5847 Mention range in docstring 2025-10-15 14:56:20 -05:00
Aria 0fe7edc157 Some examples 2025-10-15 14:53:07 -05:00
Aria d6de160224 Merge branch 'main' into glossing/partials 2025-10-15 13:19:57 -05:00
Aria ac7e9c6978 Typos on envelope values 2025-10-14 11:37:41 -05:00
Aria c13a726129 Merge branch 'main' into glossing/filter-lfo 2025-10-14 11:24:49 -05:00
Switch Angel AKA Jade Rose 99b3c04a76 Merge branch 'main' into glossing/filter-lfo 2025-10-13 07:04:51 +02:00
Aria 57fac57677 Remove todo 2025-10-09 22:23:28 -05:00
Aria a3b803c3f3 First pass 2025-10-09 18:28:59 -05:00
Felix Roos 319a2d7289 fix: tests 2025-10-07 23:05:00 +02:00
Felix Roos 3a7d50924d refactor: add op_precedence to express & with desugar_ops 2025-10-07 22:59:50 +02:00
Felix Roos f33db5f07f - refactor bjork -> bjorklund
- refactor e -> bjork
- flip & desugared arguments
2025-10-07 22:35:29 +02:00
Tristan de Cacqueray b7fa440bda mondough: interpret & 2025-10-07 18:10:24 +02:00
Tristan de Cacqueray 3dc9b3524c mondo: add & sugar 2025-10-07 17:19:27 +02:00
dtricks f48f8e46e5 fix empty catches 2025-10-06 15:53:44 +02:00
dtricks a79fc3db5b remove unnecessary comments 2025-10-06 15:53:44 +02:00
dtricks 30a2e14959 weird permission change 2025-10-06 15:53:44 +02:00
dtricks cfe17fcba5 wrong unicode char 2025-10-06 15:53:44 +02:00
dtricks 46ba165c7e remove gcc 2025-10-06 15:53:44 +02:00
dtricks 73a9834959 codeformat 2025-10-06 15:53:44 +02:00
dtricks be9368e8a2 feat(codemirror): add Vim :w evaluate, :q stop, and gc/gcc toggle comment via custom events; docs: add Vim shortcuts page and link from REPL manual 2025-10-06 15:53:44 +02:00
Aria 39a73f2499 Merge branch 'main' into glossing/scale-with-note-and-n 2025-10-05 18:20:30 -05:00
Aria cc4452ce58 Working version of partials and phases 2025-10-04 20:33:25 -05:00
Aria 11dd8a318a Cleanup 2025-10-04 02:13:42 -05:00
Aria 3ef6c7c921 Handle scale-for-notes when n also supplied 2025-10-04 02:07:35 -05:00
Switch Angel AKA Jade Rose f4cf77f5c6 Merge branch 'main' into solo 2025-09-30 08:09:37 +02:00
Kissaki 4d39f36868 Fix web README sample code
Looks like the code was copied, and then the wrong string was replaced when creating this code sample.
2025-09-21 18:31:59 +02:00
Kissaki c64aa3bf01 Fix link syntax in project-start 2025-09-21 18:27:05 +02:00
Felix Roos a0c2edad30 fix: add clamp function 2025-09-18 10:54:41 +02:00
Felix Roos 82c9047195 fix: unstable filter 2025-09-18 10:47:23 +02:00
Aria e4070b86f8 More descriptive variables and comments 2025-08-29 14:33:14 -05:00
Aria 9eeb6da3e6 Codeformat 2025-08-28 14:54:09 -05:00
Aria 869141b0a3 Parity with old adsr 2025-08-28 00:31:37 -05:00
Aria ea017e7622 Working version 2025-08-27 20:11:00 -05:00
Aria 763f900c69 First pass at envelopes with curves 2025-08-27 19:19:54 -05:00
Felix Roos 7f103e77e3 prefix "S" for solo 2025-07-28 04:05:21 +02:00
97 changed files with 9047 additions and 7010 deletions
+1
View File
@@ -0,0 +1 @@
22
+88
View File
@@ -0,0 +1,88 @@
# changelog
NOTE: you can generate this with `node warm.js`. it might still not be perfectly sorted...
## november 2025
- 2025-11-27T22:03:53+01:00 [perf] in `noise`, let noiseMix do the disconnect when it exists by jeromew in: [#1783](https://codeberg.org/uzu/strudel/pulls/1783)
- 2025-11-27T22:01:01+01:00 Bug Fix: Retries for sounds tab by glossing in: [#1754](https://codeberg.org/uzu/strudel/pulls/1754)
- 2025-11-27T20:36:46+01:00 [hydra] return the hydra object when await initHydra(..) is called by jeromew in: [#1784](https://codeberg.org/uzu/strudel/pulls/1784)
- 2025-11-27T20:36:14+01:00 Use errorLogger for query and tonal errors by glossing in: [#1782](https://codeberg.org/uzu/strudel/pulls/1782)
- 2025-11-27T20:27:10+01:00 [perf] level 5 `connect-leak` on `vowel` by jeromew in: [#1779](https://codeberg.org/uzu/strudel/pulls/1779)
- 2025-11-26T08:23:38+01:00 feat: add prebake script import button for loading .strudel files by daslyfe in: [#1774](https://codeberg.org/uzu/strudel/pulls/1774)
- 2025-11-26T07:39:28+01:00 Fix Sampler port trampling by Dayglo in: [#1717](https://codeberg.org/uzu/strudel/pulls/1717)
- 2025-11-25T20:59:09+01:00 Feat: Hook up octave and fix example by glossing in: [#1773](https://codeberg.org/uzu/strudel/pulls/1773)
- 2025-11-25T20:48:09+01:00 [perf] fix `connect-leak` in `tremolo` param by jeromew in: [#1780](https://codeberg.org/uzu/strudel/pulls/1780)
- 2025-11-24T17:51:20+01:00 filter modulation improvements! by daslyfe in: [#1769](https://codeberg.org/uzu/strudel/pulls/1769)
- 2025-11-24T01:20:53+01:00 Add o as a synonym for orbit by daslyfe in: [#1766](https://codeberg.org/uzu/strudel/pulls/1766)
- 2025-11-23T22:23:26+01:00 [perf] fix `connect-leak` in `fm` modulation by jeromew in: [#1758](https://codeberg.org/uzu/strudel/pulls/1758)
- 2025-11-23T09:47:24+01:00 Fix interoperability issue between `all` and `await initHydra()` by jeromew in: [#1663](https://codeberg.org/uzu/strudel/pulls/1663)
- 2025-11-23T04:23:06+01:00 Bug fix: Swap l/r gains with temp variable by glossing in: [#1768](https://codeberg.org/uzu/strudel/pulls/1768)
- 2025-11-23T00:52:28+01:00 FIX: prevent LFO filter modulation from popping from negative values by daslyfe in: [#1767](https://codeberg.org/uzu/strudel/pulls/1767)
- 2025-11-21T01:56:11+01:00 Bug Fix: Use frac due to negative frequencies from FM by glossing in: [#1759](https://codeberg.org/uzu/strudel/pulls/1759)
- 2025-11-20T19:48:16+01:00 [perf] fix `connect-leak` added by #1742 when noise() is not used by jeromew in: [#1757](https://codeberg.org/uzu/strudel/pulls/1757)
- 2025-11-20T14:51:00+01:00 [perf] fix `connect-leak` in `delay` effect by jeromew in: [#1755](https://codeberg.org/uzu/strudel/pulls/1755)
- 2025-11-20T14:49:41+01:00 [perf] fix `connect leak` when .noise() is in the mix by jeromew in: [#1742](https://codeberg.org/uzu/strudel/pulls/1742)
- 2025-11-18T23:52:45+01:00 wchooseCycles has now notes in an example by scrappy_fiddler in: [#1748](https://codeberg.org/uzu/strudel/pulls/1748)
- 2025-11-17T05:31:54+01:00 Feature: Partials by glossing in: [#1659](https://codeberg.org/uzu/strudel/pulls/1659)
- 2025-11-16T22:06:14+01:00 README: update superdough documentation by TristanMlct in: [#1741](https://codeberg.org/uzu/strudel/pulls/1741)
- 2025-11-16T21:08:54+01:00 Worklet optimizations by glossing in: [#1730](https://codeberg.org/uzu/strudel/pulls/1730)
- 2025-11-15T14:59:30+01:00 [perf] disconnect Offline AudioNode connections in generateReverb by jeromew in: [#1740](https://codeberg.org/uzu/strudel/pulls/1740)
- 2025-11-15T14:57:47+01:00 [perf] disconnect `send` effect AudioNode when `room` is used by jeromew in: [#1736](https://codeberg.org/uzu/strudel/pulls/1736)
- 2025-11-12T21:22:34+01:00 [supradough] fix: unstable filter by froos in: [#1593](https://codeberg.org/uzu/strudel/pulls/1593)
- 2025-11-12T21:12:07+01:00 Fix link syntax in `project-start` by Kissaki in: [#1724](https://codeberg.org/uzu/strudel/pulls/1724)
- 2025-11-12T21:06:13+01:00 Fix web README sample code by Kissaki in: [#1725](https://codeberg.org/uzu/strudel/pulls/1725)
- 2025-11-12T20:59:39+01:00 Fix typo: 'studel' -> 'strudel' by drhayes in: [#1726](https://codeberg.org/uzu/strudel/pulls/1726)
- 2025-11-12T15:23:41+01:00 fix for node 24 support in tests - #1718 by ausav in: [#1719](https://codeberg.org/uzu/strudel/pulls/1719)
- 2025-11-11T09:00:49+01:00 soundAlias example fix by PepsiiMan in: [#1720](https://codeberg.org/uzu/strudel/pulls/1720)
- 2025-11-10T08:18:36+01:00 fix: Make Supradough package build by munshkr in: [#1711](https://codeberg.org/uzu/strudel/pulls/1711)
- 2025-11-10T08:18:00+01:00 added docs for spaces in scale names by ondras in: [#1715](https://codeberg.org/uzu/strudel/pulls/1715)
- 2025-11-10T02:44:01+01:00 Feature: LFOs for Filters by glossing in: [#1636](https://codeberg.org/uzu/strudel/pulls/1636)
- 2025-11-05T17:07:00+01:00 improvement: sync midi messages to audio context clock by daslyfe in: [#1708](https://codeberg.org/uzu/strudel/pulls/1708)
- 2025-11-04T19:36:14+01:00 Add stick button mappings to gamepad implementation and improve docs by kaosuryoko in: [#1696](https://codeberg.org/uzu/strudel/pulls/1696)
- 2025-11-04T19:29:35+01:00 Refactor sound stopping and triggering logic in SoundsTab component by IJOL in: [#1688](https://codeberg.org/uzu/strudel/pulls/1688)
- 2025-11-04T19:13:21+01:00 Add setting to toggle pattern auto-start on pattern change by moumar in: [#1690](https://codeberg.org/uzu/strudel/pulls/1690)
- 2025-11-04T18:58:50+01:00 Voicings JSDoc by sharkeys_lunchbox in: [#1686](https://codeberg.org/uzu/strudel/pulls/1686)
- 2025-11-01T07:50:40+01:00 FIX: Loading local samples uses too much memory by daslyfe in: [#1706](https://codeberg.org/uzu/strudel/pulls/1706)
## october 2025
- 2025-10-28T22:58:24+01:00 Bug Fix: Handle scale-for-notes when n also supplied by glossing in: [#1625](https://codeberg.org/uzu/strudel/pulls/1625)
- 2025-10-28T22:51:16+01:00 Repurpose vim shortcuts for usability by dtricks in: [#1624](https://codeberg.org/uzu/strudel/pulls/1624)
- 2025-10-28T22:35:21+01:00 Add support for euclidian in mondo with `bd&3:8` by TristanCacqueray in: [#1630](https://codeberg.org/uzu/strudel/pulls/1630)
- 2025-10-27T10:42:07+01:00 Use bunny cdn for all samples and json files by yaxu in: [#1701](https://codeberg.org/uzu/strudel/pulls/1701)
- 2025-10-26T22:52:54+01:00 degithub - switch some samples to bunnycdn by yaxu in: [#1697](https://codeberg.org/uzu/strudel/pulls/1697)
- 2025-10-26T17:09:22+01:00 Fix ZZFX example by moumar in: [#1685](https://codeberg.org/uzu/strudel/pulls/1685)
- 2025-10-23T15:56:04+02:00 Make osc port and host configurable. Changes dependencies. by yaxu in: [#1682](https://codeberg.org/uzu/strudel/pulls/1682)
- 2025-10-23T03:47:31+02:00 Adds back shape to superdough by glossing in: [#1672](https://codeberg.org/uzu/strudel/pulls/1672)
- 2025-10-22T22:15:19+02:00 Fix sampler.mjs githubPath by jeromew in: [#1684](https://codeberg.org/uzu/strudel/pulls/1684)
- 2025-10-22T16:20:50+02:00 Fix onPaint for widgets by yaxu in: [#1658](https://codeberg.org/uzu/strudel/pulls/1658)
- 2025-10-22T15:19:12+02:00 Fix a bug introduced by #4e17cfbdd6 by milliganf in: [#1679](https://codeberg.org/uzu/strudel/pulls/1679)
- 2025-10-18T04:29:20+02:00 Bug Fix: Wavetable: phase wrapping at 1 and detune by glossing in: [#1620](https://codeberg.org/uzu/strudel/pulls/1620)
- 2025-10-16T20:26:28+02:00 github samples: default to "samples" if repository is not specified by prezmop in: [#1644](https://codeberg.org/uzu/strudel/pulls/1644)
- 2025-10-16T17:33:39+02:00 Docs: add example of custom chained function by dariusk in: [#1642](https://codeberg.org/uzu/strudel/pulls/1642)
- 2025-10-14T12:39:48+02:00 textbox by yaxu in: [#1650](https://codeberg.org/uzu/strudel/pulls/1650)
- 2025-10-13T07:05:57+02:00 Feature: Wavetable FM by glossing in: [#1623](https://codeberg.org/uzu/strudel/pulls/1623)
- 2025-10-12T20:43:42+02:00 fix: repair REPL sample sources and URL concat (#1640) by erikfox in: [#1646](https://codeberg.org/uzu/strudel/pulls/1646)
- 2025-10-10T11:25:02+02:00 Add control-metadata by yaxu in: [#1634](https://codeberg.org/uzu/strudel/pulls/1634)
- 2025-10-07T22:59:39+02:00 Fix MQTT: change the trigger handler to match new hap by jurrchen in: [#1629](https://codeberg.org/uzu/strudel/pulls/1629)
- 2025-10-05T21:42:19+02:00 Fix case sensitivity for synonym search by vvolhejn in: [#1618](https://codeberg.org/uzu/strudel/pulls/1618)
- 2025-10-01T08:56:35+02:00 Optimize wavetable synth by glossing in: [#1613](https://codeberg.org/uzu/strudel/pulls/1613)
- 2025-10-01T02:02:33+02:00 fix: pattern switching by daslyfe in: [#1616](https://codeberg.org/uzu/strudel/pulls/1616)
## September 2025
- 2025-09-29T02:10:54+02:00 fix wavetable JSON by daslyfe in: [#1611](https://codeberg.org/uzu/strudel/pulls/1611)
- 2025-09-28T21:28:16+02:00 rename wavetable controls to match existing naming conventions by daslyfe in: [#1610](https://codeberg.org/uzu/strudel/pulls/1610)
- 2025-09-27T23:28:13+02:00 Feature: Wavetable synth improvements by glossing in: [#1607](https://codeberg.org/uzu/strudel/pulls/1607)
- 2025-09-27T21:43:10+02:00 wavetable improvements by daslyfe in: [#1608](https://codeberg.org/uzu/strudel/pulls/1608)
- 2025-09-26T08:48:46+02:00 create orbit based DJ filter by daslyfe in: [#1603](https://codeberg.org/uzu/strudel/pulls/1603)
- 2025-09-23T13:28:20+02:00 fix: time signal by daslyfe in: [#1583](https://codeberg.org/uzu/strudel/pulls/1583)
- 2025-09-21T16:16:06+02:00 mondo: fix all + setcpm + setcps by froos in: [#1600](https://codeberg.org/uzu/strudel/pulls/1600)
- 2025-09-19T21:59:43+02:00 fix: osc error message by froos in: [#1597](https://codeberg.org/uzu/strudel/pulls/1597)
- 2025-09-18T21:09:56+02:00 osc: --debug flag to see incoming messages by froos in: [#1595](https://codeberg.org/uzu/strudel/pulls/1595)
- 2025-09-17T14:12:10+02:00 simplify osc usage by froos in: [#1588](https://codeberg.org/uzu/strudel/pulls/1588)
- 2025-09-16T16:59:58+02:00 dev: logger errors to console in dev mode by daslyfe in: [#1585](https://codeberg.org/uzu/strudel/pulls/1585)
- 2025-09-15T22:52:14+02:00 add tic80 font by froos in: [#1579](https://codeberg.org/uzu/strudel/pulls/1579)
- 2025-09-15T00:00:19+02:00 supradough: fix delay + add some jsdoc types by froos in: [#1578](https://codeberg.org/uzu/strudel/pulls/1578)
+1 -1
View File
@@ -13,7 +13,7 @@ https://strudel.cc/
After cloning the project, you can run the REPL locally: After cloning the project, you can run the REPL locally:
1. Install [Node.js](https://nodejs.org/) 1. Install [Node.js](https://nodejs.org/) 18 or newer
2. Install [pnpm](https://pnpm.io/installation) 2. Install [pnpm](https://pnpm.io/installation)
3. Install dependencies by running the following command: 3. Install dependencies by running the following command:
```bash ```bash
+3
View File
@@ -20,5 +20,8 @@
"@strudel/tonal": "workspace:*", "@strudel/tonal": "workspace:*",
"@strudel/transpiler": "workspace:*", "@strudel/transpiler": "workspace:*",
"@strudel/webaudio": "workspace:*" "@strudel/webaudio": "workspace:*"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+3
View File
@@ -14,5 +14,8 @@
}, },
"dependencies": { "dependencies": {
"@strudel/web": "workspace:*" "@strudel/web": "workspace:*"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+3
View File
@@ -18,5 +18,8 @@
"@strudel/transpiler": "workspace:*", "@strudel/transpiler": "workspace:*",
"@strudel/webaudio": "workspace:*", "@strudel/webaudio": "workspace:*",
"@strudel/tonal": "workspace:*" "@strudel/tonal": "workspace:*"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+3
View File
@@ -13,5 +13,8 @@
}, },
"devDependencies": { "devDependencies": {
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+3
View File
@@ -32,5 +32,8 @@
}, },
"devDependencies": { "devDependencies": {
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+3
View File
@@ -73,5 +73,8 @@
"prettier": "^3.4.2", "prettier": "^3.4.2",
"vitest": "^3.0.4", "vitest": "^3.0.4",
"vite-plugin-bundle-audioworklet": "workspace:*" "vite-plugin-bundle-audioworklet": "workspace:*"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+14 -10
View File
@@ -1,8 +1,10 @@
import jsdoc from '../../doc.json'; import jsdoc from '../../doc.json';
import { autocompletion } from '@codemirror/autocomplete'; import { autocompletion } from '@codemirror/autocomplete';
import { h } from './html'; import { h } from './html';
import { Scale } from '@tonaljs/tonal'; //TODO: fix tonal scale import
import { soundMap } from 'superdough'; // import { Scale } from '@tonaljs/tonal';
// import { soundMap } from '@strudel/webaudio';
let soundMap = undefined;
import { complex } from '@strudel/tonal'; import { complex } from '@strudel/tonal';
const escapeHtml = (str) => { const escapeHtml = (str) => {
@@ -79,7 +81,9 @@ const hasExcludedTags = (doc) =>
['superdirtOnly', 'noAutocomplete'].some((tag) => doc.tags?.find((t) => t.originalTitle === tag)); ['superdirtOnly', 'noAutocomplete'].some((tag) => doc.tags?.find((t) => t.originalTitle === tag));
export function bankCompletions() { export function bankCompletions() {
const soundDict = soundMap.get(); // TODO: FIX IMPORT
const soundDict = soundMap?.get() ?? {};
const banks = new Set(); const banks = new Set();
for (const key of Object.keys(soundDict)) { for (const key of Object.keys(soundDict)) {
const [bank, suffix] = key.split('_'); const [bank, suffix] = key.split('_');
@@ -90,13 +94,13 @@ export function bankCompletions() {
.map((name) => ({ label: name, type: 'bank' })); .map((name) => ({ label: name, type: 'bank' }));
} }
// Attempt to get all scale names from Tonal // Attempt to get all scale names from Tonal TODO: FIX IMPORT
let scaleCompletions = []; let scaleCompletions = [];
try { // try {
scaleCompletions = (Scale.names ? Scale.names() : []).map((name) => ({ label: name, type: 'scale' })); // scaleCompletions = (Scale.names ? Scale.names() : []).map((name) => ({ label: name, type: 'scale' }));
} catch (e) { // } catch (e) {
console.warn('[autocomplete] Could not load scale names from Tonal:', e); // console.warn('[autocomplete] Could not load scale names from Tonal:', e);
} // }
// Valid mode values for voicing // Valid mode values for voicing
const modeCompletions = [ const modeCompletions = [
@@ -268,7 +272,7 @@ function soundHandler(context) {
const inside = text.slice(quoteIdx + 1); const inside = text.slice(quoteIdx + 1);
const fragMatch = inside.match(SOUND_FRAGMENT_MATCH_REGEX); const fragMatch = inside.match(SOUND_FRAGMENT_MATCH_REGEX);
const fragment = fragMatch ? fragMatch[1] : inside; const fragment = fragMatch ? fragMatch[1] : inside;
const soundNames = Object.keys(soundMap.get()).sort(); const soundNames = Object.keys(soundMap?.get() ?? {}).sort();
const filteredSounds = soundNames.filter((name) => name.includes(fragment)); const filteredSounds = soundNames.filter((name) => name.includes(fragment));
let options = filteredSounds.map((name) => ({ label: name, type: 'sound' })); let options = filteredSounds.map((name) => ({ label: name, type: 'sound' }));
const from = soundContext.to - fragment.length; const from = soundContext.to - fragment.length;
+63 -17
View File
@@ -1,30 +1,31 @@
import { closeBrackets } from '@codemirror/autocomplete'; import { closeBrackets } from '@codemirror/autocomplete';
export { toggleComment, toggleBlockComment, toggleLineComment, toggleBlockCommentByLine } from '@codemirror/commands'; import { indentWithTab, toggleLineComment } from '@codemirror/commands';
// import { search, highlightSelectionMatches } from '@codemirror/search';
import { indentWithTab } from '@codemirror/commands';
import { javascript, javascriptLanguage } from '@codemirror/lang-javascript'; import { javascript, javascriptLanguage } from '@codemirror/lang-javascript';
import { defaultHighlightStyle, syntaxHighlighting, bracketMatching } from '@codemirror/language'; import { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { Compartment, EditorState, Prec } from '@codemirror/state'; import { Compartment, EditorState, Prec } from '@codemirror/state';
import { import {
drawSelection,
EditorView, EditorView,
highlightActiveLineGutter,
highlightActiveLine, highlightActiveLine,
highlightActiveLineGutter,
keymap, keymap,
lineNumbers, lineNumbers,
drawSelection,
} from '@codemirror/view'; } from '@codemirror/view';
import { repl, registerControl } from '@strudel/core'; import { persistentAtom } from '@nanostores/persistent';
import { Drawer, cleanupDraw } from '@strudel/draw'; import { logger, registerControl, repl } from '@strudel/core';
import { cleanupDraw, Drawer } from '@strudel/draw';
import { isAutoCompletionEnabled } from './autocomplete.mjs'; import { isAutoCompletionEnabled } from './autocomplete.mjs';
import { isTooltipEnabled } from './tooltip.mjs'; import { basicSetup } from './basicSetup.mjs';
import { flash, isFlashEnabled } from './flash.mjs'; import { flash, isFlashEnabled } from './flash.mjs';
import { highlightMiniLocations, isPatternHighlightingEnabled, updateMiniLocations } from './highlight.mjs'; import { highlightMiniLocations, isPatternHighlightingEnabled, updateMiniLocations } from './highlight.mjs';
import { keybindings } from './keybindings.mjs'; import { keybindings } from './keybindings.mjs';
import { initTheme, activateTheme, theme } from './themes.mjs';
import { sliderPlugin, updateSliderWidgets } from './slider.mjs'; import { sliderPlugin, updateSliderWidgets } from './slider.mjs';
import { widgetPlugin, updateWidgets } from './widget.mjs'; import { activateTheme, initTheme, theme } from './themes.mjs';
import { persistentAtom } from '@nanostores/persistent'; import { isTooltipEnabled } from './tooltip.mjs';
import { basicSetup } from './basicSetup.mjs'; import { updateWidgets, widgetPlugin } from './widget.mjs';
export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands';
const extensions = { const extensions = {
isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []), isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []),
@@ -94,8 +95,8 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
}), }),
sliderPlugin, sliderPlugin,
widgetPlugin, widgetPlugin,
// indentOnInput(), // works without. already brought with javascript extension? // indentOnInput(), // works without. already brought with javascript
// bracketMatching(), // does not do anything // extension? bracketMatching(), // does not do anything
syntaxHighlighting(defaultHighlightStyle), syntaxHighlighting(defaultHighlightStyle),
EditorView.updateListener.of((v) => onChange(v)), EditorView.updateListener.of((v) => onChange(v)),
drawSelection({ cursorBlinkRate: 0 }), drawSelection({ cursorBlinkRate: 0 }),
@@ -206,7 +207,8 @@ export class StrudelMirror {
updateWidgets(this.editor, widgets); updateWidgets(this.editor, widgets);
updateMiniLocations(this.editor, this.miniLocations); updateMiniLocations(this.editor, this.miniLocations);
replOptions?.afterEval?.(options); replOptions?.afterEval?.(options);
// if no painters are set (.onPaint was not called), then we only need the present moment (for highlighting) // if no painters are set (.onPaint was not called), then we only need
// the present moment (for highlighting)
const drawTime = options.pattern.getPainters().length ? this.drawTime : [0, 0]; const drawTime = options.pattern.getPainters().length ? this.drawTime : [0, 0];
this.drawer.setDrawTime(drawTime); this.drawer.setDrawTime(drawTime);
// invalidate drawer after we've set the appropriate drawTime // invalidate drawer after we've set the appropriate drawTime
@@ -245,6 +247,33 @@ export class StrudelMirror {
} }
}; };
document.addEventListener('start-repl', this.onStartRepl); document.addEventListener('start-repl', this.onStartRepl);
// Handle global evaluation requests (e.g., from Vim :w)
this.onEvaluateRequest = (e) => {
try {
// Evaluate current editor on repl-evaluate
logger('[repl] evaluate via event');
this.evaluate();
e?.cancelable && e.preventDefault?.();
} catch (err) {
console.error('Error handling repl-evaluate event', err);
}
};
document.addEventListener('repl-evaluate', this.onEvaluateRequest);
document.addEventListener('repl-stop', this.onStopRequest);
// Toggle comments requested from Vim (gc)
this.onToggleComment = (e) => {
try {
// Honor selections; toggleLineComment handles both selections and
// single line
toggleLineComment(this.editor);
e?.cancelable && e.preventDefault?.();
} catch (err) {
console.error('Error handling repl-toggle-comment event', err);
}
};
document.addEventListener('repl-toggle-comment', this.onToggleComment);
} }
draw(haps, time, painters) { draw(haps, time, painters) {
painters?.forEach((painter) => painter(this.drawContext, time, haps, this.drawTime)); painters?.forEach((painter) => painter(this.drawContext, time, haps, this.drawTime));
@@ -271,6 +300,16 @@ export class StrudelMirror {
async stop() { async stop() {
this.repl.scheduler.stop(); this.repl.scheduler.stop();
} }
// Listen for global stop requests (e.g., from Vim :q)
onStopRequest = (e) => {
try {
this.stop();
e?.cancelable && e.preventDefault?.();
} catch (err) {
console.error('Error handling repl-stop event', err);
}
};
async toggle() { async toggle() {
if (this.repl.scheduler.started) { if (this.repl.scheduler.started) {
this.repl.stop(); this.repl.stop();
@@ -346,11 +385,18 @@ export class StrudelMirror {
} }
} }
setCode(code) { setCode(code) {
const changes = { from: 0, to: this.editor.state.doc.length, insert: code }; const changes = {
from: 0,
to: this.editor.state.doc.length,
insert: code,
};
this.editor.dispatch({ changes }); this.editor.dispatch({ changes });
} }
clear() { clear() {
this.onStartRepl && document.removeEventListener('start-repl', this.onStartRepl); this.onStartRepl && document.removeEventListener('start-repl', this.onStartRepl);
this.onEvaluateRequest && document.removeEventListener('repl-evaluate', this.onEvaluateRequest);
this.onStopRequest && document.removeEventListener('repl-stop', this.onStopRequest);
this.onToggleComment && document.removeEventListener('repl-toggle-comment', this.onToggleComment);
} }
getCursorLocation() { getCursorLocation() {
return this.editor.state.selection.main.head; return this.editor.state.selection.main.head;
+107 -2
View File
@@ -1,11 +1,12 @@
import { defaultKeymap } from '@codemirror/commands';
import { Prec } from '@codemirror/state'; import { Prec } from '@codemirror/state';
import { keymap, ViewPlugin } from '@codemirror/view'; import { keymap, ViewPlugin } from '@codemirror/view';
// import { searchKeymap } from '@codemirror/search'; // import { searchKeymap } from '@codemirror/search';
import { emacs } from '@replit/codemirror-emacs'; import { emacs } from '@replit/codemirror-emacs';
import { vim } from '@replit/codemirror-vim'; import { vim, Vim } from '@replit/codemirror-vim';
// import { vim } from './vim_test.mjs'; // import { vim } from './vim_test.mjs';
import { vscodeKeymap } from '@replit/codemirror-vscode-keymap'; import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
import { defaultKeymap } from '@codemirror/commands'; import { logger } from '@strudel/core';
const vscodePlugin = ViewPlugin.fromClass( const vscodePlugin = ViewPlugin.fromClass(
class { class {
@@ -19,6 +20,110 @@ const vscodePlugin = ViewPlugin.fromClass(
); );
const vscodeExtension = (options) => [vscodePlugin].concat(options ?? []); const vscodeExtension = (options) => [vscodePlugin].concat(options ?? []);
// Map Vim :w to trigger the same action as evaluation. We dispatch a custom
// event 'repl-evaluate' that the editor listens for, and also simulate
// Ctrl+Enter/Alt+Enter as a fallback. We log to the Strudel logger so it
// appears in the Console panel.
try {
if (Vim && typeof Vim.defineEx === 'function') {
// Map gc to toggle line comments by dispatching a custom event that our
// CodeMirror integration listens to. This avoids depending on Vim's
// internal actions and works with current selections/visual mode.
try {
Vim.defineAction('strudelToggleComment', (cm) => {
const view = cm?.view || cm;
try {
const ev = new CustomEvent('repl-toggle-comment', { detail: { source: 'vim', view }, cancelable: true });
document.dispatchEvent(ev);
} catch (e) {
console.error('strudelToggleComment dispatch failed', e);
}
});
Vim.mapCommand('gc', 'action', 'strudelToggleComment', {}, { context: 'normal' });
Vim.mapCommand('gc', 'action', 'strudelToggleComment', {}, { context: 'visual' });
} catch (e) {
console.error('Vim gc mapping failed', e);
}
// :q to pause/stop
Vim.defineEx('quit', 'q', (cm) => {
try {
const view = cm?.view || cm;
// First try dispatching our custom stop event, then fallback to Alt+.
let handled = false;
try {
const ev = new CustomEvent('repl-stop', { detail: { source: 'vim', view }, cancelable: true });
handled = document.dispatchEvent(ev) === false;
} catch (e) {
console.error('Error dispatching repl-stop event', e);
}
if (!handled) {
const altDot = new KeyboardEvent('keydown', {
key: '.',
code: 'Period',
altKey: true,
bubbles: true,
cancelable: true,
});
view?.dom?.dispatchEvent?.(altDot);
}
} catch (e) {
console.error('Error dispatching :q stop event', e);
}
});
// :w to evaluate
Vim.defineEx('write', 'w', (cm) => {
const view = cm?.view || cm; // CM6 Vim passes either an object with view or the view itself
try {
view?.focus?.();
// Let the app know this came from Vim :w
try {
logger('[vim] :w — evaluating code');
} catch (e) {
console.error('Error logging Vim :w evaluation', e);
}
// Dispatch a dedicated evaluate event first
let handled = false;
try {
const ev = new CustomEvent('repl-evaluate', { detail: { source: 'vim', view }, cancelable: true });
handled = document.dispatchEvent(ev) === false; // false means preventDefault was called
} catch (e) {
console.error('Error dispatching repl-evaluate event', e);
}
if (handled) {
return;
}
// Try Ctrl+Enter first if not handled by custom event
const ctrlEnter = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
ctrlKey: true,
bubbles: true,
cancelable: true,
});
view?.dom?.dispatchEvent?.(ctrlEnter);
// If not handled (no handler called preventDefault), try Alt+Enter as
// fallback
if (!ctrlEnter.defaultPrevented) {
const altEnter = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
altKey: true,
bubbles: true,
cancelable: true,
});
view?.dom?.dispatchEvent?.(altEnter);
}
} catch (e) {
console.error('Error dispatching :w evaluation event', e);
}
});
}
} catch (e) {
console.error('Vim ex command setup failed (defineEx missing or Vim unavailable)', e);
}
const keymaps = { const keymaps = {
vim, vim,
emacs, emacs,
+3
View File
@@ -54,5 +54,8 @@
}, },
"devDependencies": { "devDependencies": {
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+248 -11
View File
@@ -1288,6 +1288,196 @@ export const { fanchor } = registerControl('fanchor');
*/ */
// currently an alias of 'hcutoff' https://codeberg.org/uzu/strudel/issues/496 // currently an alias of 'hcutoff' https://codeberg.org/uzu/strudel/issues/496
// ['hpf'], // ['hpf'],
/**
* Rate of the LFO for the lowpass filter
*
* @name lprate
* @param {number | Pattern} rate rate in hertz
* @example
* note("<c c c# c c c4>*16").s("sawtooth").lpf(600).lprate("<4 8 2 1>")
*/
export const { lprate } = registerControl('lprate');
/**
* Cycle-synced rate of the LFO for the lowpass filter
*
* @name lpsync
* @param {number | Pattern} rate rate in cycles
* @example
* note("<c c c# c c c4>*16").s("sawtooth").lpf(600).lpsync("<4 8 2 1>")
*/
export const { lpsync } = registerControl('lpsync');
/**
* Depth of the LFO for the lowpass filter
*
* @name lpdepth
* @param {number | Pattern} depth depth of modulation
* @example
* note("<c c c# c c c4>*16").s("sawtooth").lpf(600).lpdepth("<1 .5 1.8 0>")
*/
export const { lpdepth } = registerControl('lpdepth');
/**
* Depth of the LFO for the lowpass filter, in HZ
*
* @name lpdepthfrequency
* @synonyms
* lpdethfreq
* @param {number | Pattern} depth depth of modulation
* @example
* note("<c c c# c c c4>*16").s("sawtooth").lpf(600).lpdepthfrequency("<200 500 100 0>")
*/
export const { lpdepthfrequency } = registerControl('lpdepthfrequency', 'lpdepthfreq');
/**
* Shape of the LFO for the lowpass filter
*
* @name lpshape
* @param {number | Pattern} shape Shape of the lfo (0, 1, 2, ..)
*/
export const { lpshape } = registerControl('lpshape');
/**
* DC offset of the LFO for the lowpass filter
*
* @name lpdc
* @param {number | Pattern} dcoffset dc offset. set to 0 for unipolar
*/
export const { lpdc } = registerControl('lpdc');
/**
* Skew of the LFO for the lowpass filter
*
* @name lpskew
* @param {number | Pattern} skew How much to bend the LFO shape
*/
export const { lpskew } = registerControl('lpskew');
/**
* Rate of the LFO for the bandpass filter
*
* @name bprate
* @param {number | Pattern} rate rate in hertz
*/
export const { bprate } = registerControl('bprate');
/**
* Cycle-synced rate of the LFO for the bandpass filter
*
* @name bpsync
* @param {number | Pattern} rate rate in cycles
*/
export const { bpsync } = registerControl('bpsync');
/**
* Depth of the LFO for the bandpass filter
*
* @name bpdepth
* @param {number | Pattern} depth depth of modulation
*/
export const { bpdepth } = registerControl('bpdepth');
/**
* Depth of the LFO for the bandpass filter, in HZ
*
* @name bpdepthfrequency
* @synonyms
* bpdethfreq
* @param {number | Pattern} depth depth of modulation
* @example
* note("<c c c# c c c4>*16").s("sawtooth").lpf(600).bpdepthfrequency("<200 500 100 0>")
*/
export const { bpdepthfrequency } = registerControl('bpdepthfrequency', 'bpdepthfreq');
/**
* Shape of the LFO for the bandpass filter
*
* @name bpshape
* @param {number | Pattern} shape Shape of the lfo (0, 1, 2, ..)
*/
export const { bpshape } = registerControl('bpshape');
/**
* DC offset of the LFO for the bandpass filter
*
* @name bpdc
* @param {number | Pattern} dcoffset dc offset. set to 0 for unipolar
*/
export const { bpdc } = registerControl('bpdc');
/**
* Skew of the LFO for the bandpass filter
*
* @name bpskew
* @param {number | Pattern} skew How much to bend the LFO shape
*/
export const { bpskew } = registerControl('bpskew');
/**
* Rate of the LFO for the highpass filter
*
* @name hprate
* @param {number | Pattern} rate rate in hertz
*/
export const { hprate } = registerControl('hprate');
/**
* Cycle-synced rate of the LFO for the highpass filter
*
* @name hpsync
* @param {number | Pattern} rate rate in cycles
*/
export const { hpsync } = registerControl('hpsync');
/**
* Depth of the LFO for the highpass filter
*
* @name hpdepth
* @param {number | Pattern} depth depth of modulation
*/
export const { hpdepth, hpdepthfreq } = registerControl('hpdepth');
/**
* Depth of the LFO for the hipass filter, in hz
*
* @name hpdepthfrequency
* @synonyms
* hpdethfreq
* @param {number | Pattern} depth depth of modulation
* @example
* note("<c c c# c c c4>*16").s("sawtooth").lpf(600).hpdepthfrequency("<200 500 100 0>")
*/
export const { hpdepthfrequency } = registerControl('hpdepthfrequency', 'hpdepthfreq');
/**
* Shape of the LFO for the highpass filter
*
* @name hpshape
* @param {number | Pattern} shape Shape of the lfo (0, 1, 2, ..)
*/
export const { hpshape } = registerControl('hpshape');
/**
* DC offset of the LFO for the highpass filter
*
* @name hpdc
* @param {number | Pattern} dcoffset dc offset. set to 0 for unipolar
*/
export const { hpdc } = registerControl('hpdc');
/**
* Skew of the LFO for the highpass filter
*
* @name hpskew
* @param {number | Pattern} skew How much to bend the LFO shape
*/
export const { hpskew } = registerControl('hpskew');
/** /**
* Applies a vibrato to the frequency of the oscillator. * Applies a vibrato to the frequency of the oscillator.
* *
@@ -1675,12 +1865,12 @@ export const { nudge } = registerControl('nudge');
* Sets the default octave of a synth. * Sets the default octave of a synth.
* *
* @name octave * @name octave
* @synonyms oct
* @param {number | Pattern} octave octave number * @param {number | Pattern} octave octave number
* @example * @example
* n("0,4,7").s('supersquare').octave("<3 4 5 6>").osc() * n("0,4,7").scale("F:minor").s('supersaw').octave("<0 1 2 3>")
* @superDirtOnly
*/ */
export const { octave } = registerControl('octave'); export const { octave, oct } = registerControl('octave', 'oct');
// ['ophatdecay'], // ['ophatdecay'],
// TODO: example // TODO: example
@@ -1688,6 +1878,7 @@ export const { octave } = registerControl('octave');
* An `orbit` is a global parameter context for patterns. Patterns with the same orbit will share the same global effects. * An `orbit` is a global parameter context for patterns. Patterns with the same orbit will share the same global effects.
* *
* @name orbit * @name orbit
* @synonyms o
* @param {number | Pattern} number * @param {number | Pattern} number
* @example * @example
* stack( * stack(
@@ -1695,7 +1886,7 @@ export const { octave } = registerControl('octave');
* s("~ sd ~ sd").delay(.5).delaytime(.125).orbit(2) * s("~ sd ~ sd").delay(.5).delaytime(.125).orbit(2)
* ) * )
*/ */
export const { orbit } = registerControl('orbit'); export const { orbit } = registerControl('orbit', 'o');
// TODO: what is this? not found in tidal doc Answer: gain is limited to maximum of 2. This allows you to go over that // 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'); export const { overgain } = registerControl('overgain');
// TODO: what is this? not found in tidal doc. Similar to above, but limited to 1 // TODO: what is this? not found in tidal doc. Similar to above, but limited to 1
@@ -1750,17 +1941,64 @@ export const { semitone } = registerControl('semitone');
// TODO: synth param // TODO: synth param
export const { voice } = registerControl('voice'); export const { voice } = registerControl('voice');
// voicings // https://codeberg.org/uzu/strudel/issues/506 // voicings // https://codeberg.org/uzu/strudel/issues/506
// chord to voice, like C Eb Fm7 G7. the symbols can be defined via addVoicings /**
* The chord to voice
* @name chord
* @param {string | Pattern} symbols chord symbols to voice e.g., C, Eb, Fm7, G7. The symbols can be defined via addVoicings
* @example
* chord("<Am C D F Am E Am E>").voicing()
**/
export const { chord } = registerControl('chord'); export const { chord } = registerControl('chord');
// which dictionary to use for the voicings /**
* Which dictionary to use for the voicings. This falls back to the default dictionary if not provided
*
* @name dictionary
* @param {string} dictionaryName which dictionary (having been defined with `addVoicings`) to use
* @example
* addVoicings('house', {
'': ['7 12 16', '0 7 16', '4 7 12'],
'm': ['0 3 7']
})
chord("<Am C D F Am E Am E>")
.dict('house').anchor(66)
.voicing().room(.5)
**/
export const { dictionary, dict } = registerControl('dictionary', 'dict'); export const { dictionary, dict } = registerControl('dictionary', 'dict');
// the top note to align the voicing to, defaults to c5 /** The top note to align the voicing to. Defaults to c5
*
* @name anchor
* @param {string | Pattern} anchorNote the note to align the voicings to
* @example
* anchor("<c4 g4 c5 g5>").chord("C").voicing()
**/
export const { anchor } = registerControl('anchor'); export const { anchor } = registerControl('anchor');
// how the voicing is offset from the anchored position /**
* Sets how the voicing is offset from the anchored position
*
* @name offset
* @param {number | Pattern} shift the amount to shift the voicing up or down
* @example
* chord("<Am C D F Am E Am E>").offset("<0 1 2 3 4 5>") // alter the voicing each time
**/
export const { offset } = registerControl('offset'); export const { offset } = registerControl('offset');
// how many octaves are voicing steps spread apart, defaults to 1 /**
* How many octaves are voicing steps spread apart, defaults to 1
*
* @name octaves
* @param {number | Pattern} count the number of octaves
* @example
* chord("<Am C D F Am E Am E>").octaves("<2 4>").voicing()
**/
export const { octaves } = registerControl('octaves'); export const { octaves } = registerControl('octaves');
// below = anchor note will be removed from the voicing, useful for melody harmonization /**
* Remove anchor note from the voicing. Useful for melody harmonization
*
* @name mode
* @param {string | Pattern} modeName one of {below | above | duck | root}
* @example
* mode("<below above duck root>").chord("C").voicing()
*
**/
export const { mode } = registerControl(['mode', 'anchor']); export const { mode } = registerControl(['mode', 'anchor']);
/** /**
@@ -2072,7 +2310,6 @@ export const { tsdelay } = registerControl('tsdelay');
export const { real } = registerControl('real'); export const { real } = registerControl('real');
export const { imag } = registerControl('imag'); export const { imag } = registerControl('imag');
export const { enhance } = registerControl('enhance'); export const { enhance } = registerControl('enhance');
export const { partials } = registerControl('partials');
export const { comb } = registerControl('comb'); export const { comb } = registerControl('comb');
export const { smear } = registerControl('smear'); export const { smear } = registerControl('smear');
export const { scram } = registerControl('scram'); export const { scram } = registerControl('scram');
+7 -7
View File
@@ -35,18 +35,18 @@ const right = function (n, x) {
return result; return result;
}; };
const _bjork = function (n, x) { const _bjorklund = function (n, x) {
const [ons, offs] = n; const [ons, offs] = n;
return Math.min(ons, offs) <= 1 ? [n, x] : _bjork(...(ons > offs ? left(n, x) : right(n, x))); return Math.min(ons, offs) <= 1 ? [n, x] : _bjorklund(...(ons > offs ? left(n, x) : right(n, x)));
}; };
export const bjork = function (ons, steps) { export const bjorklund = function (ons, steps) {
const inverted = ons < 0; const inverted = ons < 0;
const absOns = Math.abs(ons); const absOns = Math.abs(ons);
const offs = steps - absOns; const offs = steps - absOns;
const ones = Array(absOns).fill([1]); const ones = Array(absOns).fill([1]);
const zeros = Array(offs).fill([0]); const zeros = Array(offs).fill([0]);
const result = _bjork([absOns, offs], [ones, zeros]); const result = _bjorklund([absOns, offs], [ones, zeros]);
const pattern = flatten(result[1][0]).concat(flatten(result[1][1])); const pattern = flatten(result[1][0]).concat(flatten(result[1][1]));
return inverted ? pattern.map((x) => 1 - x) : pattern; return inverted ? pattern.map((x) => 1 - x) : pattern;
}; };
@@ -128,7 +128,7 @@ export const bjork = function (ons, steps) {
*/ */
const _euclidRot = function (pulses, steps, rotation) { const _euclidRot = function (pulses, steps, rotation) {
const b = bjork(pulses, steps); const b = bjorklund(pulses, steps);
if (rotation) { if (rotation) {
return rotate(b, -rotation); return rotate(b, -rotation);
} }
@@ -139,7 +139,7 @@ export const euclid = register('euclid', function (pulses, steps, pat) {
return pat.struct(_euclidRot(pulses, steps, 0)); return pat.struct(_euclidRot(pulses, steps, 0));
}); });
export const e = register('e', function (euc, pat) { export const bjork = register('bjork', function (euc, pat) {
if (!Array.isArray(euc)) { if (!Array.isArray(euc)) {
euc = [euc]; euc = [euc];
} }
@@ -216,6 +216,6 @@ export const euclidLegatoRot = register(['euclidLegatoRot'], function (pulses, s
* .pan(sine.slow(8)) * .pan(sine.slow(8))
*/ */
export const { euclidish, eish } = register(['euclidish', 'eish'], function (pulses, steps, perc, pat) { export const { euclidish, eish } = register(['euclidish', 'eish'], function (pulses, steps, perc, pat) {
const morphed = _morph(bjork(pulses, steps), new Array(pulses).fill(1), perc); const morphed = _morph(bjorklund(pulses, steps), new Array(pulses).fill(1), perc);
return pat.struct(morphed).setSteps(steps); return pat.struct(morphed).setSteps(steps);
}); });
+3
View File
@@ -37,5 +37,8 @@
"devDependencies": { "devDependencies": {
"vite": "^6.0.11", "vite": "^6.0.11",
"vitest": "^3.0.4" "vitest": "^3.0.4"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+66 -3
View File
@@ -25,7 +25,7 @@ import {
stringifyValues, stringifyValues,
} from './util.mjs'; } from './util.mjs';
import drawLine from './drawLine.mjs'; import drawLine from './drawLine.mjs';
import { logger } from './logger.mjs'; import { errorLogger, logger } from './logger.mjs';
let stringParser; let stringParser;
@@ -414,7 +414,7 @@ export class Pattern {
try { try {
return this.query(new State(new TimeSpan(begin, end), controls)); return this.query(new State(new TimeSpan(begin, end), controls));
} catch (err) { } catch (err) {
logger(`[query]: ${err.message}`, 'error'); errorLogger(err, 'query');
return []; return [];
} }
} }
@@ -2574,8 +2574,8 @@ export const { chunkBack, chunkback } = register(
* @returns Pattern * @returns Pattern
* @example * @example
* "<0 8> 1 2 3 4 5 6 7" * "<0 8> 1 2 3 4 5 6 7"
* .fastChunk(4, x => x.color('red')).slow(2)
* .scale("C2:major").note() * .scale("C2:major").note()
* .fastChunk(4, x => x.color('red')).slow(2)
*/ */
export const { fastchunk, fastChunk } = register( export const { fastchunk, fastChunk } = register(
['fastchunk', 'fastChunk'], ['fastchunk', 'fastChunk'],
@@ -3624,3 +3624,66 @@ for (const name of distAlgoNames) {
return this.distort(argsPat); return this.distort(argsPat);
}; };
} }
/**
* Turns a list of patterns into a single pattern which outputs list-values
*
* @name parray
* @returns Pattern
*/
export const parray = (pats) => {
const pack = (...xs) => xs;
let acc = pure(curry(pack, null, pats.length));
for (const p of pats) acc = acc.appBoth(reify(p));
return acc;
};
const _ensureListPattern = (list) => {
if (Array.isArray(list)) {
return parray(list);
}
return reify(list);
};
/**
* Scale the magnitude of the harmonics of one of the core synths ('sine', 'tri', 'saw', ..)
*
* Can also be used to create a new synth via `s('user').partials(...)`
*
* @name partials
* @param {number[] | Pattern} magnitudes List of [0, 1] magnitudes for partials. 0th entry is the fundamental harmonic (i.e. DC offset is skipped)
* @example
* s("user").seg(16).n(irand(8)).scale("A:major")
* .partials([1, 0, 1, 0, 0, 1])
* @example
* s("saw").seg(8).n(irand(12)).scale("G#:minor")
* .partials(binaryL(irand(256).add("1")))
*/
Pattern.prototype.partials = function (list) {
return this.withValue((v) => (l) => ({ ...v, partials: l })).appLeft(_ensureListPattern(list));
};
// Also create a top-level function
export const partials = (list) => {
return _ensureListPattern(list).as('partials');
};
/**
* Rotates the harmonics of one of the core synths ('sine', 'tri', 'saw', 'user', ..) by a list of phases
*
* @name phases
* @param {number[] | Pattern} phases List of [0, 1) phases for partials. 0th entry is the fundamental phase (i.e. DC offset is skipped)
* @example
* // Phase cancellation
* s("saw").seg(8).n(irand(12)).scale("G#1:minor")
* .partials(partials([1, 1, 1]))
* .superimpose(x => x.phases([0.5, 0.5, 0.5]))
*/
Pattern.prototype.phases = function (list) {
return this.withValue((v) => (l) => ({ ...v, phases: l })).appLeft(_ensureListPattern(list));
};
// Also create a top-level function
export const phases = (list) => {
return _ensureListPattern(list).as('phases');
};
+17 -7
View File
@@ -152,9 +152,9 @@ export function repl({
// allows muting a pattern x with x_ or _x // allows muting a pattern x with x_ or _x
return silence; return silence;
} }
if (id === '$') { if (id.includes('$')) {
// allows adding anonymous patterns with $: // allows adding anonymous patterns with $:
id = `$${anonymousIndex}`; id = `${id}${anonymousIndex}`;
anonymousIndex++; anonymousIndex++;
} }
pPatterns[id] = this; pPatterns[id] = this;
@@ -215,8 +215,19 @@ export function repl({
let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptions); let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptions);
if (Object.keys(pPatterns).length) { if (Object.keys(pPatterns).length) {
let patterns = []; let patterns = [];
let soloActive = false;
for (const [key, value] of Object.entries(pPatterns)) { for (const [key, value] of Object.entries(pPatterns)) {
patterns.push(value.withState((state) => state.setControls({ id: key }))); // handle soloed patterns ex: S$: s("bd!4")
const isSolod = key.length > 1 && key.startsWith('S');
if (isSolod && soloActive === false) {
// first time we see a soloed pattern, clear existing patterns
patterns = [];
soloActive = true;
}
if (!soloActive || (soloActive && isSolod)) {
const valWithState = value.withState((state) => state.setControls({ id: key }));
patterns.push(valWithState);
}
} }
if (eachTransform) { if (eachTransform) {
// Explicit lambda so only element (not index and array) are passed // Explicit lambda so only element (not index and array) are passed
@@ -227,14 +238,13 @@ export function repl({
pattern = eachTransform(pattern); pattern = eachTransform(pattern);
} }
if (allTransforms.length) { if (allTransforms.length) {
for (let i in allTransforms) { for (const transform of allTransforms) {
pattern = allTransforms[i](pattern); pattern = transform(pattern);
} }
} }
if (!isPattern(pattern)) { if (!isPattern(pattern)) {
const message = `got "${typeof evaluated}" instead of pattern`; pattern = silence;
throw new Error(message + (typeof evaluated === 'function' ? ', did you forget to call a function?' : '.'));
} }
logger(`[eval] code updated`); logger(`[eval] code updated`);
pattern = await setPattern(pattern, autostart); pattern = await setPattern(pattern, autostart);
+49 -5
View File
@@ -201,8 +201,7 @@ const timeToIntSeed = (x) => xorwise(Math.trunc(_frac(x / 300) * 536870912));
const intSeedToRand = (x) => (x % 536870912) / 536870912; const intSeedToRand = (x) => (x % 536870912) / 536870912;
// Set first random value to be 0.5, otherwise it would be 0. https://github.com/tidalcycles/strudel/issues/1293 const timeToRand = (x) => Math.abs(intSeedToRand(timeToIntSeed(x)));
const timeToRand = (x) => (x === 0 ? 0.5 : Math.abs(intSeedToRand(timeToIntSeed(x))));
const timeToRandsPrime = (seed, n) => { const timeToRandsPrime = (seed, n) => {
const result = []; const result = [];
@@ -229,7 +228,7 @@ const timeToRands = (t, n) => timeToRandsPrime(timeToIntSeed(t), n);
export const run = (n) => saw.range(0, n).round().segment(n); export const run = (n) => saw.range(0, n).round().segment(n);
/** /**
* Creates a pattern from a binary number. * Creates a binary pattern from a number.
* *
* @name binary * @name binary
* @param {number} n - input number to convert to binary * @param {number} n - input number to convert to binary
@@ -243,7 +242,7 @@ export const binary = (n) => {
}; };
/** /**
* Creates a pattern from a binary number, padded to n bits long. * Creates a binary pattern from a number, padded to n bits long.
* *
* @name binaryN * @name binaryN
* @param {number} n - input number to convert to binary * @param {number} n - input number to convert to binary
@@ -259,6 +258,51 @@ export const binaryN = (n, nBits = 16) => {
return reify(n).segment(nBits).brshift(bitPos).band(pure(1)); return reify(n).segment(nBits).brshift(bitPos).band(pure(1));
}; };
/**
* Creates a binary list pattern from a number.
*
* @name binaryL
* @param {number} n - input number to convert to binary
* s("saw").seg(8)
* .partials(binaryL(irand(4096).add(1)))
*/
export const binaryL = (n) => {
const nBits = reify(n).log2(0).floor().add(1);
return binaryNL(n, nBits);
};
/**
* Creates a binary list pattern from a number, padded to n bits long.
*
* @name binaryNL
* @param {number} n - input number to convert to binary
* @param {number} nBits - pattern length, defaults to 16
*/
export const binaryNL = (n, nBits = 16) => {
return reify(n)
.withValue((v) => (bits) => {
const bList = [];
for (let i = bits - 1; i >= 0; i--) {
bList.push((v >> i) & 1);
}
return bList;
})
.appLeft(reify(nBits));
};
/**
* Creates a list of random numbers of the given length
*
* @name randL
* @param {number} n Number of random numbers to sample
* @example
* s("saw").seg(16).n(irand(12)).scale("F1:minor")
* .partials(randL(8))
*/
export const randL = (n) => {
return signal((t) => (nVal) => timeToRands(t, nVal).map(Math.abs)).appLeft(reify(n));
};
export const randrun = (n) => { export const randrun = (n) => {
return signal((t) => { return signal((t) => {
// Without adding 0.5, the first cycle is always 0,1,2,3,... // Without adding 0.5, the first cycle is always 0,1,2,3,...
@@ -480,7 +524,7 @@ export const wchoose = (...pairs) => wchooseWith(rand, ...pairs);
* @example * @example
* wchooseCycles(["bd",10], ["hh",1], ["sd",1]).s().fast(8) * wchooseCycles(["bd",10], ["hh",1], ["sd",1]).s().fast(8)
* @example * @example
* wchooseCycles(["bd bd bd",5], ["hh hh hh",3], ["sd sd sd",1]).fast(4).s() * wchooseCycles(["c c c",5], ["a a a",3], ["f f f",1]).fast(4).note()
* @example * @example
* // The probability can itself be a pattern * // The probability can itself be a pattern
* wchooseCycles(["bd(3,8)","<5 0>"], ["hh hh hh",3]).fast(4).s() * wchooseCycles(["bd(3,8)","<5 0>"], ["hh hh hh",3]).fast(4).s()
+8 -8
View File
@@ -1,14 +1,14 @@
import { bjork } from '../euclid.mjs'; import { bjorklund } from '../euclid.mjs';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { fastcat } from '../pattern.mjs'; import { fastcat } from '../pattern.mjs';
describe('bjork', () => { describe('bjorklund', () => {
it('should apply bjorklund to ons and steps', () => { it('should apply bjorklundlund to ons and steps', () => {
expect(bjork(3, 8)).toStrictEqual([1, 0, 0, 1, 0, 0, 1, 0]); expect(bjorklund(3, 8)).toStrictEqual([1, 0, 0, 1, 0, 0, 1, 0]);
expect(bjork(-3, 8)).toStrictEqual([0, 1, 1, 0, 1, 1, 0, 1]); expect(bjorklund(-3, 8)).toStrictEqual([0, 1, 1, 0, 1, 1, 0, 1]);
expect(bjork(8, 8)).toStrictEqual([1, 1, 1, 1, 1, 1, 1, 1]); expect(bjorklund(8, 8)).toStrictEqual([1, 1, 1, 1, 1, 1, 1, 1]);
expect(bjork(-8, 8)).toStrictEqual([0, 0, 0, 0, 0, 0, 0, 0]); expect(bjorklund(-8, 8)).toStrictEqual([0, 0, 0, 0, 0, 0, 0, 0]);
expect(bjork(5, 8)).toStrictEqual([1, 0, 1, 1, 0, 1, 1, 0]); expect(bjorklund(5, 8)).toStrictEqual([1, 0, 1, 1, 0, 1, 1, 0]);
}); });
}); });
+7 -3
View File
@@ -7,8 +7,8 @@ This program is free software: you can redistribute it and/or modify it under th
import { logger } from './logger.mjs'; import { logger } from './logger.mjs';
// returns true if the given string is a note // returns true if the given string is a note
export const isNoteWithOctave = (name) => /^[a-gA-G][#bs]*[0-9]$/.test(name); export const isNoteWithOctave = (name) => /^[a-gA-G][#bsf]*[0-9]*$/.test(name);
export const isNote = (name) => /^[a-gA-G][#bsf]*-?[0-9]?$/.test(name); export const isNote = (name) => /^[a-gA-G][#bsf]*-?[0-9]*$/.test(name);
export const tokenizeNote = (note) => { export const tokenizeNote = (note) => {
if (typeof note !== 'string') { if (typeof note !== 'string') {
return []; return [];
@@ -23,6 +23,10 @@ export const tokenizeNote = (note) => {
const chromas = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; const chromas = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
const accs = { '#': 1, b: -1, s: 1, f: -1 }; const accs = { '#': 1, b: -1, s: 1, f: -1 };
export const getAccidentalsOffset = (accidentals) => {
return accidentals?.split('').reduce((o, char) => o + accs[char], 0) || 0;
};
// turns the given note into its midi number representation // turns the given note into its midi number representation
export const noteToMidi = (note, defaultOctave = 3) => { export const noteToMidi = (note, defaultOctave = 3) => {
const [pc, acc, oct = defaultOctave] = tokenizeNote(note); const [pc, acc, oct = defaultOctave] = tokenizeNote(note);
@@ -30,7 +34,7 @@ export const noteToMidi = (note, defaultOctave = 3) => {
throw new Error('not a note: "' + note + '"'); throw new Error('not a note: "' + note + '"');
} }
const chroma = chromas[pc.toLowerCase()]; const chroma = chromas[pc.toLowerCase()];
const offset = acc?.split('').reduce((o, char) => o + accs[char], 0) || 0; const offset = getAccidentalsOffset(acc);
return (Number(oct) + 1) * 12 + chroma + offset; return (Number(oct) + 1) * 12 + chroma + offset;
}; };
export const midiToFreq = (n) => { export const midiToFreq = (n) => {
+3
View File
@@ -38,5 +38,8 @@
}, },
"devDependencies": { "devDependencies": {
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+4 -1
View File
@@ -25,5 +25,8 @@
"@strudel/core": "workspace:*", "@strudel/core": "workspace:*",
"@tauri-apps/api": "^2.2.0" "@tauri-apps/api": "^2.2.0"
}, },
"homepage": "https://codeberg.org/uzu/strudel#readme" "homepage": "https://codeberg.org/uzu/strudel#readme",
"engines": {
"node": ">=18.0.0"
}
} }
+3
View File
@@ -33,5 +33,8 @@
}, },
"devDependencies": { "devDependencies": {
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+4 -1
View File
@@ -20,5 +20,8 @@
"bugs": { "bugs": {
"url": "https://codeberg.org/uzu/strudel/issues" "url": "https://codeberg.org/uzu/strudel/issues"
}, },
"homepage": "https://codeberg.org/uzu/strudel#readme" "homepage": "https://codeberg.org/uzu/strudel#readme",
"engines": {
"node": ">=18.0.0"
}
} }
+6
View File
@@ -40,6 +40,12 @@ const pattern = sequence([
- D-Pad - D-Pad
- `up`, `down`, `left`, `right` (or `u`, `d`, `l`, `r` or uppercase) - `up`, `down`, `left`, `right` (or `u`, `d`, `l`, `r` or uppercase)
- Toggle versions: `tglUp`, `tglDown`, `tglLeft`, `tglRight`(or `tglU`, `tglD`, `tglL`, `tglR`) - Toggle versions: `tglUp`, `tglDown`, `tglLeft`, `tglRight`(or `tglU`, `tglD`, `tglL`, `tglR`)
- Stick Buttons
- `l3`, `r3` (or `ls`, `rs`)
- Toggle versions: `tglL3`, `tglR3` (or `tglLS`, `tglRS`)
- System Buttons
- `start`, `back` (or uppercase `START`, `BACK`)
- Toggle versions: `tglStart`, `tglBack` (or `tglSTART`, `tglBACK`)
### Analog Sticks ### Analog Sticks
- Left Stick - Left Stick
+4
View File
@@ -29,6 +29,10 @@ The gamepad module provides access to buttons and analog sticks as normalized si
| | Toggle versions: `tglLB`, `tglRB`, `tglLT`, `tglRT` | | | Toggle versions: `tglLB`, `tglRB`, `tglLT`, `tglRT` |
| D-Pad | `up`, `down`, `left`, `right` (or `u`, `d`, `l`, `r` or uppercase) | | D-Pad | `up`, `down`, `left`, `right` (or `u`, `d`, `l`, `r` or uppercase) |
| | Toggle versions: `tglUp`, `tglDown`, `tglLeft`, `tglRight` (or `tglU`, `tglD`, `tglL`, `tglR`) | | | Toggle versions: `tglUp`, `tglDown`, `tglLeft`, `tglRight` (or `tglU`, `tglD`, `tglL`, `tglR`) |
| Stick Buttons | `l3`, 'r3' (or `ls`, `rs`) |
| | Toggle versions: `tglL3`, 'tglR3' (or `tglLs`, `tglRs`) |
| System Buttons | `start`, `back` (or uppercase `START`, `BACK`) |
| | Toggle versions: `tglStart`, `tglBack` (or `tglSTART`, `tglBACK`) |
### Analog Sticks ### Analog Sticks
+4
View File
@@ -14,6 +14,10 @@ export const buttonMap = {
rt: 7, rt: 7,
back: 8, back: 8,
start: 9, start: 9,
l3: 10,
ls: 10,
r3: 11,
rs: 11,
u: 12, u: 12,
up: 12, up: 12,
d: 13, d: 13,
+3
View File
@@ -33,5 +33,8 @@
}, },
"devDependencies": { "devDependencies": {
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+3
View File
@@ -34,5 +34,8 @@
"devDependencies": { "devDependencies": {
"tree-sitter-haskell": "^0.23.1", "tree-sitter-haskell": "^0.23.1",
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+1
View File
@@ -35,6 +35,7 @@ export async function initHydra(options = {}) {
hydra.synth.s0.init({ src: canvas }); hydra.synth.s0.init({ src: canvas });
} }
} }
return hydra;
} }
export function clearHydra() { export function clearHydra() {
+3
View File
@@ -40,5 +40,8 @@
"devDependencies": { "devDependencies": {
"pkg": "^5.8.1", "pkg": "^5.8.1",
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+59 -42
View File
@@ -5,9 +5,10 @@ This program is free software: you can redistribute it and/or modify it under th
*/ */
import * as _WebMidi from 'webmidi'; import * as _WebMidi from 'webmidi';
import { Pattern, getEventOffsetMs, isPattern, logger, ref } from '@strudel/core'; import { Pattern, isPattern, logger, ref } from '@strudel/core';
import { noteToMidi, getControlName } from '@strudel/core'; import { noteToMidi, getControlName } from '@strudel/core';
import { Note } from 'webmidi'; import { Note } from 'webmidi';
import { scheduleAtTime } from '../superdough/helpers.mjs';
// if you use WebMidi from outside of this package, make sure to import that instance: // if you use WebMidi from outside of this package, make sure to import that instance:
export const { WebMidi } = _WebMidi; export const { WebMidi } = _WebMidi;
@@ -190,7 +191,7 @@ function mapCC(mapping, value) {
} }
// sends a cc message to the given device on the given channel // sends a cc message to the given device on the given channel
function sendCC(ccn, ccv, device, midichan, timeOffsetString) { function sendCC(ccn, ccv, device, midichan, targetTime) {
if (typeof ccv !== 'number' || ccv < 0 || ccv > 1) { if (typeof ccv !== 'number' || ccv < 0 || ccv > 1) {
throw new Error('expected ccv to be a number between 0 and 1'); throw new Error('expected ccv to be a number between 0 and 1');
} }
@@ -198,19 +199,23 @@ function sendCC(ccn, ccv, device, midichan, timeOffsetString) {
throw new Error('expected ccn to be a number or a string'); throw new Error('expected ccn to be a number or a string');
} }
const scaled = Math.round(ccv * 127); const scaled = Math.round(ccv * 127);
device.sendControlChange(ccn, scaled, midichan, { time: timeOffsetString }); scheduleAtTime(() => {
device.sendControlChange(ccn, scaled, midichan);
}, targetTime);
} }
// sends a program change message to the given device on the given channel // sends a program change message to the given device on the given channel
function sendProgramChange(progNum, device, midichan, timeOffsetString) { function sendProgramChange(progNum, device, midichan, targetTime) {
if (typeof progNum !== 'number' || progNum < 0 || progNum > 127) { if (typeof progNum !== 'number' || progNum < 0 || progNum > 127) {
throw new Error('expected progNum (program change) to be a number between 0 and 127'); throw new Error('expected progNum (program change) to be a number between 0 and 127');
} }
device.sendProgramChange(progNum, midichan, { time: timeOffsetString }); scheduleAtTime(() => {
device.sendProgramChange(progNum, midichan);
}, targetTime);
} }
// sends a sysex message to the given device on the given channel // sends a sysex message to the given device on the given channel
function sendSysex(sysexid, sysexdata, device, timeOffsetString) { function sendSysex(sysexid, sysexdata, device, targetTime) {
if (Array.isArray(sysexid)) { if (Array.isArray(sysexid)) {
if (!sysexid.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) { if (!sysexid.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
throw new Error('all sysexid bytes must be integers between 0 and 255'); throw new Error('all sysexid bytes must be integers between 0 and 255');
@@ -225,11 +230,13 @@ function sendSysex(sysexid, sysexdata, device, timeOffsetString) {
if (!sysexdata.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) { if (!sysexdata.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
throw new Error('all sysex bytes must be integers between 0 and 255'); throw new Error('all sysex bytes must be integers between 0 and 255');
} }
device.sendSysex(sysexid, sysexdata, { time: timeOffsetString }); scheduleAtTime(() => {
device.sendSysex(sysexid, sysexdata);
}, targetTime);
} }
// sends a NRPN message to the given device on the given channel // sends a NRPN message to the given device on the given channel
function sendNRPN(nrpnn, nrpv, device, midichan, timeOffsetString) { function sendNRPN(nrpnn, nrpv, device, midichan, targetTime) {
if (Array.isArray(nrpnn)) { if (Array.isArray(nrpnn)) {
if (!nrpnn.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) { if (!nrpnn.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
throw new Error('all nrpnn bytes must be integers between 0 and 255'); throw new Error('all nrpnn bytes must be integers between 0 and 255');
@@ -237,28 +244,34 @@ function sendNRPN(nrpnn, nrpv, device, midichan, timeOffsetString) {
} else if (!Number.isInteger(nrpv) || nrpv < 0 || nrpv > 255) { } else if (!Number.isInteger(nrpv) || nrpv < 0 || nrpv > 255) {
throw new Error('A:sysexid must be an number between 0 and 255 or an array of such integers'); throw new Error('A:sysexid must be an number between 0 and 255 or an array of such integers');
} }
scheduleAtTime(() => {
device.sendNRPN(nrpnn, nrpv, midichan, { time: timeOffsetString }); device.sendNRPN(nrpnn, nrpv, midichan);
}, targetTime);
} }
// sends a pitch bend message to the given device on the given channel // sends a pitch bend message to the given device on the given channel
function sendPitchBend(midibend, device, midichan, timeOffsetString) { function sendPitchBend(midibend, device, midichan, targetTime) {
if (typeof midibend !== 'number' || midibend < -1 || midibend > 1) { if (typeof midibend !== 'number' || midibend < -1 || midibend > 1) {
throw new Error('expected midibend to be a number between -1 and 1'); throw new Error('expected midibend to be a number between -1 and 1');
} }
device.sendPitchBend(midibend, midichan, { time: timeOffsetString }); scheduleAtTime(() => {
device.sendPitchBend(midibend, midichan);
}, targetTime);
} }
// sends a channel aftertouch message to the given device on the given channel // sends a channel aftertouch message to the given device on the given channel
function sendAftertouch(miditouch, device, midichan, timeOffsetString) { function sendAftertouch(miditouch, device, midichan, targetTime) {
if (typeof miditouch !== 'number' || miditouch < 0 || miditouch > 1) { if (typeof miditouch !== 'number' || miditouch < 0 || miditouch > 1) {
throw new Error('expected miditouch to be a number between 0 and 1'); throw new Error('expected miditouch to be a number between 0 and 1');
} }
device.sendChannelAftertouch(miditouch, midichan, { time: timeOffsetString });
scheduleAtTime(() => {
device.sendChannelAftertouch(miditouch, midichan);
}, targetTime);
} }
// sends a note message to the given device on the given channel // sends a note message to the given device on the given channel
function sendNote(note, velocity, duration, device, midichan, timeOffsetString) { function sendNote(note, velocity, duration, device, midichan, targetTime) {
if (note == null || note === '') { if (note == null || note === '') {
throw new Error('note cannot be null or empty'); throw new Error('note cannot be null or empty');
} }
@@ -268,12 +281,12 @@ function sendNote(note, velocity, duration, device, midichan, timeOffsetString)
if (duration != null && (typeof duration !== 'number' || duration < 0)) { if (duration != null && (typeof duration !== 'number' || duration < 0)) {
throw new Error('duration must be a positive number'); throw new Error('duration must be a positive number');
} }
const midiNumber = typeof note === 'number' ? note : noteToMidi(note); const midiNumber = typeof note === 'number' ? note : noteToMidi(note);
const midiNote = new Note(midiNumber, { attack: velocity, duration }); const midiNote = new Note(midiNumber, { attack: velocity, duration });
device.playNote(midiNote, midichan, {
time: timeOffsetString, scheduleAtTime(() => {
}); device.playNote(midiNote, midichan);
}, targetTime);
} }
/** /**
@@ -309,7 +322,6 @@ Pattern.prototype.midi = function (midiport, options = {}) {
let midiConfig = { let midiConfig = {
// Default configuration values // Default configuration values
isController: false, // Disable sending notes for midi controllers isController: false, // Disable sending notes for midi controllers
latencyMs: 34, // Default latency to get audio engine to line up in ms
noteOffsetMs: 10, // Default note-off offset to prevent glitching in ms noteOffsetMs: 10, // Default note-off offset to prevent glitching in ms
midichannel: 1, // Default MIDI channel midichannel: 1, // Default MIDI channel
velocity: 0.9, // Default velocity velocity: 0.9, // Default velocity
@@ -333,18 +345,13 @@ Pattern.prototype.midi = function (midiport, options = {}) {
logger(`Midi device disconnected! Available: ${getMidiDeviceNamesString(outputs)}`), logger(`Midi device disconnected! Available: ${getMidiDeviceNamesString(outputs)}`),
}); });
return this.onTrigger((hap, currentTime, cps, targetTime) => { return this.onTrigger((hap, _currentTime, cps, targetTime) => {
if (!WebMidi.enabled) { if (!WebMidi.enabled) {
logger('Midi not enabled'); logger('Midi not enabled');
return; return;
} }
hap.ensureObjectValue(); hap.ensureObjectValue();
//magic number to get audio engine to line up, can probably be calculated somehow
const latencyMs = midiConfig.latencyMs;
// passing a string with a +num into the webmidi api adds an offset to the current time https://webmidijs.org/api/classes/Output
const timeOffsetString = `+${getEventOffsetMs(targetTime, currentTime) + latencyMs}`;
// midi event values from hap with configurable defaults // midi event values from hap with configurable defaults
let { let {
note, note,
@@ -380,7 +387,7 @@ Pattern.prototype.midi = function (midiport, options = {}) {
// if midimap is set, send a cc messages from defined controls // if midimap is set, send a cc messages from defined controls
if (midicontrolMap.has(midimap)) { if (midicontrolMap.has(midimap)) {
const ccs = mapCC(midicontrolMap.get(midimap), hap.value); const ccs = mapCC(midicontrolMap.get(midimap), hap.value);
ccs.forEach(({ ccn, ccv }) => sendCC(ccn, ccv, device, midichan, timeOffsetString)); ccs.forEach(({ ccn, ccv }) => sendCC(ccn, ccv, device, midichan, targetTime));
} else if (midimap !== 'default') { } else if (midimap !== 'default') {
// Add warning when a non-existent midimap is specified // Add warning when a non-existent midimap is specified
logger(`[midi] midimap "${midimap}" not found! Available maps: ${[...midicontrolMap.keys()].join(', ')}`); logger(`[midi] midimap "${midimap}" not found! Available maps: ${[...midicontrolMap.keys()].join(', ')}`);
@@ -392,12 +399,12 @@ Pattern.prototype.midi = function (midiport, options = {}) {
// try to prevent glitching by subtracting noteOffsetMs from the duration length // try to prevent glitching by subtracting noteOffsetMs from the duration length
const duration = (hap.duration.valueOf() / cps) * 1000 - midiConfig.noteOffsetMs; const duration = (hap.duration.valueOf() / cps) * 1000 - midiConfig.noteOffsetMs;
sendNote(note, velocity, duration, device, midichan, timeOffsetString); sendNote(note, velocity, duration, device, midichan, targetTime);
} }
// Handle program change // Handle program change
if (progNum !== undefined) { if (progNum !== undefined) {
sendProgramChange(progNum, device, midichan, timeOffsetString); sendProgramChange(progNum, device, midichan, targetTime);
} }
// Handle sysex // Handle sysex
@@ -407,53 +414,63 @@ Pattern.prototype.midi = function (midiport, options = {}) {
// if sysexid is an array the first byte is 0x00 // if sysexid is an array the first byte is 0x00
if (sysexid !== undefined && sysexdata !== undefined) { if (sysexid !== undefined && sysexdata !== undefined) {
sendSysex(sysexid, sysexdata, device, timeOffsetString); sendSysex(sysexid, sysexdata, device, targetTime);
} }
// Handle control change // Handle control change
if (ccv !== undefined && ccn !== undefined) { if (ccv !== undefined && ccn !== undefined) {
sendCC(ccn, ccv, device, midichan, timeOffsetString); sendCC(ccn, ccv, device, midichan, targetTime);
} }
// Handle NRPN non-registered parameter number // Handle NRPN non-registered parameter number
if (nrpnn !== undefined && nrpv !== undefined) { if (nrpnn !== undefined && nrpv !== undefined) {
sendNRPN(nrpnn, nrpv, device, midichan, timeOffsetString); sendNRPN(nrpnn, nrpv, device, midichan, targetTime);
} }
// Handle midibend // Handle midibend
if (midibend !== undefined) { if (midibend !== undefined) {
sendPitchBend(midibend, device, midichan, timeOffsetString); sendPitchBend(midibend, device, midichan, targetTime);
} }
// Handle miditouch // Handle miditouch
if (miditouch !== undefined) { if (miditouch !== undefined) {
sendAftertouch(miditouch, device, midichan, timeOffsetString); sendAftertouch(miditouch, device, midichan, targetTime);
} }
// Handle midicmd // Handle midicmd
if (hap.whole.begin + 0 === 0) { if (hap.whole.begin + 0 === 0) {
// we need to start here because we have the timing info // we need to start here because we have the timing info
device.sendStart({ time: timeOffsetString }); scheduleAtTime(() => {
device.sendStart();
}, targetTime);
} }
if (['clock', 'midiClock'].includes(midicmd)) { if (['clock', 'midiClock'].includes(midicmd)) {
device.sendClock({ time: timeOffsetString }); scheduleAtTime(() => {
device.sendClock();
}, targetTime);
} else if (['start'].includes(midicmd)) { } else if (['start'].includes(midicmd)) {
device.sendStart({ time: timeOffsetString }); scheduleAtTime(() => {
device.sendStart();
}, targetTime);
} else if (['stop'].includes(midicmd)) { } else if (['stop'].includes(midicmd)) {
device.sendStop({ time: timeOffsetString }); scheduleAtTime(() => {
device.sendStop();
}, targetTime);
} else if (['continue'].includes(midicmd)) { } else if (['continue'].includes(midicmd)) {
device.sendContinue({ time: timeOffsetString }); scheduleAtTime(() => {
device.sendContinue();
}, targetTime);
} else if (Array.isArray(midicmd)) { } else if (Array.isArray(midicmd)) {
if (midicmd[0] === 'progNum') { if (midicmd[0] === 'progNum') {
sendProgramChange(midicmd[1], device, midichan, timeOffsetString); sendProgramChange(midicmd[1], device, midichan, targetTime);
} else if (midicmd[0] === 'cc') { } else if (midicmd[0] === 'cc') {
if (midicmd.length === 2) { if (midicmd.length === 2) {
sendCC(midicmd[0], midicmd[1] / 127, device, midichan, timeOffsetString); sendCC(midicmd[0], midicmd[1] / 127, device, midichan, targetTime);
} }
} else if (midicmd[0] === 'sysex') { } else if (midicmd[0] === 'sysex') {
if (midicmd.length === 3) { if (midicmd.length === 3) {
const [_, id, data] = midicmd; const [_, id, data] = midicmd;
sendSysex(id, data, device, timeOffsetString); sendSysex(id, data, device, targetTime);
} }
} }
} }
+3
View File
@@ -35,5 +35,8 @@
}, },
"devDependencies": { "devDependencies": {
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+3
View File
@@ -38,5 +38,8 @@
"peggy": "^4.2.0", "peggy": "^4.2.0",
"vite": "^6.0.11", "vite": "^6.0.11",
"vitest": "^3.0.4" "vitest": "^3.0.4"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+8 -5
View File
@@ -21,13 +21,14 @@ export class MondoParser {
close_curly: /^\}/, close_curly: /^\}/,
number: /^-?[0-9]*\.?[0-9]+/, // before pipe! number: /^-?[0-9]*\.?[0-9]+/, // before pipe!
// TODO: better error handling when "-" is used as rest, e.g "s [- bd]" // TODO: better error handling when "-" is used as rest, e.g "s [- bd]"
op: /^[*/:!@%?+-]|^\.{2}/, // * / : ! @ % ? .. op: /^[*/:!@%?+\-&]|^\.{2}/, // * / : ! @ % ? ..
// dollar: /^\$/, // dollar: /^\$/,
pipe: /^#/, pipe: /^#/,
stack: /^[,$]/, stack: /^[,$]/,
or: /^[|]/, or: /^[|]/,
plain: /^[a-zA-Z0-9-~_^#]+/, plain: /^[a-zA-Z0-9-~_^#]+/,
}; };
op_precedence = [['*', '/', ':', '!', '@', '%', '?', '+', '-', '..'], ['&']];
// matches next token // matches next token
next_token(code, offset = 0) { next_token(code, offset = 0) {
for (let type in this.token_types) { for (let type in this.token_types) {
@@ -150,9 +151,9 @@ export class MondoParser {
} }
return children; return children;
} }
desugar_ops(children) { desugar_ops(children, types) {
while (true) { while (true) {
let opIndex = children.findIndex((child) => child.type === 'op'); let opIndex = children.findIndex((child) => child.type === 'op' && types.includes(child.value));
if (opIndex === -1) break; if (opIndex === -1) break;
const op = { type: 'plain', value: children[opIndex].value }; const op = { type: 'plain', value: children[opIndex].value };
if (opIndex === children.length - 1) { if (opIndex === children.length - 1) {
@@ -263,8 +264,10 @@ export class MondoParser {
// the type we've removed before splitting needs to be added back // the type we've removed before splitting needs to be added back
children = [{ type: 'plain', value: type }, ...children]; children = [{ type: 'plain', value: type }, ...children];
} }
children = this.desugar_ops(children); // for each precendence group, call desugar_ops once
// children = this.desugar_pipes(children, (children) => this.desugar_dollars(children)); this.op_precedence.forEach((ops) => {
children = this.desugar_ops(children, ops);
});
children = this.desugar_pipes(children); children = this.desugar_pipes(children);
return children; return children;
}), }),
+3
View File
@@ -33,5 +33,8 @@
"devDependencies": { "devDependencies": {
"vite": "^6.0.11", "vite": "^6.0.11",
"vitest": "^3.0.4" "vitest": "^3.0.4"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+1
View File
@@ -117,6 +117,7 @@ describe('mondo sugar', () => {
it('should desugar x:y', () => expect(desguar('x:y')).toEqual('(: y x)')); it('should desugar x:y', () => expect(desguar('x:y')).toEqual('(: y x)'));
it('should desugar x:y:z', () => expect(desguar('x:y:z')).toEqual('(: z (: y x))')); it('should desugar x:y:z', () => expect(desguar('x:y:z')).toEqual('(: z (: y x))'));
it('should desugar x:y*x', () => expect(desguar('bd:0*2')).toEqual('(* 2 (: 0 bd))')); it('should desugar x:y*x', () => expect(desguar('bd:0*2')).toEqual('(* 2 (: 0 bd))'));
it('should desugar x&y:z', () => expect(desguar('bd&3:8')).toEqual('(& (: 8 3) bd)'));
it('should desugar a..b', () => expect(desguar('0..2')).toEqual('(.. 2 0)')); it('should desugar a..b', () => expect(desguar('0..2')).toEqual('(.. 2 0)'));
/* it('should desugar x $ y', () => expect(desguar('x $ y')).toEqual('(x y)')); /* it('should desugar x $ y', () => expect(desguar('x $ y')).toEqual('(x y)'));
it('should desugar x $ y z', () => expect(desguar('x $ y z')).toEqual('(x (y z))')); it('should desugar x $ y z', () => expect(desguar('x $ y z')).toEqual('(x (y z))'));
+2
View File
@@ -11,6 +11,7 @@ import {
chooseIn, chooseIn,
degradeBy, degradeBy,
silence, silence,
bjork,
} from '@strudel/core'; } from '@strudel/core';
import { registerLanguage } from '@strudel/transpiler'; import { registerLanguage } from '@strudel/transpiler';
import { MondoRunner } from 'mondolang'; import { MondoRunner } from 'mondolang';
@@ -40,6 +41,7 @@ lib['!'] = replicate;
lib['@'] = expand; lib['@'] = expand;
lib['%'] = pace; lib['%'] = pace;
lib['?'] = degradeBy; // todo: default 0.5 not working.. lib['?'] = degradeBy; // todo: default 0.5 not working..
lib['&'] = bjork;
lib[':'] = tail; lib[':'] = tail;
lib['..'] = range; lib['..'] = range;
lib['def'] = () => silence; lib['def'] = () => silence;
+3
View File
@@ -40,5 +40,8 @@
"mondo": "*", "mondo": "*",
"vite": "^6.0.11", "vite": "^6.0.11",
"vitest": "^3.0.4" "vitest": "^3.0.4"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+3
View File
@@ -33,5 +33,8 @@
}, },
"devDependencies": { "devDependencies": {
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+3
View File
@@ -34,5 +34,8 @@
}, },
"devDependencies": { "devDependencies": {
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+3
View File
@@ -44,5 +44,8 @@
"devDependencies": { "devDependencies": {
"pkg": "^5.8.1", "pkg": "^5.8.1",
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+3
View File
@@ -33,5 +33,8 @@
"homepage": "https://codeberg.org/uzu/strudel#readme", "homepage": "https://codeberg.org/uzu/strudel#readme",
"devDependencies": { "devDependencies": {
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+3
View File
@@ -48,5 +48,8 @@
"@rollup/plugin-replace": "^6.0.2", "@rollup/plugin-replace": "^6.0.2",
"vite": "^6.0.11", "vite": "^6.0.11",
"vite-plugin-bundle-audioworklet": "workspace:*" "vite-plugin-bundle-audioworklet": "workspace:*"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+3
View File
@@ -15,5 +15,8 @@
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"cowsay": "^1.6.0" "cowsay": "^1.6.0"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+1 -3
View File
@@ -9,6 +9,7 @@ import readline from 'readline';
import os from 'os'; import os from 'os';
const LOG = !!process.env.LOG || false; const LOG = !!process.env.LOG || false;
const PORT = process.env.PORT || 5432;
const VALID_AUDIO_EXTENSIONS = ['wav', 'mp3', 'ogg']; const VALID_AUDIO_EXTENSIONS = ['wav', 'mp3', 'ogg'];
const isAudioFile = (f) => { const isAudioFile = (f) => {
@@ -54,7 +55,6 @@ async function getBanks(directory, flat = false) {
banks[bank].push(subDir); banks[bank].push(subDir);
return subDir; return subDir;
}); });
banks._base = `http://localhost:5432`;
return { banks, files }; return { banks, files };
} }
@@ -134,8 +134,6 @@ const server = http.createServer(async (req, res) => {
readStream.pipe(res); readStream.pipe(res);
}); });
// eslint-disable-next-line
const PORT = process.env.PORT || 5432;
const IP_ADDRESS = '0.0.0.0'; const IP_ADDRESS = '0.0.0.0';
let IP; let IP;
const networkInterfaces = os.networkInterfaces(); const networkInterfaces = os.networkInterfaces();
+3
View File
@@ -33,5 +33,8 @@
}, },
"devDependencies": { "devDependencies": {
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+3
View File
@@ -37,5 +37,8 @@
"devDependencies": { "devDependencies": {
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+1 -1
View File
@@ -89,7 +89,7 @@ superdough({ s: 'bd', delay: 0.5 }, 0, 1);
- `decay`: seconds of decay phase - `decay`: seconds of decay phase
- `sustain`: gain of sustain phase - `sustain`: gain of sustain phase
- `release`: seconds of release phase - `release`: seconds of release phase
- `deadline`: seconds until the sound should play (0 = immediate) - `deadline`: seconds from audio context initialization before playing the sound (getAudioContextCurrentTime() = immediate)
- `duration`: seconds the sound should last. optional for one shot samples, required for synth sounds - `duration`: seconds the sound should last. optional for one shot samples, required for synth sounds
### registerSynthSounds() ### registerSynthSounds()
+100 -39
View File
@@ -154,6 +154,24 @@ export const getADSRValues = (params, curve = 'linear', defaultValues) => {
return [Math.max(a ?? 0, envmin), Math.max(d ?? 0, envmin), Math.min(sustain, envmax), Math.max(r ?? 0, releaseMin)]; return [Math.max(a ?? 0, envmin), Math.max(d ?? 0, envmin), Math.min(sustain, envmax), Math.max(r ?? 0, releaseMin)];
}; };
export function getParamLfo(audioContext, param, start, end, lfoValues) {
let { defaultDepth = 1, depth, dcoffset, ...getLfoInputs } = lfoValues;
if (depth == null) {
const hasLFOParams = Object.values(getLfoInputs).some((v) => v != null);
depth = hasLFOParams ? defaultDepth : 0;
}
let lfo;
if (depth) {
lfo = getLfo(audioContext, start, end, {
depth,
dcoffset,
...getLfoInputs,
});
lfo.connect(param);
}
return lfo;
}
// helper utility for applying standard modulators to a parameter // helper utility for applying standard modulators to a parameter
export function applyParameterModulators(audioContext, param, start, end, envelopeValues, lfoValues) { export function applyParameterModulators(audioContext, param, start, end, envelopeValues, lfoValues) {
let { amount, offset, defaultAmount = 1, curve = 'linear', values, holdEnd, defaultValues } = envelopeValues; let { amount, offset, defaultAmount = 1, curve = 'linear', values, holdEnd, defaultValues } = envelopeValues;
@@ -170,55 +188,76 @@ export function applyParameterModulators(audioContext, param, start, end, envelo
const [attack, decay, sustain, release] = getADSRValues(values, curve, defaultValues); const [attack, decay, sustain, release] = getADSRValues(values, curve, defaultValues);
getParamADSR(param, attack, decay, sustain, release, min, max, start, holdEnd, curve); getParamADSR(param, attack, decay, sustain, release, min, max, start, holdEnd, curve);
} }
let lfo; const lfo = getParamLfo(audioContext, param, start, end, lfoValues);
let { defaultDepth = 1, depth, dcoffset, ...getLfoInputs } = lfoValues;
if (depth == null) {
const hasLFOParams = Object.values(getLfoInputs).some((v) => v != null);
depth = hasLFOParams ? defaultDepth : 0;
}
if (depth) {
lfo = getLfo(audioContext, start, end, {
depth,
dcoffset,
...getLfoInputs,
});
lfo.connect(param);
}
return { lfo, disconnect: () => lfo?.disconnect() }; return { lfo, disconnect: () => lfo?.disconnect() };
} }
export function createFilter(context, start, end, params, cps, cycle) {
let {
frequency,
anchor,
env,
type,
model,
q = 1,
drive = 0.69,
depth,
depthfrequency,
dcoffset = -0.5,
skew,
shape,
rate,
sync,
} = params;
export function createFilter(context, type, frequency, Q, att, dec, sus, rel, fenv, start, end, fanchor, model, drive) { let frequencyParam, filter;
const curve = 'exponential';
const [attack, decay, sustain, release] = getADSRValues([att, dec, sus, rel], curve, [0.005, 0.14, 0, 0.1]);
let filter;
let frequencyParam;
if (model === 'ladder') { if (model === 'ladder') {
filter = getWorklet(context, 'ladder-processor', { frequency, q: Q, drive }); filter = getWorklet(context, 'ladder-processor', { frequency, q, drive });
frequencyParam = filter.parameters.get('frequency'); frequencyParam = filter.parameters.get('frequency');
} else { } else {
filter = context.createBiquadFilter(); filter = context.createBiquadFilter();
filter.type = type; filter.type = type;
filter.Q.value = Q; filter.Q.value = q;
filter.frequency.value = frequency; filter.frequency.value = frequency;
frequencyParam = filter.frequency; frequencyParam = filter.frequency;
} }
const envelopeValues = [params.attack, params.decay, params.sustain, params.release];
const [attack, decay, sustain, release] = getADSRValues(envelopeValues, 'exponential', [0.005, 0.14, 0, 0.1]);
// envelope is active when any of these values is set // envelope is active when any of these values is set
const hasEnvelope = att ?? dec ?? sus ?? rel ?? fenv; const hasEnvelope = [...envelopeValues, env].some((v) => v !== undefined);
// Apply ADSR to filter frequency // Apply ADSR to filter frequency
if (hasEnvelope !== undefined) { if (hasEnvelope) {
fenv = nanFallback(fenv, 1, true); env = nanFallback(env, 1, true);
fanchor = nanFallback(fanchor, 0, true); anchor = nanFallback(anchor, 0, true);
const fenvAbs = Math.abs(fenv); const envAbs = Math.abs(env);
const offset = fenvAbs * fanchor; const offset = envAbs * anchor;
let min = clamp(2 ** -offset * frequency, 0, 20000); let min = clamp(2 ** -offset * frequency, 0, 20000);
let max = clamp(2 ** (fenvAbs - offset) * frequency, 0, 20000); let max = clamp(2 ** (envAbs - offset) * frequency, 0, 20000);
if (fenv < 0) [min, max] = [max, min]; if (env < 0) [min, max] = [max, min];
getParamADSR(frequencyParam, attack, decay, sustain, release, min, max, start, end, curve); getParamADSR(frequencyParam, attack, decay, sustain, release, min, max, start, end, 'exponential');
return filter;
} }
if (sync != null) {
rate = cps * sync;
}
const hasLFO = [depth, depthfrequency, skew, shape, rate].some((v) => v !== undefined);
if (hasLFO) {
depth = depth ?? 1;
const time = cycle / cps;
const modDepth = depthfrequency ?? (depth ?? 1) * frequency;
const lfoValues = {
depth: modDepth,
dcoffset,
skew,
shape,
frequency: rate ?? cps,
min: -frequency + 30,
max: 20000 - frequency,
time,
curve: 1,
};
getParamLfo(context, frequencyParam, start, end, lfoValues);
}
return filter; return filter;
} }
@@ -241,7 +280,15 @@ export function drywet(dry, wet, wetAmount = 0) {
let mix = ac.createGain(); let mix = ac.createGain();
dry_gain.connect(mix); dry_gain.connect(mix);
wet_gain.connect(mix); wet_gain.connect(mix);
return mix; return {
node: mix,
onended: () => {
dry_gain.disconnect(mix);
wet_gain.disconnect(mix);
dry.disconnect(dry_gain);
wet.disconnect(wet_gain);
},
};
} }
let curves = ['linear', 'exponential']; let curves = ['linear', 'exponential'];
@@ -276,10 +323,19 @@ export function getVibratoOscillator(param, value, t) {
gain.gain.value = vibmod * 100; gain.gain.value = vibmod * 100;
vibratoOscillator.connect(gain); vibratoOscillator.connect(gain);
gain.connect(param); gain.connect(param);
vibratoOscillator.onended = () => {
gain.disconnect(param);
vibratoOscillator.disconnect(gain);
};
vibratoOscillator.start(t); vibratoOscillator.start(t);
return vibratoOscillator; return vibratoOscillator;
} }
} }
export function scheduleAtTime(callback, targetTime, audioContext = getAudioContext()) {
const currentTime = audioContext.currentTime;
webAudioTimeout(audioContext, callback, currentTime, targetTime);
}
// ConstantSource inherits AudioScheduledSourceNode, which has scheduling abilities // ConstantSource inherits AudioScheduledSourceNode, which has scheduling abilities
// a bit of a hack, but it works very well :) // a bit of a hack, but it works very well :)
export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) { export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) {
@@ -324,9 +380,9 @@ const mod = (freq, range = 1, type = 'sine') => {
} }
osc.start(); osc.start();
const g = new GainNode(ctx, { gain: range }); const g = gainNode(range);
osc.connect(g); // -range, range osc.connect(g); // -range, range
return { node: g, stop: (t) => osc.stop(t) }; return { node: g, stop: (t) => osc.stop(t), osc: osc };
}; };
const fm = (frequencyparam, harmonicityRatio, modulationIndex, wave = 'sine') => { const fm = (frequencyparam, harmonicityRatio, modulationIndex, wave = 'sine') => {
const carrfreq = frequencyparam.value; const carrfreq = frequencyparam.value;
@@ -378,6 +434,11 @@ export function applyFM(param, value, begin) {
modulator.connect(envGain); modulator.connect(envGain);
envGain.connect(param); envGain.connect(param);
} }
fmmod.osc.onended = () => {
envGain.disconnect();
modulator.disconnect();
fmmod.osc.disconnect();
};
} }
return { stop }; return { stop };
} }
@@ -479,7 +540,7 @@ export const getDistortion = (distort, postgain, algorithm) => {
}; };
export const getFrequencyFromValue = (value, defaultNote = 36) => { export const getFrequencyFromValue = (value, defaultNote = 36) => {
let { note, freq } = value; let { note, freq, octave = 0 } = value;
note = note || defaultNote; note = note || defaultNote;
if (typeof note === 'string') { if (typeof note === 'string') {
note = noteToMidi(note); // e.g. c3 => 48 note = noteToMidi(note); // e.g. c3 => 48
@@ -488,7 +549,7 @@ export const getFrequencyFromValue = (value, defaultNote = 36) => {
if (!freq && typeof note === 'number') { if (!freq && typeof note === 'number') {
freq = midiToFreq(note); // + 48); freq = midiToFreq(note); // + 48);
} }
freq *= Math.pow(2, octave);
return Number(freq); return Number(freq);
}; };
+2 -1
View File
@@ -65,8 +65,9 @@ export function getNoiseOscillator(type = 'white', t, density = 0.02) {
export function getNoiseMix(inputNode, wet, t) { export function getNoiseMix(inputNode, wet, t) {
const noiseOscillator = getNoiseOscillator('pink', t); const noiseOscillator = getNoiseOscillator('pink', t);
const noiseMix = drywet(inputNode, noiseOscillator.node, wet); const noiseMix = drywet(inputNode, noiseOscillator.node, wet);
noiseOscillator.node.onended = () => noiseMix.onended();
return { return {
node: noiseMix, node: noiseMix.node,
stop: (time) => noiseOscillator?.stop(time), stop: (time) => noiseOscillator?.stop(time),
}; };
} }
+3
View File
@@ -37,5 +37,8 @@
}, },
"dependencies": { "dependencies": {
"nanostores": "^0.11.3" "nanostores": "^0.11.3"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+2
View File
@@ -104,6 +104,8 @@ var applyGradualLowpass = function (input, lpFreqStart, lpFreqEnd, lpFreqEndAt,
player.start(); player.start();
context.oncomplete = function (event) { context.oncomplete = function (event) {
callback(event.renderedBuffer); callback(event.renderedBuffer);
filter.disconnect();
player.disconnect();
}; };
context.startRendering(); context.startRendering();
+2 -2
View File
@@ -1,4 +1,4 @@
import { getCommonSampleInfo } from './util.mjs'; import { getBaseURL, getCommonSampleInfo } from './util.mjs';
import { registerSound, registerWaveTable } from './index.mjs'; import { registerSound, registerWaveTable } from './index.mjs';
import { getAudioContext } from './audioContext.mjs'; import { getAudioContext } from './audioContext.mjs';
import { getADSRValues, getParamADSR, getPitchEnvelope, getVibratoOscillator } from './helpers.mjs'; import { getADSRValues, getParamADSR, getPitchEnvelope, getVibratoOscillator } from './helpers.mjs';
@@ -211,7 +211,7 @@ export async function fetchSampleMap(url) {
// not a browser // not a browser
return; return;
} }
const base = url.split('/').slice(0, -1).join('/'); const base = getBaseURL(url);
if (typeof fetch === 'undefined') { if (typeof fetch === 'undefined') {
// skip fetch when in node / testing // skip fetch when in node / testing
return; return;
+80 -74
View File
@@ -7,7 +7,7 @@ This program is free software: you can redistribute it and/or modify it under th
import './feedbackdelay.mjs'; import './feedbackdelay.mjs';
import './reverb.mjs'; import './reverb.mjs';
import './vowel.mjs'; import './vowel.mjs';
import { nanFallback, _mod, cycleToSeconds } from './util.mjs'; import { nanFallback, _mod, cycleToSeconds, pickAndRename } from './util.mjs';
import workletsUrl from './worklets.mjs?audioworklet'; import workletsUrl from './worklets.mjs?audioworklet';
import { createFilter, gainNode, getCompressor, getDistortion, getLfo, getWorklet, effectSend } from './helpers.mjs'; import { createFilter, gainNode, getCompressor, getDistortion, getLfo, getWorklet, effectSend } from './helpers.mjs';
import { map } from 'nanostores'; import { map } from 'nanostores';
@@ -146,11 +146,6 @@ let defaultDefaultValues = {
gain: 0.8, gain: 0.8,
postgain: 1, postgain: 1,
density: '.03', density: '.03',
ftype: '12db',
fanchor: 0,
resonance: 1,
hresonance: 1,
bandq: 1,
channels: [1, 2], channels: [1, 2],
phaserdepth: 0.75, phaserdepth: 0.75,
shapevol: 1, shapevol: 1,
@@ -268,8 +263,8 @@ let audioReady;
export async function initAudioOnFirstClick(options) { export async function initAudioOnFirstClick(options) {
if (!audioReady) { if (!audioReady) {
audioReady = new Promise((resolve) => { audioReady = new Promise((resolve) => {
document.addEventListener('click', async function listener() { document.addEventListener('mousedown', async function listener() {
document.removeEventListener('click', listener); document.removeEventListener('mousedown', listener);
await initAudio(options); await initAudio(options);
resolve(); resolve();
}); });
@@ -415,32 +410,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
djf, djf,
// filters // filters
fanchor = getDefaultValue('fanchor'), fanchor = getDefaultValue('fanchor'),
drive = 0.69,
release = 0, release = 0,
// low pass
cutoff,
lpenv,
lpattack,
lpdecay,
lpsustain,
lprelease,
resonance = getDefaultValue('resonance'),
// high pass
hpenv,
hcutoff,
hpattack,
hpdecay,
hpsustain,
hprelease,
hresonance = getDefaultValue('hresonance'),
// band pass
bpenv,
bandf,
bpattack,
bpdecay,
bpsustain,
bprelease,
bandq = getDefaultValue('bandq'),
//phaser //phaser
phaserrate: phaser, phaserrate: phaser,
@@ -511,7 +481,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
// oldest audio nodes will be destroyed if maximum polyphony is exceeded // oldest audio nodes will be destroyed if maximum polyphony is exceeded
for (let i = 0; i <= activeSoundSources.size - maxPolyphony; i++) { for (let i = 0; i <= activeSoundSources.size - maxPolyphony; i++) {
const ch = activeSoundSources.entries().next(); const ch = activeSoundSources.entries().next();
const source = ch.value[1]; const source = ch.value[1].deref();
const chainID = ch.value[0]; const chainID = ch.value[0];
const endTime = t + 0.25; const endTime = t + 0.25;
source?.node?.gain?.linearRampToValueAtTime(0, endTime); source?.node?.gain?.linearRampToValueAtTime(0, endTime);
@@ -543,7 +513,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
if (soundHandle) { if (soundHandle) {
sourceNode = soundHandle.node; sourceNode = soundHandle.node;
activeSoundSources.set(chainID, soundHandle); activeSoundSources.set(chainID, new WeakRef(soundHandle)); // allow GC
} }
} else { } else {
throw new Error(`sound ${s} not found! Is it loaded?`); throw new Error(`sound ${s} not found! Is it loaded?`);
@@ -567,55 +537,88 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
// filter // filter
const ftype = getFilterType(value.ftype); const ftype = getFilterType(value.ftype);
if (cutoff !== undefined) {
let lp = () => if (value.cutoff !== undefined) {
createFilter( const lpMap = {
ac, frequency: 'cutoff',
'lowpass', q: 'resonance',
cutoff, attack: 'lpattack',
resonance, decay: 'lpdecay',
lpattack, sustain: 'lpsustain',
lpdecay, release: 'lprelease',
lpsustain, env: 'lpenv',
lprelease, anchor: 'fanchor',
lpenv, model: 'ftype',
t, drive: 'drive',
end, rate: 'lprate',
fanchor, sync: 'lpsync',
ftype, depth: 'lpdepth',
drive, depthfrequency: 'lpdepthfrequency',
); shape: 'lpshape',
dcoffset: 'lpdc',
skew: 'lpskew',
};
const lpParams = pickAndRename(value, lpMap);
lpParams.type = 'lowpass';
let lp = () => createFilter(ac, t, end, lpParams, cps, cycle);
chain.push(lp()); chain.push(lp());
if (ftype === '24db') { if (ftype === '24db') {
chain.push(lp()); chain.push(lp());
} }
} }
if (hcutoff !== undefined) { if (value.hcutoff !== undefined) {
let hp = () => const hpMap = {
createFilter( frequency: 'hcutoff',
ac, q: 'hresonance',
'highpass', attack: 'hpattack',
hcutoff, decay: 'hpdecay',
hresonance, sustain: 'hpsustain',
hpattack, release: 'hprelease',
hpdecay, env: 'hpenv',
hpsustain, anchor: 'fanchor',
hprelease, model: 'ftype',
hpenv, drive: 'drive',
t, rate: 'hprate',
end, sync: 'hpsync',
fanchor, depth: 'hpdepth',
); depthfrequency: 'hpdepthfrequency',
shape: 'hpshape',
dcoffset: 'hpdc',
skew: 'hpskew',
};
const hpParams = pickAndRename(value, hpMap);
hpParams.type = 'highpass';
let hp = () => createFilter(ac, t, end, hpParams, cps, cycle);
chain.push(hp()); chain.push(hp());
if (ftype === '24db') { if (ftype === '24db') {
chain.push(hp()); chain.push(hp());
} }
} }
if (bandf !== undefined) { if (value.bandf !== undefined) {
let bp = () => const bpMap = {
createFilter(ac, 'bandpass', bandf, bandq, bpattack, bpdecay, bpsustain, bprelease, bpenv, t, end, fanchor); frequency: 'bandf',
q: 'bandq',
attack: 'bpattack',
decay: 'bpdecay',
sustain: 'bpsustain',
release: 'bprelease',
env: 'bpenv',
anchor: 'fanchor',
model: 'ftype',
drive: 'drive',
rate: 'bprate',
sync: 'bpsync',
depth: 'bpdepth',
depthfrequency: 'bpdepthfrequency',
shape: 'bpshape',
dcoffset: 'bpdc',
skew: 'bpskew',
};
const bpParams = pickAndRename(value, bpMap);
bpParams.type = 'bandpass';
let bp = () => createFilter(ac, t, end, bpParams, cps, cycle);
chain.push(bp()); chain.push(bp());
if (ftype === '24db') { if (ftype === '24db') {
chain.push(bp()); chain.push(bp());
@@ -665,6 +668,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
curve: 1.5, curve: 1.5,
}); });
lfo.connect(amGain.gain); lfo.connect(amGain.gain);
audioNodes.push(lfo);
chain.push(amGain); chain.push(amGain);
} }
@@ -692,7 +696,8 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
// delay // delay
if (delay > 0 && delaytime > 0 && delayfeedback > 0) { if (delay > 0 && delaytime > 0 && delayfeedback > 0) {
orbitBus.getDelay(delaytime, delayfeedback, t); orbitBus.getDelay(delaytime, delayfeedback, t);
orbitBus.sendDelay(post, delay); const send = orbitBus.sendDelay(post, delay);
audioNodes.push(send);
} }
// reverb // reverb
if (room > 0) { if (room > 0) {
@@ -708,7 +713,8 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
roomIR = await loadBuffer(url, ac, ir, 0); roomIR = await loadBuffer(url, ac, ir, 0);
} }
orbitBus.getReverb(roomsize, roomfade, roomlp, roomdim, roomIR, irspeed, irbegin); orbitBus.getReverb(roomsize, roomfade, roomlp, roomdim, roomIR, irspeed, irbegin);
orbitBus.sendReverb(post, room); const send = orbitBus.sendReverb(post, room);
audioNodes.push(send);
} }
if (djf != null) { if (djf != null) {
+2 -2
View File
@@ -78,11 +78,11 @@ export class Orbit {
return this.reverbNode; return this.reverbNode;
} }
sendReverb(node, amount) { sendReverb(node, amount) {
effectSend(node, this.reverbNode, amount); return effectSend(node, this.reverbNode, amount);
} }
sendDelay(node, amount) { sendDelay(node, amount) {
effectSend(node, this.delayNode, amount); return effectSend(node, this.delayNode, amount);
} }
duck(t, onsettime = 0, attacktime = 0.1, depth = 1) { duck(t, onsettime = 0, attacktime = 0.1, depth = 1) {
+51 -25
View File
@@ -15,9 +15,10 @@ import {
noises, noises,
webAudioTimeout, webAudioTimeout,
} from './helpers.mjs'; } from './helpers.mjs';
import { logger } from './logger.mjs';
import { getNoiseMix, getNoiseOscillator } from './noise.mjs'; import { getNoiseMix, getNoiseOscillator } from './noise.mjs';
const waveforms = ['triangle', 'square', 'sawtooth', 'sine']; const waveforms = ['triangle', 'square', 'sawtooth', 'sine', 'user'];
const waveformAliases = [ const waveformAliases = [
['tri', 'triangle'], ['tri', 'triangle'],
['sqr', 'square'], ['sqr', 'square'],
@@ -47,19 +48,17 @@ export function registerSynthSounds() {
[0.001, 0.05, 0.6, 0.01], [0.001, 0.05, 0.6, 0.01],
); );
let sound = getOscillator(s, t, value);
let { node: o, stop, triggerRelease } = sound;
// turn down // turn down
const g = gainNode(0.3); const g = gainNode(0.3);
const { duration } = value; let sound = getOscillator(s, t, value, () => {
o.onended = () => {
o.disconnect();
g.disconnect(); g.disconnect();
onended(); onended();
}; });
let { node: o, stop, triggerRelease } = sound;
const { duration } = value;
const envGain = gainNode(1); const envGain = gainNode(1);
let node = o.connect(g).connect(envGain); let node = o.connect(g).connect(envGain);
@@ -414,9 +413,13 @@ export function registerSynthSounds() {
waveformAliases.forEach(([alias, actual]) => soundMap.set({ ...soundMap.get(), [alias]: soundMap.get()[actual] })); waveformAliases.forEach(([alias, actual]) => soundMap.set({ ...soundMap.get(), [alias]: soundMap.get()[actual] }));
} }
export function waveformN(partials, type) { const PI2 = 2 * Math.PI;
const real = new Float32Array(partials + 1); export function waveformN(partials, phases, type) {
const imag = new Float32Array(partials + 1); const isList = typeof partials === 'object';
partials = isList ? partials : new Float32Array(partials).fill(1);
const len = partials.length;
const real = new Float32Array(len + 1);
const imag = new Float32Array(len + 1);
const ac = getAudioContext(); const ac = getAudioContext();
const osc = ac.createOscillator(); const osc = ac.createOscillator();
@@ -424,20 +427,29 @@ export function waveformN(partials, type) {
sawtooth: (n) => [0, -1 / n], sawtooth: (n) => [0, -1 / n],
square: (n) => [0, n % 2 === 0 ? 0 : 1 / n], square: (n) => [0, n % 2 === 0 ? 0 : 1 / n],
triangle: (n) => [n % 2 === 0 ? 0 : 1 / (n * n), 0], triangle: (n) => [n % 2 === 0 ? 0 : 1 / (n * n), 0],
user: (_n) => [0, 1],
}; };
if (!terms[type]) { if (!terms[type]) {
throw new Error(`unknown wave type ${type}`); throw new Error(`unknown wave type ${type}`);
} }
real[0] = 0; // dc offset for (let n = 0; n < len; n++) {
imag[0] = 0; const mag = partials[n];
let n = 1; const [r, i] = terms[type](n + 1); // we skip n === 0 as this is dc offset
while (n <= partials) { const phase = phases?.[n] ?? 0;
const [r, i] = terms[type](n); // Scale by `partials`
real[n] = r; let R = r * mag;
imag[n] = i; let I = i * mag;
n++; // Apply rotation by the phase
if (phase !== 0) {
const c = Math.cos(PI2 * phase);
const s = Math.sin(PI2 * phase);
R = c * R - s * I;
I = s * R + c * I;
}
real[n + 1] = R;
imag[n + 1] = I;
} }
const wave = ac.createPeriodicWave(real, imag); const wave = ac.createPeriodicWave(real, imag);
@@ -446,21 +458,28 @@ export function waveformN(partials, type) {
} }
// expects one of waveforms as s // expects one of waveforms as s
export function getOscillator(s, t, value) { export function getOscillator(s, t, value, onended) {
let { n: partials, duration, noise = 0 } = value; const { duration, noise = 0 } = value;
const partials = value.partials ?? value.n;
let o; let o;
if (s === 'user' && !partials) {
logger(
`[superdough] Synth 'user' was selected, but partials not specified. Defaulting to triangle. Use pat.partials to setup custom waveform`,
);
s = 'triangle';
}
s = s === 'user' && !partials ? 'triangle' : s;
// If no partials are given, use stock waveforms // If no partials are given, use stock waveforms
if (!partials || s === 'sine') { if (!partials || partials?.length === 0 || s === 'sine') {
o = getAudioContext().createOscillator(); o = getAudioContext().createOscillator();
o.type = s || 'triangle'; o.type = s || 'triangle';
} }
// generate custom waveform if partials are given // generate custom waveform if partials are given
else { else {
o = waveformN(partials, s); o = waveformN(partials, value.phases, s);
} }
// set frequency // set frequency
o.frequency.value = getFrequencyFromValue(value); o.frequency.value = getFrequencyFromValue(value);
o.start(t);
let vibratoOscillator = getVibratoOscillator(o.detune, value, t); let vibratoOscillator = getVibratoOscillator(o.detune, value, t);
@@ -473,6 +492,13 @@ export function getOscillator(s, t, value) {
noiseMix = getNoiseMix(o, noise, t); noiseMix = getNoiseMix(o, noise, t);
} }
o.onended = () => {
noiseMix || o.disconnect();
noiseMix?.node.disconnect();
onended();
};
o.start(t);
return { return {
node: noiseMix?.node || o, node: noiseMix?.node || o,
stop: (time) => { stop: (time) => {
+20 -1
View File
@@ -16,13 +16,17 @@ export const tokenizeNote = (note) => {
const chromas = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; const chromas = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
const accs = { '#': 1, b: -1, s: 1, f: -1 }; const accs = { '#': 1, b: -1, s: 1, f: -1 };
export const getAccidentalsOffset = (accidentals) => {
return accidentals?.split('').reduce((o, char) => o + accs[char], 0) || 0;
};
export const noteToMidi = (note, defaultOctave = 3) => { export const noteToMidi = (note, defaultOctave = 3) => {
const [pc, acc, oct = defaultOctave] = tokenizeNote(note); const [pc, acc, oct = defaultOctave] = tokenizeNote(note);
if (!pc) { if (!pc) {
throw new Error('not a note: "' + note + '"'); throw new Error('not a note: "' + note + '"');
} }
const chroma = chromas[pc.toLowerCase()]; const chroma = chromas[pc.toLowerCase()];
const offset = acc?.split('').reduce((o, char) => o + accs[char], 0) || 0; const offset = getAccidentalsOffset(acc);
return (Number(oct) + 1) * 12 + chroma + offset; return (Number(oct) + 1) * 12 + chroma + offset;
}; };
export const midiToFreq = (n) => { export const midiToFreq = (n) => {
@@ -105,3 +109,18 @@ export function getCommonSampleInfo(hapValue, bank) {
const label = `${s}:${index}`; const label = `${s}:${index}`;
return { transpose, url, index, midi, label }; return { transpose, url, index, midi, label };
} }
/** Selects entries from `source` and renames them via `map` */
export const pickAndRename = (source, map) => {
return Object.fromEntries(Object.entries(map).map(([newKey, oldKey]) => [newKey, source[oldKey]]));
};
export const getBaseURL = (url) => {
try {
// For real URLs
return new URL('.', new URL(url)).href.replace(/\/$/, ''); // removes trailing slash
} catch {
// For pseudo URLS
return url.split('/').slice(0, -1).join('/');
}
};
+17 -5
View File
@@ -45,7 +45,8 @@ if (typeof GainNode !== 'undefined') {
throw new Error('vowel: unknown vowel ' + letter); throw new Error('vowel: unknown vowel ' + letter);
} }
const { gains, qs, freqs } = vowelFormant[letter]; const { gains, qs, freqs } = vowelFormant[letter];
const makeupGain = ac.createGain(); this.makeupGain = ac.createGain();
this.audioNodes = [];
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
const gain = ac.createGain(); const gain = ac.createGain();
gain.gain.value = gains[i]; gain.gain.value = gains[i];
@@ -53,14 +54,25 @@ if (typeof GainNode !== 'undefined') {
filter.type = 'bandpass'; filter.type = 'bandpass';
filter.Q.value = qs[i]; filter.Q.value = qs[i];
filter.frequency.value = freqs[i]; filter.frequency.value = freqs[i];
this.connect(filter); super.connect(filter);
filter.connect(gain); filter.connect(gain);
gain.connect(makeupGain); this.audioNodes.push(filter);
gain.connect(this.makeupGain);
this.audioNodes.push(gain);
} }
makeupGain.gain.value = 8; // how much makeup gain to add? this.makeupGain.gain.value = 8; // how much makeup gain to add?
this.connect = (target) => makeupGain.connect(target);
return this; return this;
} }
connect(target) {
this.makeupGain.connect(target);
}
disconnect() {
this.makeupGain.disconnect();
this.audioNodes.forEach((n) => n.disconnect());
super.disconnect();
this.makeupGain = null;
this.audioNodes = null;
}
} }
AudioContext.prototype.createVowelFilter = function (letter) { AudioContext.prototype.createVowelFilter = function (letter) {
+3 -2
View File
@@ -1,5 +1,5 @@
import { getAudioContext, registerSound } from './index.mjs'; import { getAudioContext, registerSound } from './index.mjs';
import { getCommonSampleInfo } from './util.mjs'; import { getBaseURL, getCommonSampleInfo } from './util.mjs';
import { import {
applyFM, applyFM,
applyParameterModulators, applyParameterModulators,
@@ -190,6 +190,7 @@ export const tables = async (url, frameLen, json, options = {}) => {
if (url.startsWith('local:')) { if (url.startsWith('local:')) {
url = `http://localhost:5432`; url = `http://localhost:5432`;
} }
const base = getBaseURL(url);
if (typeof fetch !== 'function') { if (typeof fetch !== 'function') {
// not a browser // not a browser
return; return;
@@ -200,7 +201,7 @@ export const tables = async (url, frameLen, json, options = {}) => {
} }
return fetch(url) return fetch(url)
.then((res) => res.json()) .then((res) => res.json())
.then((json) => _processTables(json, url, frameLen, options)) .then((json) => _processTables(json, base, frameLen, options))
.catch((error) => { .catch((error) => {
console.error(error); console.error(error);
throw new Error(`error loading "${url}"`); throw new Error(`error loading "${url}"`);
+237 -173
View File
@@ -6,51 +6,57 @@ import OLAProcessor from './ola-processor';
import FFT from './fft.js'; import FFT from './fft.js';
import { getDistortionAlgorithm } from './helpers.mjs'; import { getDistortionAlgorithm } from './helpers.mjs';
const blockSize = 128;
const PI = Math.PI;
const TWO_PI = 2 * PI;
const INVSR = 1 / sampleRate;
const clamp = (num, min, max) => Math.min(Math.max(num, min), max); const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
const mod = (n, m) => ((n % m) + m) % m; const mod = (n, m) => ((n % m) + m) % m;
const lerp = (a, b, n) => n * (b - a) + a; const lerp = (a, b, n) => n * (b - a) + a;
const pv = (arr, n) => arr[n] ?? arr[0]; const pv = (arr, n) => arr[n] ?? arr[0];
const frac = (x) => x - Math.floor(x); const frac = (x) => x - Math.floor(x);
const ffloor = (x) => x | 0; // fast floor for non-negative
const getUnisonDetune = (unison, detune, voiceIndex) => { // Fast integer ops for non-negative values
if (unison < 2) { const ffloor = (x) => x | 0;
return 0; const fround = (x) => ffloor(x + 0.5);
} const fceil = (x) => ffloor(x + 1);
return lerp(-detune * 0.5, detune * 0.5, voiceIndex / (unison - 1)); const ffrac = (x) => x - ffloor(x);
const fast_tanh = (x) => {
const x2 = x ** 2;
return (x * (27.0 + x2)) / (27.0 + 9.0 * x2);
}; };
// Optimized per-voice detuner which precomputes constants
const getDetuner = (unison, detune) => {
if (unison < 2) {
return (_voiceIdx) => 0;
}
const scale = detune / (unison - 1);
const center = detune * 0.5;
return (voiceIdx) => voiceIdx * scale - center;
};
const applySemitoneDetuneToFrequency = (frequency, detune) => { const applySemitoneDetuneToFrequency = (frequency, detune) => {
return frequency * Math.pow(2, detune / 12); return frequency * Math.pow(2, detune / 12);
}; };
// Restrict phase to the range [0, maxPhase) via wrapping
function wrapPhase(phase, maxPhase = 1) {
if (phase >= maxPhase) {
phase -= maxPhase;
} else if (phase < 0) {
phase += maxPhase;
}
return phase;
}
const blockSize = 128;
// Smooth waveshape near discontinuities to remove frequencies above Nyquist and prevent aliasing // Smooth waveshape near discontinuities to remove frequencies above Nyquist and prevent aliasing
// referenced from https://www.kvraudio.com/forum/viewtopic.php?t=375517 // referenced from https://www.kvraudio.com/forum/viewtopic.php?t=375517
function polyBlep(phase, dt) { function polyBlep(phase, dt) {
dt = Math.min(dt, 1 - dt); dt = Math.min(dt, 1 - dt);
const invdt = 1 / dt;
// Start of cycle // Start of cycle
if (phase < dt) { if (phase < dt) {
phase /= dt; phase *= invdt;
// 2 * (phase - phase^2/2 - 0.5) return 2 * phase - phase ** 2 - 1;
return phase + phase - phase * phase - 1;
} }
// End of cycle // End of cycle
else if (phase > 1 - dt) { else if (phase > 1 - dt) {
phase = (phase - 1) / dt; phase = (phase - 1) * invdt;
// 2 * (phase^2/2 + phase + 0.5) return phase ** 2 + 2 * phase + 1;
return phase * phase + phase + phase + 1;
} }
// 0 otherwise // 0 otherwise
else { else {
return 0; return 0;
@@ -66,7 +72,7 @@ const waveshapes = {
return phase / skew; return phase / skew;
}, },
sine(phase) { sine(phase) {
return Math.sin(Math.PI * 2 * phase) * 0.5 + 0.5; return Math.sin(TWO_PI * phase) * 0.5 + 0.5;
}, },
ramp(phase) { ramp(phase) {
return phase; return phase;
@@ -100,12 +106,6 @@ const waveshapes = {
return v - polyBlep(phase, dt); return v - polyBlep(phase, dt);
}, },
}; };
function getParamValue(block, param) {
if (param.length > 1) {
return param[block];
}
return param[0];
}
const waveShapeNames = Object.keys(waveshapes); const waveShapeNames = Object.keys(waveshapes);
class LFOProcessor extends AudioWorkletProcessor { class LFOProcessor extends AudioWorkletProcessor {
@@ -165,9 +165,9 @@ class LFOProcessor extends AudioWorkletProcessor {
const blockSize = output[0].length ?? 0; const blockSize = output[0].length ?? 0;
if (this.phase == null) { if (this.phase == null) {
this.phase = mod(time * frequency + phaseoffset, 1); this.phase = ffrac(time * frequency + phaseoffset);
} }
const dt = frequency / sampleRate; const dt = frequency * INVSR;
for (let n = 0; n < blockSize; n++) { for (let n = 0; n < blockSize; n++) {
for (let i = 0; i < output.length; i++) { for (let i = 0; i < output.length; i++) {
let modval = (waveshapes[shape](this.phase, skew) + dcoffset) * depth; let modval = (waveshapes[shape](this.phase, skew) + dcoffset) * depth;
@@ -293,8 +293,8 @@ class TwoPoleFilter {
// Out of bound values can produce NaNs // Out of bound values can produce NaNs
resonance = clamp(resonance, 0, 1); resonance = clamp(resonance, 0, 1);
cutoff = clamp(cutoff, 0, sampleRate / 2 - 1); cutoff = clamp(cutoff, 0, sampleRate / 2 - 1);
const c = clamp(2 * Math.sin(cutoff * (_PI / sampleRate)), 0, 1.14); const c = clamp(2 * Math.sin(cutoff * PI * INVSR), 0, 1.14);
const r = Math.pow(0.5, (resonance + 0.125) / 0.125); const r = Math.pow(0.5, 8 * resonance + 1);
const mrc = 1 - r * c; const mrc = 1 - r * c;
this.s0 = mrc * this.s0 - c * this.s1 + c * s; // bpf this.s0 = mrc * this.s0 - c * this.s1 + c * s; // bpf
this.s1 = mrc * this.s1 + c * this.s0; // lpf this.s1 = mrc * this.s1 + c * this.s0; // lpf
@@ -353,11 +353,6 @@ class DJFProcessor extends AudioWorkletProcessor {
} }
registerProcessor('djf-processor', DJFProcessor); registerProcessor('djf-processor', DJFProcessor);
function fast_tanh(x) {
const x2 = x * x;
return (x * (27.0 + x2)) / (27.0 + 9.0 * x2);
}
const _PI = 3.14159265359;
//adapted from https://github.com/TheBouteillacBear/webaudioworklet-wasm?tab=MIT-1-ov-file //adapted from https://github.com/TheBouteillacBear/webaudioworklet-wasm?tab=MIT-1-ov-file
class LadderProcessor extends AudioWorkletProcessor { class LadderProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() { static get parameterDescriptors() {
@@ -395,7 +390,7 @@ class LadderProcessor extends AudioWorkletProcessor {
const drive = clamp(Math.exp(parameters.drive[0]), 0.1, 2000); const drive = clamp(Math.exp(parameters.drive[0]), 0.1, 2000);
let cutoff = parameters.frequency[0]; let cutoff = parameters.frequency[0];
cutoff = (cutoff * 2 * _PI) / sampleRate; cutoff = cutoff * TWO_PI * INVSR;
cutoff = cutoff > 1 ? 1 : cutoff; cutoff = cutoff > 1 ? 1 : cutoff;
const k = Math.min(8, resonance * 0.13); const k = Math.min(8, resonance * 0.13);
@@ -508,6 +503,7 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
name: 'voices', name: 'voices',
defaultValue: 5, defaultValue: 5,
min: 1, min: 1,
automationRate: 'k-rate',
}, },
]; ];
} }
@@ -519,40 +515,37 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
// this.port.postMessage({ type: 'onended' }); // this.port.postMessage({ type: 'onended' });
return false; return false;
} }
const output = outputs[0]; const output = outputs[0];
const voices = params.voices[0]; // k-rate
for (let i = 0; i < output[0].length; i++) { for (let i = 0; i < output[0].length; i++) {
const detune = pv(params.detune, i); const detune = pv(params.detune, i);
const voices = pv(params.voices, i);
const freqspread = pv(params.freqspread, i); const freqspread = pv(params.freqspread, i);
const panspread = pv(params.panspread, i) * 0.5 + 0.5; const panspread = pv(params.panspread, i) * 0.5 + 0.5;
const gain1 = Math.sqrt(1 - panspread); let gainL = Math.sqrt(1 - panspread);
const gain2 = Math.sqrt(panspread); let gainR = Math.sqrt(panspread);
let freq = pv(params.frequency, i); let freq = pv(params.frequency, i);
// Main detuning // Main detuning
freq = applySemitoneDetuneToFrequency(freq, detune / 100); freq = applySemitoneDetuneToFrequency(freq, detune / 100);
const detuner = getDetuner(voices, freqspread);
for (let n = 0; n < voices; n++) { for (let n = 0; n < voices; n++) {
const isOdd = (n & 1) == 1;
let gainL = gain1;
let gainR = gain2;
// invert right and left gain
if (isOdd) {
gainL = gain2;
gainR = gain1;
}
// Individual voice detuning // Individual voice detuning
const freqVoice = applySemitoneDetuneToFrequency(freq, getUnisonDetune(voices, freqspread, n)); const freqVoice = applySemitoneDetuneToFrequency(freq, detuner(n));
// We must wrap this here because it is passed into sawblep below which // We must wrap this here because it is passed into sawblep below which
// has domain [0, 1] // has domain [0, 1]
const dt = mod(freqVoice / sampleRate, 1); const dt = frac(freqVoice * INVSR);
this.phase[n] = this.phase[n] ?? Math.random(); this.phase[n] = this.phase[n] ?? Math.random();
const v = waveshapes.sawblep(this.phase[n], dt); const v = waveshapes.sawblep(this.phase[n], dt);
output[0][i] = output[0][i] + v * gainL; output[0][i] += v * gainL;
output[1][i] = output[1][i] + v * gainR; output[1][i] += v * gainR;
this.phase[n] = wrapPhase(this.phase[n] + dt); let pn = this.phase[n] + dt;
if (pn >= 1.0) pn -= 1.0;
this.phase[n] = pn;
// invert right and left gain
const tmp = gainL;
gainL = gainR;
gainR = tmp;
} }
} }
return true; return true;
@@ -564,12 +557,16 @@ registerProcessor('supersaw-oscillator', SuperSawOscillatorProcessor);
// Phase Vocoder sourced from https://github.com/olvb/phaze/tree/master?tab=readme-ov-file // Phase Vocoder sourced from https://github.com/olvb/phaze/tree/master?tab=readme-ov-file
const BUFFERED_BLOCK_SIZE = 2048; const BUFFERED_BLOCK_SIZE = 2048;
const hannCache = new Map();
function genHannWindow(length) { function genHannWindow(length) {
let win = new Float32Array(length); if (!hannCache.has(length)) {
for (var i = 0; i < length; i++) { const win = new Float32Array(length);
win[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / length)); for (let i = 0; i < length; i++) {
win[i] = 0.5 * (1 - Math.cos((TWO_PI * i) / length));
} }
return win; hannCache.set(length, win);
}
return hannCache.get(length);
} }
class PhaseVocoderProcessor extends OLAProcessor { class PhaseVocoderProcessor extends OLAProcessor {
@@ -587,11 +584,10 @@ class PhaseVocoderProcessor extends OLAProcessor {
blockSize: BUFFERED_BLOCK_SIZE, blockSize: BUFFERED_BLOCK_SIZE,
}; };
super(options); super(options);
this.fftSize = this.blockSize;
this.timeCursor = 0; this.timeCursor = 0;
this.fftSize = this.blockSize;
this.hannWindow = genHannWindow(this.blockSize); this.invfftSize = 1 / this.fftSize;
this.hannWindow = genHannWindow(this.fftSize);
// prepare FFT and pre-allocate buffers // prepare FFT and pre-allocate buffers
this.fft = new FFT(this.fftSize); this.fft = new FFT(this.fftSize);
this.freqComplexBuffer = this.fft.createComplexArray(); this.freqComplexBuffer = this.fft.createComplexArray();
@@ -604,52 +600,43 @@ class PhaseVocoderProcessor extends OLAProcessor {
processOLA(inputs, outputs, parameters) { processOLA(inputs, outputs, parameters) {
// no automation, take last value // no automation, take last value
let pitchFactor = parameters.pitchFactor[parameters.pitchFactor.length - 1]; let pitchFactor = parameters.pitchFactor[parameters.pitchFactor.length - 1];
if (pitchFactor < 0) { if (pitchFactor < 0) {
pitchFactor = pitchFactor * 0.25; pitchFactor = pitchFactor * 0.25;
} }
pitchFactor = Math.max(0, pitchFactor + 1); pitchFactor = Math.max(0, pitchFactor + 1);
for (let i = 0; i < this.nbInputs; i++) {
for (var i = 0; i < this.nbInputs; i++) { for (let j = 0; j < inputs[i].length; j++) {
for (var j = 0; j < inputs[i].length; j++) { const input = inputs[i][j];
// big assumption here: output is symetric to input const output = outputs[i][j];
var input = inputs[i][j];
var output = outputs[i][j];
this.applyHannWindow(input); this.applyHannWindow(input);
this.fft.realTransform(this.freqComplexBuffer, input); this.fft.realTransform(this.freqComplexBuffer, input);
this.computeMagnitudes(); this.computeMagnitudes();
this.findPeaks(); this.findPeaks();
this.shiftPeaks(pitchFactor); this.shiftPeaks(pitchFactor);
this.fft.completeSpectrum(this.freqComplexBufferShifted); this.fft.completeSpectrum(this.freqComplexBufferShifted);
this.fft.inverseTransform(this.timeComplexBuffer, this.freqComplexBufferShifted); this.fft.inverseTransform(this.timeComplexBuffer, this.freqComplexBufferShifted);
this.fft.fromComplexArray(this.timeComplexBuffer, output); this.fft.fromComplexArray(this.timeComplexBuffer, output);
this.applyHannWindow(output); this.applyHannWindow(output);
} }
} }
this.timeCursor += this.hopSize; this.timeCursor += this.hopSize;
} }
/** Apply Hann window in-place */ /** Apply Hann window in-place */
applyHannWindow(input) { applyHannWindow(input) {
for (var i = 0; i < this.blockSize; i++) { for (let i = 0; i < this.blockSize; i++) {
input[i] = input[i] * this.hannWindow[i] * 1.62; input[i] *= this.hannWindow[i] * 1.62;
} }
} }
/** Compute squared magnitudes for peak finding **/ /** Compute squared magnitudes for peak finding **/
computeMagnitudes() { computeMagnitudes() {
var i = 0, let i = 0,
j = 0; j = 0;
while (i < this.magnitudes.length) { while (i < this.magnitudes.length) {
let real = this.freqComplexBuffer[j]; const real = this.freqComplexBuffer[j];
let imag = this.freqComplexBuffer[j + 1]; const imag = this.freqComplexBuffer[j + 1];
// no need to sqrt for peak finding // no need to sqrt for peak finding
this.magnitudes[i] = real ** 2 + imag ** 2; this.magnitudes[i] = real ** 2 + imag ** 2;
i += 1; i += 1;
@@ -660,12 +647,10 @@ class PhaseVocoderProcessor extends OLAProcessor {
/** Find peaks in spectrum magnitudes **/ /** Find peaks in spectrum magnitudes **/
findPeaks() { findPeaks() {
this.nbPeaks = 0; this.nbPeaks = 0;
var i = 2; let i = 2;
let end = this.magnitudes.length - 2; const end = this.magnitudes.length - 2;
while (i < end) { while (i < end) {
let mag = this.magnitudes[i]; const mag = this.magnitudes[i];
if (this.magnitudes[i - 1] >= mag || this.magnitudes[i - 2] >= mag) { if (this.magnitudes[i - 1] >= mag || this.magnitudes[i - 2] >= mag) {
i++; i++;
continue; continue;
@@ -674,7 +659,6 @@ class PhaseVocoderProcessor extends OLAProcessor {
i++; i++;
continue; continue;
} }
this.peakIndexes[this.nbPeaks] = i; this.peakIndexes[this.nbPeaks] = i;
this.nbPeaks++; this.nbPeaks++;
i += 2; i += 2;
@@ -685,53 +669,44 @@ class PhaseVocoderProcessor extends OLAProcessor {
shiftPeaks(pitchFactor) { shiftPeaks(pitchFactor) {
// zero-fill new spectrum // zero-fill new spectrum
this.freqComplexBufferShifted.fill(0); this.freqComplexBufferShifted.fill(0);
for (let i = 0; i < this.nbPeaks; i++) {
for (var i = 0; i < this.nbPeaks; i++) { const peakIndex = this.peakIndexes[i];
let peakIndex = this.peakIndexes[i]; const peakIndexShifted = fround(peakIndex * pitchFactor);
let peakIndexShifted = Math.round(peakIndex * pitchFactor);
if (peakIndexShifted > this.magnitudes.length) { if (peakIndexShifted > this.magnitudes.length) {
break; break;
} }
// find region of influence // find region of influence
var startIndex = 0; let startIndex = 0;
var endIndex = this.fftSize; let endIndex = this.fftSize;
if (i > 0) { if (i > 0) {
let peakIndexBefore = this.peakIndexes[i - 1]; startIndex = peakIndex - fround((peakIndex - this.peakIndexes[i - 1]) / 2);
startIndex = peakIndex - Math.floor((peakIndex - peakIndexBefore) / 2);
} }
if (i < this.nbPeaks - 1) { if (i < this.nbPeaks - 1) {
let peakIndexAfter = this.peakIndexes[i + 1]; endIndex = peakIndex + fceil((this.peakIndexes[i + 1] - peakIndex) / 2);
endIndex = peakIndex + Math.ceil((peakIndexAfter - peakIndex) / 2);
} }
// shift whole region of influence around peak to shifted peak // shift whole region of influence around peak to shifted peak
let startOffset = startIndex - peakIndex; const startOffset = startIndex - peakIndex;
let endOffset = endIndex - peakIndex; const endOffset = endIndex - peakIndex;
for (var j = startOffset; j < endOffset; j++) { const omegaDelta = TWO_PI * this.invfftSize * (peakIndexShifted - peakIndex);
let binIndex = peakIndex + j; const phaseShiftReal = Math.cos(omegaDelta * this.timeCursor);
let binIndexShifted = peakIndexShifted + j; const phaseShiftImag = Math.sin(omegaDelta * this.timeCursor);
for (let j = startOffset; j < endOffset; j++) {
const binIndex = peakIndex + j;
const binIndexShifted = peakIndexShifted + j;
if (binIndexShifted >= this.magnitudes.length) { if (binIndexShifted >= this.magnitudes.length) {
break; break;
} }
// apply phase correction // apply phase correction
let omegaDelta = (2 * Math.PI * (binIndexShifted - binIndex)) / this.fftSize; const indexReal = 2 * binIndex;
let phaseShiftReal = Math.cos(omegaDelta * this.timeCursor); const indexImag = indexReal + 1;
let phaseShiftImag = Math.sin(omegaDelta * this.timeCursor); const valueReal = this.freqComplexBuffer[indexReal];
const valueImag = this.freqComplexBuffer[indexImag];
let indexReal = binIndex * 2; const valueShiftedReal = valueReal * phaseShiftReal - valueImag * phaseShiftImag;
let indexImag = indexReal + 1; const valueShiftedImag = valueReal * phaseShiftImag + valueImag * phaseShiftReal;
let valueReal = this.freqComplexBuffer[indexReal];
let valueImag = this.freqComplexBuffer[indexImag];
let valueShiftedReal = valueReal * phaseShiftReal - valueImag * phaseShiftImag; const indexShiftedReal = 2 * binIndexShifted;
let valueShiftedImag = valueReal * phaseShiftImag + valueImag * phaseShiftReal; const indexShiftedImag = indexShiftedReal + 1;
let indexShiftedReal = binIndexShifted * 2;
let indexShiftedImag = indexShiftedReal + 1;
this.freqComplexBufferShifted[indexShiftedReal] += valueShiftedReal; this.freqComplexBufferShifted[indexShiftedReal] += valueShiftedReal;
this.freqComplexBufferShifted[indexShiftedImag] += valueShiftedImag; this.freqComplexBufferShifted[indexShiftedImag] += valueShiftedImag;
} }
@@ -745,11 +720,10 @@ registerProcessor('phase-vocoder-processor', PhaseVocoderProcessor);
class PulseOscillatorProcessor extends AudioWorkletProcessor { class PulseOscillatorProcessor extends AudioWorkletProcessor {
constructor() { constructor() {
super(); super();
this.pi = _PI; this.phi = -PI; // phase
this.phi = -this.pi; // phase
this.Y0 = 0; // feedback memories this.Y0 = 0; // feedback memories
this.Y1 = 0; this.Y1 = 0;
this.PW = this.pi; // pulse width this.PW = PI; // pulse width
this.B = 2.3; // feedback coefficient this.B = 2.3; // feedback coefficient
this.dphif = 0; // filtered phase increment this.dphif = 0; // filtered phase increment
this.envf = 0; // filtered envelope this.envf = 0; // filtered envelope
@@ -806,11 +780,11 @@ class PulseOscillatorProcessor extends AudioWorkletProcessor {
dphi; dphi;
for (let i = 0; i < (output[0].length ?? 0); i++) { for (let i = 0; i < (output[0].length ?? 0); i++) {
const pw = (1 - clamp(getParamValue(i, params.pulsewidth), -0.99, 0.99)) * this.pi; const pw = (1 - clamp(pv(params.pulsewidth, i), -0.99, 0.99)) * PI;
const detune = getParamValue(i, params.detune); const detune = pv(params.detune, i);
const freq = applySemitoneDetuneToFrequency(getParamValue(i, params.frequency), detune / 100); const freq = applySemitoneDetuneToFrequency(pv(params.frequency, i), detune / 100);
dphi = freq * (this.pi / (sampleRate * 0.5)); // phase increment dphi = freq * TWO_PI * INVSR; // phase increment
this.dphif += 0.1 * (dphi - this.dphif); this.dphif += 0.1 * (dphi - this.dphif);
env *= 0.9998; // exponential decay envelope env *= 0.9998; // exponential decay envelope
@@ -822,7 +796,7 @@ class PulseOscillatorProcessor extends AudioWorkletProcessor {
// Waveform generation (half-Tomisawa oscillators) // Waveform generation (half-Tomisawa oscillators)
this.phi += this.dphif; // phase increment this.phi += this.dphif; // phase increment
if (this.phi >= this.pi) this.phi -= 2 * this.pi; // phase wrapping if (this.phi >= PI) this.phi -= TWO_PI; // phase wrapping
// First half-Tomisawa generator // First half-Tomisawa generator
let out0 = Math.cos(this.phi + this.B * this.Y0); // self-phase modulation let out0 = Math.cos(this.phi + this.B * this.Y0); // self-phase modulation
@@ -852,24 +826,23 @@ const chyx = {
/*bit reverse*/ br: function (x, size = 8) { /*bit reverse*/ br: function (x, size = 8) {
if (size > 32) { if (size > 32) {
throw new Error('br() Size cannot be greater than 32'); throw new Error('br() Size cannot be greater than 32');
} else { }
let result = 0; let result = 0;
for (let idx = 0; idx < size - 0; idx++) { for (let idx = 0; idx < size; idx++) {
result += chyx.bitC(x, 2 ** idx, 2 ** (size - (idx + 1))); result |= chyx.bitC(x, 1 << idx, 1 << (size - (idx + 1)));
} }
return result; return result;
}
}, },
/*sin that loops every 128 "steps", instead of every pi steps*/ sinf: function (x) { /*sin that loops every 128 "steps", instead of every pi steps*/ sinf: function (x) {
return Math.sin(x / (128 / Math.PI)); return Math.sin((x * PI) / 128);
}, },
/*cos that loops every 128 "steps", instead of every pi steps*/ cosf: function (x) { /*cos that loops every 128 "steps", instead of every pi steps*/ cosf: function (x) {
return Math.cos(x / (128 / Math.PI)); return Math.cos((x * PI) / 128);
}, },
/*tan that loops every 128 "steps", instead of every pi steps*/ tanf: function (x) { /*tan that loops every 128 "steps", instead of every pi steps*/ tanf: function (x) {
return Math.tan(x / (128 / Math.PI)); return Math.tan((x * PI) / 128);
}, },
/*converts t into a string composed of it's bits, regex's that*/ regG: function (t, X) { /*converts t into a string composed of its bits; regexes that*/ regG: function (t, X) {
return X.test(t.toString(2)); return X.test(t.toString(2));
}, },
}; };
@@ -877,7 +850,7 @@ const chyx = {
// Create shortened Math functions // Create shortened Math functions
let mathParams, byteBeatHelperFuncs; let mathParams, byteBeatHelperFuncs;
function getByteBeatFunc(codetext) { function getByteBeatFunc(codetext) {
if ((mathParams || byteBeatHelperFuncs) == null) { if (mathParams == null) {
mathParams = Object.getOwnPropertyNames(Math); mathParams = Object.getOwnPropertyNames(Math);
byteBeatHelperFuncs = mathParams.map((k) => Math[k]); byteBeatHelperFuncs = mathParams.map((k) => Math[k]);
const chyxNames = Object.getOwnPropertyNames(chyx); const chyxNames = Object.getOwnPropertyNames(chyx);
@@ -910,7 +883,7 @@ class ByteBeatProcessor extends AudioWorkletProcessor {
this.func = getByteBeatFunc(codeText); this.func = getByteBeatFunc(codeText);
}; };
this.initialOffset = null; this.initialOffset = 0;
this.t = null; this.t = null;
this.func = null; this.func = null;
} }
@@ -957,18 +930,19 @@ class ByteBeatProcessor extends AudioWorkletProcessor {
this.t = params.begin[0] * sampleRate; this.t = params.begin[0] * sampleRate;
} }
const output = outputs[0]; const output = outputs[0];
const scale = 256 * INVSR;
for (let i = 0; i < output[0].length; i++) { for (let i = 0; i < output[0].length; i++) {
const detune = getParamValue(i, params.detune); const detune = pv(params.detune, i);
const freq = applySemitoneDetuneToFrequency(getParamValue(i, params.frequency), detune / 100); const freq = applySemitoneDetuneToFrequency(pv(params.frequency, i), detune / 100);
let local_t = (this.t / (sampleRate / 256)) * freq + this.initialOffset; const local_t = scale * freq * this.t + this.initialOffset;
const funcValue = this.func(local_t); const funcValue = this.func(local_t);
let signal = (funcValue & 255) / 127.5 - 1; const signal = (funcValue & 255) / 127.5 - 1;
const out = signal * 0.2;
for (let c = 0; c < output.length; c++) {
//prevent speaker blowout via clipping if threshold exceeds //prevent speaker blowout via clipping if threshold exceeds
output[c][i] = clamp(out, -0.4, 0.4); const out = clamp(signal * 0.2, -0.4, 0.4);
for (let c = 0; c < output.length; c++) {
output[c][i] = out;
} }
this.t = this.t + 1; this.t++;
} }
return true; // keep the audio processing going return true; // keep the audio processing going
@@ -977,6 +951,102 @@ class ByteBeatProcessor extends AudioWorkletProcessor {
registerProcessor('byte-beat-processor', ByteBeatProcessor); registerProcessor('byte-beat-processor', ByteBeatProcessor);
class EnvelopeProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [
{ name: 'begin', defaultValue: 0 },
{ name: 'end', defaultValue: 0 },
{ name: 'attack', defaultValue: 0.005, minValue: 0 },
{ name: 'decay', defaultValue: 0.14, minValue: 0 },
{ name: 'sustain', defaultValue: 0, minValue: 0, maxValue: 1 },
{ name: 'release', defaultValue: 0.1, minValue: 0 },
{ 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: 'retrigger', defaultValue: 1, minValue: 0, maxValue: 1 },
];
}
constructor() {
super();
this.val = 0;
this.segIdx = 0;
this.state = 0;
this.beginTime = 0;
this.endTime = 0;
this.attackStart = 0;
}
_warp(phase, curvature, strength = 8) {
if (phase === 0 || phase === 1) return phase; // fast exit
if (curvature > 0) {
// snappier
const exp = 1 + strength * curvature;
return 1 - Math.pow(1 - phase, exp);
} else {
// more calm
const exp = 1 - strength * curvature;
return Math.pow(phase, exp);
}
}
_advance(start, target, time, curvature) {
if (time === 0 || start === target) {
this.val = target;
} else {
// We compute our progress through this section of the envelope in time
// as a `phase` value, which is warped by the curvature, and then used
// to compute the value of the envelope at that time
const phase = Math.min(1, (currentTime - this.beginTime) / time);
const phaseWarped = this._warp(phase, curvature);
this.val = start + (target - start) * phaseWarped;
}
}
process(_inputs, outputs, params) {
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
this.beginTime = begin;
this.state = 1;
this.endTime = pv(params.end, 0);
this.attackStart = this.val;
}
const susTime = this.endTime - this.beginTime;
for (let i = 0; i < out.length; i++) {
const attack = pv(params.attack, i);
const decay = pv(params.decay, i);
const sustain = pv(params.sustain, i);
const release = pv(params.release, i);
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 states = [
{ time: Number.POSITIVE_INFINITY, start: 0, target: 0 }, // idle
{ time: attack, start: this.attackStart, target: 1, curve: aCurve },
{ time: attack + decay, start: 1, target: sustain, curve: dCurve },
{ time: susTime, start: sustain, target: sustain },
{ time: susTime + release, start: sustain, target: 0, curve: rCurve },
];
let { time, start, target, curve } = states[this.state];
this._advance(start, target, time, curve);
while (currentTime - this.beginTime >= time) {
this.state = (this.state + 1) % states.length;
time = states[this.state].time;
}
out[i] = this.val * peak;
}
return true;
}
}
registerProcessor('envelope-processor', EnvelopeProcessor);
export const WarpMode = Object.freeze({ export const WarpMode = Object.freeze({
NONE: 0, NONE: 0,
ASYM: 1, ASYM: 1,
@@ -1056,7 +1126,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
{ name: 'position', defaultValue: 0, min: 0, max: 1 }, { name: 'position', defaultValue: 0, min: 0, max: 1 },
{ name: 'warp', defaultValue: 0, min: 0, max: 1 }, { name: 'warp', defaultValue: 0, min: 0, max: 1 },
{ name: 'warpMode', defaultValue: 0 }, { name: 'warpMode', defaultValue: 0 },
{ name: 'voices', defaultValue: 1, min: 1 }, { name: 'voices', defaultValue: 1, min: 1, automationRate: 'k-rate' },
{ name: 'panspread', defaultValue: 0.7, min: 0, max: 1 }, { name: 'panspread', defaultValue: 0.7, min: 0, max: 1 },
{ name: 'phaserand', defaultValue: 0, min: 0, max: 1 }, { name: 'phaserand', defaultValue: 0, min: 0, max: 1 },
]; ];
@@ -1067,7 +1137,6 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
this.frameLen = 0; this.frameLen = 0;
this.numFrames = 0; this.numFrames = 0;
this.phase = []; this.phase = [];
this.invSR = 1 / sampleRate;
this.port.onmessage = (e) => { this.port.onmessage = (e) => {
const { type, payload } = e.data || {}; const { type, payload } = e.data || {};
@@ -1104,7 +1173,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
_toBits(amt, min = 2, max = 12) { _toBits(amt, min = 2, max = 12) {
const b = max + (min - max) * amt; const b = max + (min - max) * amt;
return { b, n: Math.round(Math.pow(2, b)) }; return { b, n: fround(Math.pow(2, b)) };
} }
_warpPhase(phase, amt, mode) { _warpPhase(phase, amt, mode) {
@@ -1130,7 +1199,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
return amt < 0.5 ? this._warpPhase(phase, 1 - 2 * amt, 3) : this._warpPhase(phase, 2 * amt - 1, 2); return amt < 0.5 ? this._warpPhase(phase, 1 - 2 * amt, 3) : this._warpPhase(phase, 2 * amt - 1, 2);
} }
case WarpMode.SYNC: { case WarpMode.SYNC: {
const syncRatio = Math.pow(16, amt * amt); const syncRatio = Math.pow(16, amt ** 2);
return (phase * syncRatio) % 1; return (phase * syncRatio) % 1;
} }
case WarpMode.QUANT: { case WarpMode.QUANT: {
@@ -1139,8 +1208,8 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
} }
case WarpMode.FOLD: { case WarpMode.FOLD: {
const K = 7; const K = 7;
const k = 1 + Math.max(1, Math.round(K * amt)); const k = 1 + Math.max(1, fround(K * amt));
return Math.abs(frac(k * phase) - 0.5) * 2; return Math.abs(ffrac(k * phase) - 0.5) * 2;
} }
case WarpMode.PWM: { case WarpMode.PWM: {
const w = clamp(0.5 + 0.49 * (2 * amt - 1), 0, 1); const w = clamp(0.5 + 0.49 * (2 * amt - 1), 0, 1);
@@ -1150,12 +1219,12 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
case WarpMode.ORBIT: { case WarpMode.ORBIT: {
const depth = 0.5 * amt; const depth = 0.5 * amt;
const n = 3; const n = 3;
return frac(phase + depth * Math.sin(2 * Math.PI * n * phase)); return frac(phase + depth * Math.sin(TWO_PI * n * phase));
} }
case WarpMode.SPIN: { case WarpMode.SPIN: {
const depth = 0.5 * amt; const depth = 0.5 * amt;
const { n } = this._toBits(amt, 1, 6); const { n } = this._toBits(amt, 1, 6);
return frac(phase + depth * Math.sin(2 * Math.PI * n * phase)); return frac(phase + depth * Math.sin(TWO_PI * n * phase));
} }
case WarpMode.CHAOS: { case WarpMode.CHAOS: {
const r = 3.7 + 0.3 * amt; const r = 3.7 + 0.3 * amt;
@@ -1166,7 +1235,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
const isPrime = (n) => { const isPrime = (n) => {
if (n < 2) return false; if (n < 2) return false;
if (n % 2 === 0) return n === 2; if (n % 2 === 0) return n === 2;
for (let d = 3; d * d <= n; d += 2) if (n % d === 0) return false; for (let d = 3; d ** 2 <= n; d += 2) if (n % d === 0) return false;
return true; return true;
}; };
let { n } = this._toBits(amt, 3); let { n } = this._toBits(amt, 3);
@@ -1175,18 +1244,12 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
} }
case WarpMode.BINARY: { case WarpMode.BINARY: {
let { b } = this._toBits(amt, 3); let { b } = this._toBits(amt, 3);
b = Math.round(b); b = fround(b);
const n = 1 << b; const n = 1 << b;
const idx = ffloor(phase * n); const idx = ffloor(phase * n);
const ridx = bitReverse(idx, b); const ridx = bitReverse(idx, b);
return ridx / n; return ridx / n;
} }
case WarpMode.MODULAR: {
const { n } = this._toBits(amt);
const depth = 0.5 * amt;
const jump = frac(phase * n) / n;
return frac(phase + depth * jump);
}
case WarpMode.BROWNIAN: { case WarpMode.BROWNIAN: {
const disp = 0.25 * amt * brownian(64 * phase, 4); const disp = 0.25 * amt * brownian(64 * phase, 4);
return frac(phase + disp); return frac(phase + disp);
@@ -1209,7 +1272,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
case WarpMode.LOGISTIC: { case WarpMode.LOGISTIC: {
let x = phase; let x = phase;
const r = 3.6 + 0.4 * amt; const r = 3.6 + 0.4 * amt;
const iters = 1 + Math.round(2 * amt); const iters = 1 + fround(2 * amt);
for (let i = 0; i < iters; i++) x = r * x * (1 - x); for (let i = 0; i < iters; i++) x = r * x * (1 - x);
return clamp(x, 0, 1); return clamp(x, 0, 1);
} }
@@ -1222,7 +1285,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
return (y - y0) / (y1 - y0); return (y - y0) / (y1 - y0);
} }
case WarpMode.FRACTAL: { case WarpMode.FRACTAL: {
const d = 0.5 * Math.sin(2 * Math.PI * phase) * amt; const d = 0.5 * Math.sin(TWO_PI * phase) * amt;
return frac(phase + d); return frac(phase + d);
} }
case WarpMode.FLIP: { case WarpMode.FLIP: {
@@ -1269,16 +1332,16 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
if (outR !== outL) outR.set(outL); if (outR !== outL) outR.set(outL);
return true; return true;
} }
const voices = parameters.voices[0]; // k-rate
for (let i = 0; i < outL.length; i++) { for (let i = 0; i < outL.length; i++) {
const detune = pv(parameters.detune, i); const detune = pv(parameters.detune, i);
const freqspread = pv(parameters.freqspread, i); const freqspread = pv(parameters.freqspread, i);
const tablePos = clamp(pv(parameters.position, i), 0, 1); const tablePos = clamp(pv(parameters.position, i), 0, 1);
const idx = tablePos * (this.numFrames - 1); const idx = tablePos * (this.numFrames - 1);
const fIdx = idx | 0; const fIdx = idx | 0;
const frac = idx - fIdx; const interpT = idx - fIdx;
const warpAmount = clamp(pv(parameters.warp, i), 0, 1); const warpAmount = clamp(pv(parameters.warp, i), 0, 1);
const warpMode = pv(parameters.warpMode, i); const warpMode = pv(parameters.warpMode, i);
const voices = pv(parameters.voices, i);
const phaseRand = clamp(pv(parameters.phaserand, i), 0, 1); const phaseRand = clamp(pv(parameters.phaserand, i), 0, 1);
const panspread = voices > 1 ? clamp(pv(parameters.panspread, i), 0, 1) : 0; const panspread = voices > 1 ? clamp(pv(parameters.panspread, i), 0, 1) : 0;
const gain1 = Math.sqrt(0.5 - 0.5 * panspread); const gain1 = Math.sqrt(0.5 - 0.5 * panspread);
@@ -1286,6 +1349,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
let f = pv(parameters.frequency, i); let f = pv(parameters.frequency, i);
f = applySemitoneDetuneToFrequency(f, detune / 100); // overall detune f = applySemitoneDetuneToFrequency(f, detune / 100); // overall detune
const normalizer = 1 / Math.sqrt(voices); const normalizer = 1 / Math.sqrt(voices);
const detuner = getDetuner(voices, freqspread);
for (let n = 0; n < voices; n++) { for (let n = 0; n < voices; n++) {
const isOdd = (n & 1) == 1; const isOdd = (n & 1) == 1;
let gainL = gain1; let gainL = gain1;
@@ -1295,8 +1359,8 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
gainL = gain2; gainL = gain2;
gainR = gain1; gainR = gain1;
} }
const fVoice = applySemitoneDetuneToFrequency(f, getUnisonDetune(voices, freqspread, n)); // voice detune const fVoice = applySemitoneDetuneToFrequency(f, detuner(n)); // voice detune
const dPhase = fVoice * this.invSR; const dPhase = fVoice * INVSR;
const level = this._chooseMip(dPhase); const level = this._chooseMip(dPhase);
const table = this.tables[level]; const table = this.tables[level];
@@ -1305,13 +1369,13 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
const ph = this._warpPhase(this.phase[n], warpAmount, warpMode); const ph = this._warpPhase(this.phase[n], warpAmount, warpMode);
const s0 = this._sampleFrame(table[fIdx], ph); const s0 = this._sampleFrame(table[fIdx], ph);
const s1 = this._sampleFrame(table[Math.min(this.numFrames - 1, fIdx + 1)], ph); const s1 = this._sampleFrame(table[Math.min(this.numFrames - 1, fIdx + 1)], ph);
let s = s0 + (s1 - s0) * frac; let s = lerp(s0, s1, interpT);
if (warpMode === WarpMode.FLIP && this.phase[n] < warpAmount) { if (warpMode === WarpMode.FLIP && this.phase[n] < warpAmount) {
s = -s; s = -s;
} }
outL[i] += s * gainL * normalizer; outL[i] += s * gainL * normalizer;
outR[i] += s * gainR * normalizer; outR[i] += s * gainR * normalizer;
this.phase[n] = wrapPhase(this.phase[n] + dPhase); this.phase[n] = frac(this.phase[n] + dPhase);
} }
} }
return true; return true;
+4 -2
View File
@@ -1,11 +1,12 @@
// this is dough, the superdough without dependencies // this is dough, the superdough without dependencies
// @ts-check // @ts-nocheck
// @ts-ignore ignore next line because sampleRate is unknown // @ts-ignore ignore next line because sampleRate is unknown
const SAMPLE_RATE = typeof sampleRate !== 'undefined' ? sampleRate : 48000; const SAMPLE_RATE = typeof sampleRate !== 'undefined' ? sampleRate : 48000;
const PI_DIV_SR = Math.PI / SAMPLE_RATE; const PI_DIV_SR = Math.PI / SAMPLE_RATE;
const ISR = 1 / SAMPLE_RATE; const ISR = 1 / SAMPLE_RATE;
let gainCurveFunc = (val) => Math.pow(val, 2); let gainCurveFunc = (val) => Math.pow(val, 2);
const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
function applyGainCurve(val) { function applyGainCurve(val) {
return gainCurveFunc(val); return gainCurveFunc(val);
@@ -151,7 +152,8 @@ export class TwoPoleFilter {
resonance = Math.max(resonance, 0); resonance = Math.max(resonance, 0);
cutoff = Math.min(cutoff, 20000); cutoff = Math.min(cutoff, 20000);
const c = 2 * Math.sin(cutoff * PI_DIV_SR); let c = 2 * Math.sin(cutoff * PI_DIV_SR);
c = clamp(c, 0, 1.14); // this line prevents instability TODO: test
const r = Math.pow(0.5, (resonance + 0.125) / 0.125); const r = Math.pow(0.5, (resonance + 0.125) / 0.125);
const mrc = 1 - r * c; const mrc = 1 - r * c;
+6 -3
View File
@@ -13,7 +13,7 @@
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/tidalcycles/strudel.git" "url": "git+https://codeberg.org/uzu/strudel.git"
}, },
"keywords": [ "keywords": [
"tidalcycles", "tidalcycles",
@@ -25,12 +25,15 @@
"author": "Felix Roos <flix91@gmail.com>", "author": "Felix Roos <flix91@gmail.com>",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"bugs": { "bugs": {
"url": "https://github.com/tidalcycles/strudel/issues" "url": "https://codeberg.org/uzu/strudel/issues"
}, },
"homepage": "https://github.com/tidalcycles/strudel#readme", "homepage": "https://codeberg.org/uzu/strudel#readme",
"devDependencies": { "devDependencies": {
"vite": "^6.0.11", "vite": "^6.0.11",
"vite-plugin-bundle-audioworklet": "workspace:*", "vite-plugin-bundle-audioworklet": "workspace:*",
"wav-encoder": "^1.3.0" "wav-encoder": "^1.3.0"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
import bundleAudioWorkletPlugin from 'vite-plugin-bundle-audioworklet';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [bundleAudioWorkletPlugin()],
build: {
lib: {
entry: resolve(__dirname, 'index.mjs'),
formats: ['es'],
fileName: (ext) => ({ es: 'index.mjs' })[ext],
},
target: 'esnext',
},
});
+3
View File
@@ -25,5 +25,8 @@
}, },
"devDependencies": { "devDependencies": {
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+3
View File
@@ -38,5 +38,8 @@
"devDependencies": { "devDependencies": {
"vite": "^6.0.11", "vite": "^6.0.11",
"vitest": "^3.0.4" "vitest": "^3.0.4"
},
"engines": {
"node": ">=18.0.0"
} }
} }
-7
View File
@@ -61,13 +61,6 @@ describe('tonal', () => {
.firstCycleValues.map((h) => h.note), .firstCycleValues.map((h) => h.note),
).toEqual(['B2', 'Eb3', 'A2', 'G3', 'F3']); ).toEqual(['B2', 'Eb3', 'A2', 'G3', 'F3']);
}); });
it('produces silence for mixed sharps and flats', () => {
expect(
n(seq('0b#', '1#b', '2#b#'))
.scale('C major')
.firstCycleValues.map((h) => h.note),
).toEqual([]);
});
it('snaps notes (upwards) to scale', () => { it('snaps notes (upwards) to scale', () => {
const inputNotes = ['Cb', 'Eb', 'G', 'A#', 'Bb']; const inputNotes = ['Cb', 'Eb', 'G', 'A#', 'Bb'];
const expectedNotes = ['B2', 'E3', 'G3', 'B3', 'B3']; const expectedNotes = ['B2', 'E3', 'G3', 'B3', 'B3'];
+60 -47
View File
@@ -5,9 +5,17 @@ This program is free software: you can redistribute it and/or modify it under th
*/ */
import { Note, Interval, Scale } from '@tonaljs/tonal'; import { Note, Interval, Scale } from '@tonaljs/tonal';
import { register, _mod, silence, logger, pure, isNote } from '@strudel/core'; import {
_mod,
errorLogger,
getAccidentalsOffset,
isNote,
logger,
noteToMidi,
register,
removeUndefineds,
} from '@strudel/core';
import { stepInNamedScale, nearestNumberIndex } from './tonleiter.mjs'; import { stepInNamedScale, nearestNumberIndex } from './tonleiter.mjs';
import { noteToMidi } from '../core/util.mjs';
const octavesInterval = (octaves) => (octaves <= 0 ? -1 : 1) + octaves * 7 + 'P'; const octavesInterval = (octaves) => (octaves <= 0 ? -1 : 1) + octaves * 7 + 'P';
@@ -185,17 +193,15 @@ function _convertStepToNumberAndOffset(step) {
step = String(step); step = String(step);
// Check to see if the step matches the expected format: // Check to see if the step matches the expected format:
// - A number (possibly negative) // - A number (possibly negative)
// - Some number of sharps or flats (but not both) // - Some number of sharps or flats
const match = /^(-?\d+)(#+|b+)?$/.exec(step); const match = /^(-?\d+)([#bsf]*)$/.exec(step);
if (!match) { if (!match) {
throw new Error(`invalid scale step "${step}", expected number or integer with optional # b suffixes`); throw new Error(`invalid scale step "${step}", expected number or integer with optional # b suffixes`);
} }
asNumber = Number(match[1]); asNumber = Number(match[1]);
// These decorations will determine the semitone offset based on the number of const accidentals = match[2] || '';
// sharps or flats offset = getAccidentalsOffset(accidentals);
const decorations = match[2] || '';
offset = decorations[0] === '#' ? decorations.length : -decorations.length;
} }
return [asNumber, offset]; return [asNumber, offset];
} }
@@ -226,12 +232,15 @@ function _getNearestScaleNote(scaleName, note, preferHigher = true) {
* Turns numbers into notes in the scale (zero indexed) or quantizes notes to a scale. * Turns numbers into notes in the scale (zero indexed) or quantizes notes to a scale.
* *
* When describing notes via numbers, note that negative numbers can be used to wrap backwards * When describing notes via numbers, note that negative numbers can be used to wrap backwards
* in the scale as well as sharps or flats (but not both) to produce notes outside of the scale. * in the scale as well as sharps or flats to produce notes outside of the scale.
* *
* Also sets scale for other scale operations, like {@link Pattern#scaleTranspose}. * Also sets scale for other scale operations, like {@link Pattern#scaleTranspose}.
* *
* A scale consists of a root note (e.g. `c4`, `c`, `f#`, `bb4`) followed by semicolon (':') and then a [scale type](https://github.com/tonaljs/tonal/blob/main/packages/scale-type/data.ts). * A scale consists of a root note (e.g. `c4`, `c`, `f#`, `bb4`) followed by semicolon (':') and then a [scale type](https://github.com/tonaljs/tonal/blob/main/packages/scale-type/data.ts).
* *
* The scale name must be written without spaces (because it would be interpreted as a multi-step pattern otherwise).
* If your scale name includes spaces, replace them with colons.
*
* The root note defaults to octave 3, if no octave number is given. * The root note defaults to octave 3, if no octave number is given.
* *
* @name scale * @name scale
@@ -253,8 +262,9 @@ function _getNearestScaleNote(scaleName, note, preferHigher = true) {
* .s("piano") * .s("piano")
* @example * @example
* note("C1*16").transpose(irand(36)).scale('Cb2 major').scaleTranspose(3) * note("C1*16").transpose(irand(36)).scale('Cb2 major').scaleTranspose(3)
* @example
* n("[0 0] [1 2] [3 4] [5 6]").scale("C:major:blues")
*/ */
export const scale = register( export const scale = register(
'scale', 'scale',
function (scale, pat) { function (scale, pat) {
@@ -262,44 +272,47 @@ export const scale = register(
if (Array.isArray(scale)) { if (Array.isArray(scale)) {
scale = scale.flat().join(' '); scale = scale.flat().join(' ');
} }
return ( return pat.withHaps((haps) => {
pat haps = haps.map((hap) => {
.fmap((value) => { let hVal = hap.value;
const isObject = typeof value === 'object'; const isObject = typeof hVal === 'object';
// The case where the note has been defined via `n` or `pure` // If hVal is a pure value, place it on `n` so that we interpret it as a scale degree
if (!isObject || (isObject && ('n' in value || 'value' in value))) { hVal = isObject ? hVal : { n: hVal };
const step = isObject ? (value.n ?? value.value) : value; const { note, n, value, ...otherValues } = hVal;
delete value.n; // remove n so it won't cause trouble const noteOrStep = note ?? n ?? value;
if (isNote(step)) { if (noteOrStep === undefined) {
// legacy.. logger(
return pure(step); `[tonal] Invalid value format for 'scale'. Value must contain n, note, or value but received keys [${Object.keys(hVal).join(', ')}]`,
} 'error',
try {
const [number, offset] = _convertStepToNumberAndOffset(step);
let note;
if (isObject && value.anchor) {
note = stepInNamedScale(number, scale, value.anchor);
} else {
note = scaleStep(number, scale);
}
if (offset != 0) note = Note.transpose(note, Interval.fromSemitones(offset));
value = pure(isObject ? { ...value, note } : note);
} catch (err) {
logger(`[tonal] ${err.message}`, 'error');
return silence;
}
return value;
}
// The case where the note has been defined via `note`
else {
const note = _getNearestScaleNote(scale, value.note);
return pure(isObject ? { ...value, note } : note);
}
})
.outerJoin()
// legacy:
.withHap((hap) => hap.setContext({ ...hap.context, scale }))
); );
return hap; // pass the value through unchanged
}
let scaleNote;
if (isNote(noteOrStep)) {
// Note case (quantize to scale)
scaleNote = _getNearestScaleNote(scale, noteOrStep);
hap.value = { ...otherValues, note: scaleNote };
} else {
// Step case (convert to note in scale)
try {
const [number, offset] = _convertStepToNumberAndOffset(noteOrStep);
if (otherValues.anchor) {
scaleNote = stepInNamedScale(number, scale, otherValues.anchor);
} else {
scaleNote = scaleStep(number, scale);
}
if (offset != 0) scaleNote = Note.transpose(scaleNote, Interval.fromSemitones(offset));
} catch (err) {
errorLogger(err, 'tonal');
return; // will be removed
}
}
hap.value = isObject ? { ...otherValues, note: scaleNote } : scaleNote;
// Tag with scale for downsteam scale-aware operations
return hap.setContext({ ...hap.context, scale });
});
return removeUndefineds(haps);
});
}, },
true, true,
true, // preserve step count true, // preserve step count
+3
View File
@@ -39,5 +39,8 @@
"devDependencies": { "devDependencies": {
"vite": "^6.0.11", "vite": "^6.0.11",
"vitest": "^3.0.4" "vitest": "^3.0.4"
},
"engines": {
"node": ">=18.0.0"
} }
} }
@@ -12,5 +12,8 @@
"type": "module", "type": "module",
"devDependencies": { "devDependencies": {
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+1 -1
View File
@@ -62,7 +62,7 @@ initStrudel();
document.getElementById('play').addEventListener('click', document.getElementById('play').addEventListener('click',
() => evaluate('note("c a f e").jux(rev)') () => evaluate('note("c a f e").jux(rev)')
); );
document.getElementById('play').addEventListener('stop', document.getElementById('stop').addEventListener('click',
() => hush() () => hush()
); );
``` ```
+3
View File
@@ -43,5 +43,8 @@
"@rollup/plugin-replace": "^6.0.2", "@rollup/plugin-replace": "^6.0.2",
"vite": "^6.0.11", "vite": "^6.0.11",
"vite-plugin-bundle-audioworklet": "workspace:*" "vite-plugin-bundle-audioworklet": "workspace:*"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+3
View File
@@ -40,5 +40,8 @@
}, },
"devDependencies": { "devDependencies": {
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+3
View File
@@ -35,5 +35,8 @@
"devDependencies": { "devDependencies": {
"vite": "^6.0.11", "vite": "^6.0.11",
"vitest": "^3.0.4" "vitest": "^3.0.4"
},
"engines": {
"node": ">=18.0.0"
} }
} }
-1
View File
@@ -41,7 +41,6 @@ Tune.prototype.tonicize = function(newTonic) {
this.tonic = newTonic this.tonic = newTonic
} }
/* Return data in the mode you are in (freq, ratio, or midi) */ /* Return data in the mode you are in (freq, ratio, or midi) */
Tune.prototype.note = function(input,octave){ Tune.prototype.note = function(input,octave){
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+15 -1
View File
@@ -4,11 +4,25 @@ import { describe, it } from 'vitest';
const tuneKeys = Object.keys(tunes); const tuneKeys = Object.keys(tunes);
// Node 24 tightened Number→string rounding; clamp decimals so snapshots stay stable across engines.
const roundFloatStrings = (input, precision = 12) => {
// if matches a decimal number ex: 12.34, -0.5, 0.123, 99.0, 1.932093850293
const regex = /-?\d+\.\d+/g;
return input.replace(regex, (match) => {
// converts the literal to a number, performs round to nearest (ties to even)
// at the requested precision, and returns the rounded decimal string
const rounded = Number(match).toFixed(precision);
// trims trailing zeros (and a dangling dot) after rounding, so the displayed string looks tidy
return rounded.replace(/\.?0+$/, '').replace(/\.$/, '');
});
};
describe('renders tunes', () => { describe('renders tunes', () => {
tuneKeys.forEach((key) => { tuneKeys.forEach((key) => {
it(`tune: ${key}`, async ({ expect }) => { it(`tune: ${key}`, async ({ expect }) => {
const haps = await queryCode(tunes[key], testCycles[key] || 1); const haps = await queryCode(tunes[key], testCycles[key] || 1);
expect(haps).toMatchSnapshot(); const normalized = haps.map((hap) => roundFloatStrings(hap));
expect(normalized).toMatchSnapshot();
}); });
}); });
}); });
+4
View File
@@ -1,5 +1,9 @@
{ {
"name": "dbpatch",
"dependencies": { "dependencies": {
"csv": "^6.3.11" "csv": "^6.3.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+14
View File
@@ -0,0 +1,14 @@
fetch('https://codeberg.org/api/v1/repos/uzu/strudel/pulls?state=closed&page=1')
.then((res) => res.json())
.then((pulls) => {
const r = pulls
.filter((pull) => pull.merged)
.sort((a, b) => new Date(b.closed_at) - new Date(a.closed_at))
.map((pull) => `${pull.closed_at} ${pull.title} by ${pull.user.login || '?'} in: [#${pull.number}](${pull.url}) `)
.join('\n');
console.log(r);
});
/*
*/
+3
View File
@@ -75,5 +75,8 @@
"sharp": "^0.33.5", "sharp": "^0.33.5",
"workbox-window": "^7.3.0", "workbox-window": "^7.3.0",
"vite-plugin-bundle-audioworklet": "workspace:*" "vite-plugin-bundle-audioworklet": "workspace:*"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+1 -1
View File
@@ -63,7 +63,7 @@ You can also create custom aliases for existing sounds using the `soundAlias` fu
<MiniRepl <MiniRepl
client:idle client:idle
tune={`soundAlias("RolandTR808_bd", "kick") tune={`soundAlias('RolandTR808_bd', 'kick')
s("kick")`} s("kick")`}
/> />
+73 -7
View File
@@ -48,28 +48,94 @@ You can also use the `crackle` type to play some subtle noise crackles. You can
### Additive Synthesis ### Additive Synthesis
To tame the harsh sound of the basic waveforms, we can set the `n` control to limit the overtones of the waveform: Periodic waveforms are composed of several [harmonics](https://en.wikipedia.org/wiki/Harmonic) above a fundamental frequency, lying at integer multiples. These overtones combine to give a sound its unique timbral quality.
For the basic waveforms, we offer you control over these harmonics with the `partials` and `phases` functions.
#### Partials
`partials` refers to the magnitude of each harmonic relative to the fundamental frequency. They can thus be used to spectrally filter these waveforms and tame some of their harshness:
<MiniRepl <MiniRepl
client:idle client:idle
tune={`note("c2 <eb2 <g2 g1>>".fast(2)) tune={`note("c2 <eb2 <g2 g1>>".fast(2))
.sound("sawtooth") .sound("sawtooth")
.n("<32 16 8 4>") .partials([1, 1, "<1 0>", "<1 0>", "<1 0>", "<1 0>", "<1 0>"])
._scope()`} ._scope()`}
/> />
When the `n` control is used on a basic waveform, it defines the number of harmonic partials the sound is getting. `partials` can also be used to construct _new_ waveforms not present in our basic set with the 'user' sound source:
You can also set `n` directly in mini notation with `sound`:
<MiniRepl <MiniRepl
client:idle client:idle
tune={`note("c2 <eb2 <g2 g1>>".fast(2)) tune={`note("c2 <eb2 <g2 g1>>".fast(2))
.sound("sawtooth:<32 16 8 4>") .sound("user")
.partials([1, 0, 0.3, 0, 0.1, 0, 0, 0.3])
._scope()`} ._scope()`}
/> />
Note for tidal users: `n` in tidal is synonymous to `note` for synths only. We may algorithmically construct lists of magnitudes with Javascript code like:
In strudel, this is not the case, where `n` will always change timbre, be it though different samples or different waveforms.
<MiniRepl
client:idle
tune={`const numHarmonics = 22;
note("c2 <eb2 <g2 g1>>".fast(2))
.sound("saw")
.partials(new Array(numHarmonics).fill(1))
._scope()`}
/>
which acts as a spectral filter. Or:
<MiniRepl
client:idle
tune={`note("c2 <eb2 <g2 g1>>").fast(2)
.sound("user")
.partials(new Array(50).fill(0)
.map((_, idx) => ((-1) ** (idx + 1)) / (idx + 1))
)
._scope()`}
/>
which recovers a familiar waveform.
`partials` is also compatible with pattern functions designed to produce lists, like `randL` or `binaryL`:
<MiniRepl
client:idle
tune={`note("c2 <eb2 <g2 g1>>").fast(2)
.sound("user")
.partials(randL(10))
._scope()`}
/>
and with lists _of_ patterns:
<MiniRepl
client:idle
tune={`note("c2 <eb2 <g2 g1>>".fast(4))
.sound("user")
.partials([1, 0, "0 1", "0 1 0.3", rand])
._scope()`}
/>
Note that the first value in the `partials` array controls the magnitude of the fundamental harmonic rather than the DC offset, which is fixed at 0.
#### Phases
Earlier, we mentioned that periodic waveforms can be broken into a set of harmonics above a fundamental frequency. Each harmonic has two defining properties: its magnitude (how loud it is) and its phase, which determines where in its cycle that sine wave starts when the waveform is built.
These phases too can be declared in Strudel and can give your sounds interesting depth.
<MiniRepl
client:idle
tune={`s("saw").seg(16).n(irand(12)).scale("F1:minor")
.penv(48).panchor(0).pdec(0.05)
.delay(0.25).room(0.25)
.compressor(-20).vib(0.3)
.partials(randL(200))
.phases(randL(200))`}
/>
## Vibrato ## Vibrato
@@ -32,7 +32,7 @@ There are 3 quick ways to embed strudel in your website:
### Inside an iframe ### Inside an iframe
Using an iframe is the most easy way to embed a studel tune. Using an iframe is the most easy way to embed a strudel tune.
You can embed any pattern of your choice via an iframe and the URL of the pattern of your choice: You can embed any pattern of your choice via an iframe and the URL of the pattern of your choice:
```html ```html
@@ -133,7 +133,7 @@ If you'd rather use your own UI, you can use the `@strudel/web` package:
</script> </script>
``` ```
For more info on this package, see the [@strudel/web README]https://codeberg.org/uzu/strudel/src/branch/main/packages/web#strudel-web). For more info on this package, see the [@strudel/web README](https://codeberg.org/uzu/strudel/src/branch/main/packages/web#strudel-web).
## Via npm ## Via npm
@@ -117,6 +117,10 @@ Here's an example AST for `c3 [e3 g3]`
which translates to `seq(c3, seq(e3, g3))` which translates to `seq(c3, seq(e3, g3))`
## Vim Keybindings
See the separate page on Vim shortcuts for a quick reference: [/technical-manual/vim](/technical-manual/vim)
## Scheduling Events ## Scheduling Events
After an instance of `Pattern` is obtained from the user code, After an instance of `Pattern` is obtained from the user code,
@@ -0,0 +1,37 @@
---
title: Vim Shortcuts
layout: ../../layouts/MainLayout.astro
---
# Vim Shortcuts in the REPL
When the REPL editor (CodeMirror) is configured to use Vim keybindings, the following commands are available:
- :w — Evaluate the current code
- Triggers the same evaluation as Ctrl+Enter / Alt+Enter
- You'll see messages in the Console panel such as:
- [vim] :w — evaluating code
- [repl] evaluate via event
- [eval] code updated
- :q — Stop/pause playback
- Triggers the same stop action as Alt+.
- Useful to quickly stop scheduling without leaving Vim mode
- gc — Toggle line comments for the current selection(s)
- Works in normal and visual mode
- If there's a selection, all selected lines are toggled
Notes
- Behavior respects the current language mode in the editor for comment syntax.
- If multiple REPL editors are open, commands target the active editor. The implementation dispatches custom events handled by the editor.
- If you don't see the Console panel, open the right panel in the REPL UI.
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.
@@ -8,3 +8,34 @@ export function ActionButton({ children, label, labelIsHidden, className, ...but
</button> </button>
); );
} }
export function SpecialActionButton(props) {
const { className, ...buttonProps } = props;
return (
<ActionButton
{...buttonProps}
className={cx('bg-background p-2 max-w-[300px] rounded-md hover:opacity-50', className)}
/>
);
}
export function ActionInput({ label, className, ...props }) {
return (
<label className={cx('inline-flex items-center cursor-pointer', className)}>
<input {...props} className="sr-only peer" />
<span className="inline-flex items-center peer-hover:opacity-50">{label}</span>
</label>
);
}
export function SpecialActionInput({ className, ...props }) {
return (
<ActionInput
{...props}
className={className}
label={<span className="bg-background p-2 max-w-[300px] rounded-md">{props.label}</span>}
/>
);
}
@@ -0,0 +1,29 @@
import { errorLogger } from '@strudel/core';
import { useSettings, storePrebakeScript } from '../../../settings.mjs';
import { SpecialActionInput } from '../button/action-button';
async function importScript(script) {
const reader = new FileReader();
reader.readAsText(script);
reader.onload = () => {
const text = reader.result;
storePrebakeScript(text);
};
reader.onerror = () => {
errorLogger(new Error('failed to import prebake script'), 'importScript');
};
}
export function ImportPrebakeScriptButton() {
const settings = useSettings();
return (
<SpecialActionInput
type="file"
label="import prebake script"
accept=".strudel"
onChange={(e) => importScript(e.target.files[0])}
/>
);
}
@@ -79,13 +79,11 @@ const updateCodeWindow = (context, patternData, reset = false) => {
context.handleUpdate(patternData, reset); context.handleUpdate(patternData, reset);
}; };
const autoResetPatternOnChange = !isUdels();
function UserPatterns({ context }) { function UserPatterns({ context }) {
const activePattern = useActivePattern(); const activePattern = useActivePattern();
const viewingPatternStore = useViewingPatternData(); const viewingPatternStore = useViewingPatternData();
const viewingPatternData = parseJSON(viewingPatternStore); const viewingPatternData = parseJSON(viewingPatternStore);
const { userPatterns, patternFilter } = useSettings(); const { userPatterns, patternFilter, patternAutoStart } = useSettings();
const viewingPatternID = viewingPatternData?.id; const viewingPatternID = viewingPatternData?.id;
return ( return (
<div className="flex flex-col gap-2 flex-grow overflow-hidden h-full pb-2 "> <div className="flex flex-col gap-2 flex-grow overflow-hidden h-full pb-2 ">
@@ -135,13 +133,13 @@ function UserPatterns({ context }) {
<div className="overflow-auto h-full bg-background p-2 rounded-md"> <div className="overflow-auto h-full bg-background p-2 rounded-md">
{/* {patternFilter === patternFilterName.user && ( */} {/* {patternFilter === patternFilterName.user && ( */}
<PatternButtons <PatternButtons
onClick={(id) => onClick={(id) => {
updateCodeWindow( updateCodeWindow(context, { ...userPatterns[id], collection: userPattern.collection }, patternAutoStart);
context,
{ ...userPatterns[id], collection: userPattern.collection }, if (context.started && activePattern === id) {
autoResetPatternOnChange, context.handleEvaluate();
)
} }
}}
patterns={userPatterns} patterns={userPatterns}
started={context.started} started={context.started}
activePattern={activePattern} activePattern={activePattern}
@@ -188,17 +186,14 @@ function FeaturedPatterns({ context }) {
const examplePatterns = useExamplePatterns(); const examplePatterns = useExamplePatterns();
const collections = examplePatterns.collections; const collections = examplePatterns.collections;
const patterns = collections.get(patternFilterName.featured); const patterns = collections.get(patternFilterName.featured);
const { patternAutoStart } = useSettings();
return ( return (
<PatternPageWithPagination <PatternPageWithPagination
patterns={patterns} patterns={patterns}
context={context} context={context}
initialPage={featuredPageNum} initialPage={featuredPageNum}
patternOnClick={(id) => { patternOnClick={(id) => {
updateCodeWindow( updateCodeWindow(context, { ...patterns[id], collection: patternFilterName.featured }, patternAutoStart);
context,
{ ...patterns[id], collection: patternFilterName.featured },
autoResetPatternOnChange,
);
}} }}
paginationOnChange={async (pageNum) => { paginationOnChange={async (pageNum) => {
await loadAndSetFeaturedPatterns(pageNum - 1); await loadAndSetFeaturedPatterns(pageNum - 1);
@@ -213,13 +208,14 @@ function LatestPatterns({ context }) {
const examplePatterns = useExamplePatterns(); const examplePatterns = useExamplePatterns();
const collections = examplePatterns.collections; const collections = examplePatterns.collections;
const patterns = collections.get(patternFilterName.public); const patterns = collections.get(patternFilterName.public);
const { patternAutoStart } = useSettings();
return ( return (
<PatternPageWithPagination <PatternPageWithPagination
patterns={patterns} patterns={patterns}
context={context} context={context}
initialPage={latestPageNum} initialPage={latestPageNum}
patternOnClick={(id) => { patternOnClick={(id) => {
updateCodeWindow(context, { ...patterns[id], collection: patternFilterName.public }, autoResetPatternOnChange); updateCodeWindow(context, { ...patterns[id], collection: patternFilterName.public }, patternAutoStart);
}} }}
paginationOnChange={async (pageNum) => { paginationOnChange={async (pageNum) => {
await loadAndSetPublicPatterns(pageNum - 1); await loadAndSetPublicPatterns(pageNum - 1);
@@ -7,6 +7,8 @@ import { AudioDeviceSelector } from './AudioDeviceSelector.jsx';
import { AudioEngineTargetSelector } from './AudioEngineTargetSelector.jsx'; import { AudioEngineTargetSelector } from './AudioEngineTargetSelector.jsx';
import { confirmDialog } from '../../util.mjs'; import { confirmDialog } from '../../util.mjs';
import { DEFAULT_MAX_POLYPHONY, setMaxPolyphony, setMultiChannelOrbits } from '@strudel/webaudio'; import { DEFAULT_MAX_POLYPHONY, setMaxPolyphony, setMultiChannelOrbits } from '@strudel/webaudio';
import { ActionButton, SpecialActionButton } from '../button/action-button.jsx';
import { ImportPrebakeScriptButton } from './ImportPrebakeScriptButton.jsx';
function Checkbox({ label, value, onChange, disabled = false }) { function Checkbox({ label, value, onChange, disabled = false }) {
return ( return (
@@ -112,6 +114,8 @@ export function SettingsTab({ started }) {
multiChannelOrbits, multiChannelOrbits,
isTabIndentationEnabled, isTabIndentationEnabled,
isMultiCursorEnabled, isMultiCursorEnabled,
patternAutoStart,
includePrebakeScriptInShare,
} = useSettings(); } = useSettings();
const shouldAlwaysSync = isUdels(); const shouldAlwaysSync = isUdels();
const canChangeAudioDevice = AudioContext.prototype.setSinkId != null; const canChangeAudioDevice = AudioContext.prototype.setSinkId != null;
@@ -203,6 +207,15 @@ export function SettingsTab({ started }) {
/> />
</FormItem> </FormItem>
</div> </div>
<FormItem label="Prebake">
<ImportPrebakeScriptButton />
<Checkbox
label="Include prebake script in share"
onChange={(cbEvent) => settingsMap.setKey('includePrebakeScriptInShare', cbEvent.target.checked)}
value={includePrebakeScriptInShare}
/>
</FormItem>
<FormItem label="Keybindings"> <FormItem label="Keybindings">
<ButtonGroup <ButtonGroup
value={keybindings} value={keybindings}
@@ -304,11 +317,15 @@ export function SettingsTab({ started }) {
onChange={(cbEvent) => settingsMap.setKey('isCSSAnimationDisabled', cbEvent.target.checked)} onChange={(cbEvent) => settingsMap.setKey('isCSSAnimationDisabled', cbEvent.target.checked)}
value={isCSSAnimationDisabled} value={isCSSAnimationDisabled}
/> />
<Checkbox
label="Auto-start pattern on pattern change"
onChange={(cbEvent) => settingsMap.setKey('patternAutoStart', cbEvent.target.checked)}
value={patternAutoStart}
/>
</FormItem> </FormItem>
<FormItem label="Zen Mode">Try clicking the logo in the top left!</FormItem> <FormItem label="Zen Mode">Try clicking the logo in the top left!</FormItem>
<FormItem label="Reset Settings"> <FormItem label="Reset Settings">
<button <SpecialActionButton
className="bg-background p-2 max-w-[300px] rounded-md hover:opacity-50"
onClick={() => { onClick={() => {
confirmDialog('Sure?').then((r) => { confirmDialog('Sure?').then((r) => {
if (r) { if (r) {
@@ -319,7 +336,7 @@ export function SettingsTab({ started }) {
}} }}
> >
restore default settings restore default settings
</button> </SpecialActionButton>
</FormItem> </FormItem>
</div> </div>
); );
+37 -13
View File
@@ -14,6 +14,10 @@ import { prebake } from '@src/repl/prebake.mjs';
const getSamples = (samples) => const getSamples = (samples) =>
Array.isArray(samples) ? samples.length : typeof samples === 'object' ? Object.values(samples).length : 1; Array.isArray(samples) ? samples.length : typeof samples === 'object' ? Object.values(samples).length : 1;
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function SoundsTab() { export function SoundsTab() {
const sounds = useStore(soundMap); const sounds = useStore(soundMap);
@@ -56,17 +60,21 @@ export function SoundsTab() {
// holds mutable ref to current triggered sound // holds mutable ref to current triggered sound
const trigRef = useRef(); const trigRef = useRef();
const numRef = useRef(0);
// Used to cycle through sound previews on banks with multiple sounds
let soundPreviewIdx = 0;
// stop current sound on mouseup // stop current sound on mouseup
useEvent('mouseup', () => { useEvent('mouseup', () => {
const t = trigRef.current; const ref = trigRef.current;
trigRef.current = undefined; trigRef.current = undefined;
t?.then((ref) => { ref?.stop?.(getAudioContext().currentTime + 0.01);
ref?.stop(getAudioContext().currentTime + 0.01);
}); });
useEvent('keydown', (e) => {
if (!isNaN(Number(e.key))) {
numRef.current = Number(e.key);
}
});
useEvent('keyup', (e) => {
numRef.current = 0;
}); });
return ( return (
<div id="sounds-tab" className="px-4 flex gap-2 flex-col w-full h-full text-foreground"> <div id="sounds-tab" className="px-4 flex gap-2 flex-col w-full h-full text-foreground">
@@ -117,19 +125,35 @@ export function SoundsTab() {
const params = { const params = {
note: ['synth', 'soundfont'].includes(data.type) ? 'a3' : undefined, note: ['synth', 'soundfont'].includes(data.type) ? 'a3' : undefined,
s: name, s: name,
n: soundPreviewIdx, n: numRef.current,
clip: 1, clip: 1,
release: 0.5, release: 0.5,
sustain: 1, sustain: 1,
duration: 0.5, duration: 0.5,
}; };
soundPreviewIdx++;
const time = ctx.currentTime + 0.05;
const onended = () => trigRef.current?.node?.disconnect(); const onended = () => trigRef.current?.node?.disconnect();
trigRef.current = Promise.resolve(onTrigger(time, params, onended)); // Attempt to play the sample and retry every 200ms until 10 attempts have been reached
trigRef.current.then((ref) => { let errMsg;
connectToDestination(ref?.node); for (let attempt = 0; attempt < 10; attempt++) {
}); try {
// Pre-load the sample by calling onTrigger with a future time
// This triggers the loading but schedules playback for later
const time = ctx.currentTime + 0.05; // Give 50ms for loading
const ref = await onTrigger(time, params, onended);
trigRef.current = ref;
if (ref?.node) {
connectToDestination(ref.node);
break;
}
} catch (err) {
errMsg = err;
}
if (attempt == 9) {
console.warn('Failed to trigger sound after 10 attempts' + (errMsg ? `: ${errMsg}` : ''));
} else {
await wait(200);
}
}
}} }}
> >
{' '} {' '}
+1 -5
View File
@@ -92,11 +92,7 @@ export function registerSamplesFromDB(config = userSamplesDBConfig, onComplete =
async function blobToDataUrl(blob) { async function blobToDataUrl(blob) {
return new Promise((resolve) => { return new Promise((resolve) => {
var reader = new FileReader(); resolve(URL.createObjectURL(blob));
reader.onload = function (event) {
resolve(event.target.result);
};
reader.readAsDataURL(blob);
}); });
} }
+2
View File
@@ -3,6 +3,8 @@ import { aliasBank, registerSynthSounds, registerZZFXSounds, samples } from '@st
import { registerSamplesFromDB } from './idbutils.mjs'; import { registerSamplesFromDB } from './idbutils.mjs';
import './piano.mjs'; import './piano.mjs';
import './files.mjs'; import './files.mjs';
import { settingsMap } from '@src/settings.mjs';
import { evaluate } from '@strudel/transpiler';
const { BASE_URL } = import.meta.env; const { BASE_URL } = import.meta.env;
const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL; const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
+15 -5
View File
@@ -6,7 +6,7 @@ This program is free software: you can redistribute it and/or modify it under th
import { code2hash, getPerformanceTimeSeconds, logger, silence } from '@strudel/core'; import { code2hash, getPerformanceTimeSeconds, logger, silence } from '@strudel/core';
import { getDrawContext } from '@strudel/draw'; import { getDrawContext } from '@strudel/draw';
import { transpiler } from '@strudel/transpiler'; import { evaluate, transpiler } from '@strudel/transpiler';
import { import {
getAudioContextCurrentTime, getAudioContextCurrentTime,
webaudioOutput, webaudioOutput,
@@ -63,11 +63,10 @@ async function getModule(name) {
const initialCode = `// LOADING`; const initialCode = `// LOADING`;
export function useReplContext() { export function useReplContext() {
const { isSyncEnabled, audioEngineTarget } = useSettings(); const { isSyncEnabled, audioEngineTarget, prebakeScript, includePrebakeScriptInShare } = useSettings();
const shouldUseWebaudio = audioEngineTarget !== audioEngineTargets.osc; const shouldUseWebaudio = audioEngineTarget !== audioEngineTargets.osc;
const defaultOutput = shouldUseWebaudio ? webaudioOutput : superdirtOutput; const defaultOutput = shouldUseWebaudio ? webaudioOutput : superdirtOutput;
const getTime = shouldUseWebaudio ? getAudioContextCurrentTime : getPerformanceTimeSeconds; const getTime = shouldUseWebaudio ? getAudioContextCurrentTime : getPerformanceTimeSeconds;
const init = useCallback(() => { const init = useCallback(() => {
const drawTime = [-2, 2]; const drawTime = [-2, 2];
const drawContext = getDrawContext(); const drawContext = getDrawContext();
@@ -84,7 +83,12 @@ export function useReplContext() {
pattern: silence, pattern: silence,
drawTime, drawTime,
drawContext, drawContext,
prebake: async () => Promise.all([modulesLoading, presets]), prebake: async () =>
Promise.all([modulesLoading, presets]).then(() => {
if (prebakeScript?.length) {
return evaluate(prebakeScript ?? '');
}
}),
onUpdateState: (state) => { onUpdateState: (state) => {
setReplState({ ...state }); setReplState({ ...state });
}, },
@@ -214,7 +218,13 @@ export function useReplContext() {
editorRef.current.repl.evaluate(code); editorRef.current.repl.evaluate(code);
}; };
const handleShare = async () => shareCode(replState.code); const handleShare = async () => {
let code = replState.code;
if (includePrebakeScriptInShare) {
code = prebakeScript + '\n' + code;
}
shareCode(code);
};
const context = { const context = {
started, started,
pending, pending,
+5 -6
View File
@@ -1,11 +1,10 @@
import { evalScope, hash2code, logger } from '@strudel/core'; import { code2hash, evalScope, hash2code, logger } from '@strudel/core';
import { settingPatterns } from '../settings.mjs'; import { settingPatterns } from '../settings.mjs';
import { setVersionDefaults } from '@strudel/webaudio'; import { setVersionDefaults } from '@strudel/webaudio';
import { getMetadata } from '../metadata_parser'; import { getMetadata } from '../metadata_parser';
import { isTauri } from '../tauri.mjs'; import { isTauri } from '../tauri.mjs';
import './Repl.css'; import './Repl.css';
import { createClient } from '@supabase/supabase-js'; import { createClient } from '@supabase/supabase-js';
import { nanoid } from 'nanoid';
import { writeText } from '@tauri-apps/plugin-clipboard-manager'; import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { $featuredPatterns /* , loadDBPatterns */ } from '@src/user_pattern_utils.mjs'; import { $featuredPatterns /* , loadDBPatterns */ } from '@src/user_pattern_utils.mjs';
@@ -109,9 +108,8 @@ export function confirmDialog(msg) {
}); });
} }
let lastShared;
//RIP due to SPAM //RIP due to SPAM
// let lastShared;
// export async function shareCode(codeToShare) { // export async function shareCode(codeToShare) {
// // const codeToShare = activeCode || code; // // const codeToShare = activeCode || code;
// if (lastShared === codeToShare) { // if (lastShared === codeToShare) {
@@ -146,9 +144,10 @@ let lastShared;
// }); // });
// } // }
export async function shareCode() { export async function shareCode(codeToShare) {
try { try {
const shareUrl = window.location.href; const hash = '#' + code2hash(codeToShare);
const shareUrl = window.location.origin + window.location.pathname + hash;
if (isTauri()) { if (isTauri()) {
await writeText(shareUrl); await writeText(shareUrl);
} else { } else {
+10
View File
@@ -45,11 +45,13 @@ export const defaultSettings = {
isPanelOpen: true, isPanelOpen: true,
togglePanelTrigger: 'click', //click | hover togglePanelTrigger: 'click', //click | hover
userPatterns: '{}', userPatterns: '{}',
prebakeScript: '',
audioEngineTarget: audioEngineTargets.webaudio, audioEngineTarget: audioEngineTargets.webaudio,
isButtonRowHidden: false, isButtonRowHidden: false,
isCSSAnimationDisabled: false, isCSSAnimationDisabled: false,
maxPolyphony: 128, maxPolyphony: 128,
multiChannelOrbits: false, multiChannelOrbits: false,
includePrebakeScriptInShare: true,
}; };
let search = null; let search = null;
@@ -96,6 +98,12 @@ export function useSettings() {
isPanelOpen: parseBoolean(state.isPanelOpen), isPanelOpen: parseBoolean(state.isPanelOpen),
userPatterns: userPatterns, userPatterns: userPatterns,
multiChannelOrbits: parseBoolean(state.multiChannelOrbits), multiChannelOrbits: parseBoolean(state.multiChannelOrbits),
includePrebakeScriptInShare: parseBoolean(state.includePrebakeScriptInShare),
patternAutoStart: isUdels()
? false
: state.patternAutoStart === undefined
? true
: parseBoolean(state.patternAutoStart),
}; };
} }
@@ -103,6 +111,8 @@ export const setActiveFooter = (tab) => settingsMap.setKey('activeFooter', tab);
export const setPanelPinned = (bool) => settingsMap.setKey('isPanelPinned', bool); export const setPanelPinned = (bool) => settingsMap.setKey('isPanelPinned', bool);
export const setIsPanelOpened = (bool) => settingsMap.setKey('isPanelOpen', bool); export const setIsPanelOpened = (bool) => settingsMap.setKey('isPanelOpen', bool);
export const storePrebakeScript = (script) => settingsMap.setKey('prebakeScript', script);
export const setIsZen = (active) => settingsMap.setKey('isZen', !!active); export const setIsZen = (active) => settingsMap.setKey('isZen', !!active);
const patternSetting = (key) => const patternSetting = (key) =>