Compare commits

..

212 Commits

Author SHA1 Message Date
Felix Roos 126e45af9a move onTriggerTime back to pattern.mjs 2026-10-11 21:59:07 +02:00
Felix Roos a6943f1eeb minor renames 2026-10-11 21:23:01 +02:00
Felix Roos 4a0d9de664 rename repl defaultOutput to onSchedule, error when defaultOutput is
passed. also rename xScheduler to xOutput, to be consistent with
webaudioOutput and friends
2026-10-11 20:54:50 +02:00
Felix Roos 7f486556c6 add getClockTime to get current seconds on scheduler clock 2026-10-11 18:36:06 +02:00
Felix Roos 257787d38a Update pnpm-lock.yaml 2026-10-11 15:32:53 +02:00
Felix Roos 9eebf0ad21 Merge remote-tracking branch 'origin/main' into trigger-refactor 2026-10-11 15:23:01 +02:00
froos d97e91e928 Merge pull request 'Add cyclestruct function' (#2107) from tzwaan/cyclestruct into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2107
2026-10-11 13:44:26 +02:00
froos e78cc85ad7 Merge pull request 'document undocumented things' (#1996) from tyow/localstrudel:jsdocs into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1996
2026-10-11 13:26:54 +02:00
froos ee9138b514 Merge pull request 'fix: correct typos in soundfonts (gm.mjs) and docs (effects.mdx)' (#2134) from CatoPaus/strudel:catopaus-fix-soundfonts-typos into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2134
2026-10-11 13:13:20 +02:00
froos 0b786700d0 Merge pull request 'improve custom scale support and highlighting' (#2018) from tyow/localstrudel:xenscalethings into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2018
2026-10-11 12:26:44 +02:00
Felix Roos 076bb8a152 hotfix: more missing containers 2026-10-11 11:47:21 +02:00
Felix Roos 357af69052 Merge branch 'main' of https://codeberg.org/uzu/strudel 2026-10-11 11:43:01 +02:00
Felix Roos fd2626d11d hotfix: pin ci container (deploy is failing) 2026-10-11 11:42:38 +02:00
froos 2b5f31f605 Merge pull request 'Negative steps change euclid into euclidLegato and vice versa' (#2099) from tzwaan/euclid-legato-notation into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2099
2026-10-11 11:20:54 +02:00
froos c57c5a7c4d Merge pull request 'keep space reserved for the sidebars above 1280px (fixes #2110)' (#2111) from bwagner/strudel:fix/sidebar-overlap into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2111
2026-10-11 11:11:33 +02:00
Tijmen Zwaan c7d0f15826 Rename cyclestruct to isobite with synonyms and add some examples 2026-10-09 22:17:10 +02:00
Tijmen Zwaan 60ac02905d Add cyclestruct function 2026-10-09 21:13:23 +02:00
tyow 5148066b01 stackCenter/Centre changes, fix from merge, snapshot 2026-10-07 16:44:43 -04:00
tyow 29a4e30042 Merge main into jsdocs 2026-10-07 16:30:41 -04:00
Alex McLean 0a6d61e14a Merge pull request 'New FAQ entry: what's a hap?' (#2088) from scrappy_fiddler/strudel:main into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2088
2026-10-04 17:44:52 +02:00
CatoPaus 74de75644f fix(soundfonts): correct preset filename typos in gm.mjs
This PR fixes minor filename typos and invalid entries in `packages/soundfonts/gm.mjs`:
1. **`gm_electric_bass_finger`**: Corrected typo `'0330_FluidR3_GM_sf2_fible'` -> `'0330_FluidR3_GM_sf2_file'`. Index 1 previously returned HTTP 404 when cycling through bass soundfont variations.
2. **`gm_slap_bass_2`**: Removed accidental space `'0370_GeneralUserGS_sf2_fil e'` -> `'0370_GeneralUserGS_sf2_file'`.
3. **`gm_gunshot`**: Removed stray empty string `''` from the variations array.
2026-10-04 10:04:11 +02:00
scrappy_fiddler 6e50217a53 Merge branch 'main' into main 2026-10-03 14:24:28 +02:00
froos c57320a2de Merge pull request 'Add error handling to stack so non-broken patterns continue playing' (#2133) from tzwaan/stack-error-handling into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2133
2026-09-29 09:21:52 +02:00
Tijmen Zwaan a1be663b15 Add error handling to stack so non-broken patterns continue playing 2026-09-28 23:44:34 +02:00
CatoPaus 9c05f8ebc6 Update website/src/pages/learn/effects.mdx
Just a small typo.
2026-09-18 07:30:23 +02:00
scrappy_fiddler b2c2dfb0b4 Merge branch 'main' into main 2026-08-30 11:18:41 +02:00
scrappy_fiddler 38956e4d16 now with prettier FAQ 2026-08-30 11:17:51 +02:00
Tijmen Zwaan ecc7bc078c Fix formatting 2026-08-22 00:12:13 +02:00
Tijmen Zwaan 078aa60779 Allow euclid pulses > steps to stretch the rhythm over multiple cycles
Also fix a bug in euclidLegato where the rotation offset would be
applied to the whole source pattern, instead of the euclid structure.
2026-08-21 23:58:47 +02:00
Tijmen Zwaan d7384dfc3d Negative steps change euclid into euclidLegato and vice versa
Also fix that euclidLegato did not support negative pulses for inversion
2026-08-21 23:58:46 +02:00
Bernhard Wagner a3a38cfe33 keep space reserved for the sidebars above 1280px (fixes #2110)
At xl the wrapper dropped its left padding and relied on centering to clear the
fixed sidebars, which only works from about 1338px up. Reserving the right
gutter instead leaves md:pl-[300px] in force, so the column narrows between
1280px and 1338px rather than sliding under the nav.
2026-08-20 11:08:23 +02:00
Felix Roos 98e99e8166 Merge remote-tracking branch 'origin/main' into trigger-refactor 2026-08-19 21:33:43 +02:00
froos 8f81463b9c Merge pull request 'dough output' (#2108) from dough-output into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2108
2026-08-19 21:22:18 +02:00
Felix Roos 8120fc72ab dummy commit to force service worker refresh 2026-08-19 18:09:05 +02:00
Felix Roos d7fb9fe992 add onSchedule with simpler args to replace onTrigger 2026-08-19 18:07:55 +02:00
Felix Roos 37d524e639 Merge remote-tracking branch 'origin/main' into trigger-refactor 2026-08-18 22:25:11 +02:00
Felix Roos d65b24db8f rename old dough function to rawdsp + mock dough in test runtime 2026-08-18 20:44:35 +02:00
Felix Roos f1dd209749 fix ci check errors 2026-08-18 20:29:23 +02:00
Felix Roos d6f0b863f0 avoid "doughsamples" name collision with old supradough 2026-08-18 20:26:29 +02:00
Felix Roos 5dbbc7b9e1 basic dough integration with onTrigger 2026-08-18 13:19:47 +02:00
froos beb16f4040 Merge pull request 'better osc timing + clockbridge class' (#2106) from osc-timing into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2106
2026-08-18 13:06:07 +02:00
Felix Roos f2be5e4f5e refactor: new clockbridge class to fix midi and osc timing 2026-08-16 22:15:39 +02:00
froos c7db8eaad6 Merge pull request 'fix: accurate midi scheduling on chromium' (#2097) from midi-timing into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2097
2026-08-16 11:31:32 +02:00
Felix Roos 723cd5da04 lower noteOffsetMs on chromium + update comment 2026-08-16 10:59:10 +02:00
Felix Roos 98d19a6643 format 2026-08-16 10:21:31 +02:00
Felix Roos ef106b356f make smoothing rate independent (thanks freya) 2026-08-16 09:41:45 +02:00
Felix Roos ee460dc980 fix longer running midi-only drift with a dummy node 2026-08-15 17:10:18 +02:00
froos d5d6bc6f34 Merge pull request 'Don't run sample server if outputting JSON' (#2094) from muffinista/strudel:no-server-when-json-output into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2094
2026-08-14 00:47:58 +02:00
froos d9336aba3a Merge pull request 'Add target to remote links in reference section.' (#2089) from emily_s/strudel:add-target-to-remote-links into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2089
2026-08-14 00:22:47 +02:00
froos 263c9c0253 Merge pull request 'osc: remove bank+sound concatenation since SuperDirt does it now' (#2077) from pulu/strudel:remove-osc-bank-sound-concat into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2077
2026-08-13 23:40:54 +02:00
froos 6ecdc739b4 Merge pull request 'transpose: default octave' (#2033) from tyow/localstrudel:transpose-default-note into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2033
2026-08-13 23:35:07 +02:00
froos f67e81a03f Merge pull request 'mode-above-fix' (#2062) from eefano/strudel:mode-above-fix into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2062
2026-08-13 22:40:41 +02:00
froos 5cf7571b77 Merge branch 'main' into mode-above-fix 2026-08-13 22:37:20 +02:00
froos b2aeaa56c1 Merge pull request 'Fix falsy value check in getFreqeuncy' (#2054) from jouae/strudel:fix-falsy-check into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2054
2026-08-13 21:54:47 +02:00
froos 9e3792f6a6 Merge pull request 'Remove outdated continuation link' (#2053) from ethancrawford/strudel:update_csound_docs into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2053
2026-08-13 21:34:28 +02:00
froos f8ed9c3a7c Merge pull request 'Fix wav export on firefox' (#2101) from tzwaan/fix-firefox-export into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2101
2026-08-13 19:58:42 +02:00
Tijmen Zwaan c5b42b5555 Fix webAudioTimeout connecting the wrong AudioContext after export 2026-08-13 19:26:52 +02:00
Tijmen Zwaan 75f58bc875 Fix wav export on firefox 2026-08-13 19:26:05 +02:00
Alex McLean 3b037deb10 Merge pull request 'learn/lfx: Add missing word' (#2096) from wjt/strudel:lfo-missing-word into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2096
2026-08-13 08:52:30 +02:00
Alex McLean 362b2df390 Merge pull request 'Add tests for randrun and negative time test for run' (#2098) from tzwaan/add-randrun-test into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2098
2026-08-13 08:51:30 +02:00
Tijmen Zwaan 20d925c10c Add tests for randrun and negative time test for run 2026-08-13 04:13:28 +02:00
Alex McLean 91f37accdd Merge pull request 'Fix multiple cases where % was used instead of _mod()' (#2092) from tzwaan/saw-modulo-bug into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2092
2026-08-13 01:13:29 +02:00
Tijmen Zwaan 07f01c237f Add tests for slowcat, slowcatPrime and arp 2026-08-13 01:04:48 +02:00
Tijmen Zwaan 76fa8264cd Remove old check in slowcat and add empty check at the start 2026-08-13 01:04:25 +02:00
Tijmen Zwaan 08dc5e2c5c Add tests for all signals using modulo, including negative time 2026-08-13 01:03:14 +02:00
Tijmen Zwaan bd54c8003a Introduce isquare and isquare2 for signal parity 2026-08-13 01:00:56 +02:00
Tijmen Zwaan 9e946f1d14 Merge branch 'main' into saw-modulo-bug 2026-08-12 23:37:14 +02:00
Felix Roos f1a541caec fix: accurate midi scheduling on chromium 2026-08-12 20:48:01 +02:00
froos 47cdf4561d Merge pull request 'Improve wav render export performance' (#2093) from tzwaan/wav-export-performance into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2093
2026-08-12 13:48:59 +02:00
wjt 6c8b707d2e learn/lfx: Add missing word 2026-08-10 17:01:43 +02:00
Cass Mitchell adcbf9240e Don't run server if outputting JSON 2026-08-05 19:19:06 -04:00
Tijmen Zwaan f4b3fdaacd Update juxUndTollerei 1 tune snapshot 2026-08-05 00:39:38 +02:00
Tijmen Zwaan 9bf3249e82 slowcatPrime pat can no longer be undefined 2026-08-05 00:25:08 +02:00
Tijmen Zwaan 0a0afcf73f Change renderPatternAudio so it renders the audio in chunks 2026-08-04 23:29:01 +02:00
Tijmen Zwaan 6ebf506070 Fix node pool reusing incompatible audio nodes when audiocontext changes 2026-08-04 23:24:52 +02:00
Tijmen Zwaan eafa2f17ec Fix the same modulo bug in arp and slowcatPrime 2026-08-04 18:03:12 +02:00
Tijmen Zwaan 429682735d Formatting 2026-08-04 17:25:45 +02:00
Tijmen Zwaan 04232370c2 Apply the same fix to square and randrun 2026-08-04 17:11:52 +02:00
Tijmen Zwaan 4484ee4e80 Fix saw and isaw returning negative numbers on negative t 2026-08-04 16:52:47 +02:00
Emily S 57598f2aa1 formatting 2026-07-31 21:34:32 +01:00
Alex McLean ebc25467c0 Merge pull request 'Added some advice for creating a new project using strudel' (#2090) from name into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2090
2026-07-29 16:48:34 +02:00
alex a866cb2189 format 2026-07-29 15:06:56 +01:00
alex 890d69b23e tweaks from feedback 2026-07-29 12:25:30 +01:00
alex 8e187abf24 add copyright notice 2026-07-29 10:48:11 +01:00
alex 2d014b47c8 add advice for creating a new project to faq 2026-07-29 09:20:08 +01:00
alex daafee7527 advice for creating a new project using strudel 2026-07-29 09:11:41 +01:00
yaxu 692170e117 Update website/src/pages/learn/faq.mdx 2026-07-27 16:55:41 +02:00
Emily S c9522a4e49 add target to remote links 2026-07-24 08:39:07 +01:00
scrappy_fiddler 14ac26fa45 what's a hap? 2026-07-23 09:17:17 +02:00
tyow 704018f774 preform/perform, mod 2026-07-21 22:48:28 -04:00
miranda 25d1179985 osc: remove bank+sound concatenation since SuperDirt does it now 2026-06-27 16:09:15 +03:00
yaxu c150752372 revert 85e6d436ef
revert motors workshop page
(committed to main by mistake!)
2026-06-21 20:47:04 +02:00
Alex McLean e248bf85f3 Merge pull request 'Creates a pattern of numbers in base x from a number or pattern of numbers @stretchyboy' (#2075) from stretchyboy-base-function into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2075
2026-06-21 20:45:28 +02:00
Alex McLean 6c51c0261a Merge branch 'main' into stretchyboy-base-function 2026-06-21 20:45:05 +02:00
Felix Roos deefde7b50 hotfix: codeformat 2026-06-21 15:41:50 +02:00
froos d4f63e8de3 Merge pull request 'fix: cache bunny cdn urls #2057' (#2076) from cache-bunny into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2076
2026-06-21 15:29:56 +02:00
alex 19cb3dedc2 snapshot 2026-06-21 14:02:34 +01:00
alex a7c3407da7 codeformat 2026-06-21 13:54:38 +01:00
Martyn Eggleton 6870b04fb2 Pretty code now 2026-06-21 13:49:11 +01:00
Alex McLean 2b2646a768 Merge branch 'main' into base-function 2026-06-21 14:34:18 +02:00
alex 97ef5bc335 Merge branch 'main' of ssh://codeberg.org/uzu/strudel 2026-06-19 21:30:17 +01:00
alex 85e6d436ef motors workshop page 2026-06-19 21:29:58 +01:00
alex d3e2b7c7b4 motors workshop page 2026-06-19 21:29:49 +01:00
alex def1738259 shortcuts for workshop 2026-06-19 21:29:25 +01:00
Alex McLean 0feeb1e701 Merge pull request 'Add growlist' (#2073) from growlist into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2073
2026-06-18 13:25:38 +02:00
alex 813cc6c190 format 2026-06-18 12:04:03 +01:00
alex 62d4f84e69 add growlist 2026-06-18 10:55:34 +01:00
stretchyboy 49a1e11cd8 Merge branch 'main' into base-function 2026-06-17 13:58:30 +02:00
Felix Roos 941c97da0d fix: cache bunny cdn urls #2057 2026-06-15 09:15:06 +02:00
Alex McLean 95a9d301a1 Merge pull request 'midikeys-with-channel' (#2063) from eefano/strudel:midikeys-with-channel into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2063
2026-06-10 13:38:02 +02:00
eefano e38811d325 oldabove and oldroot, documentation 2026-06-08 21:09:23 +02:00
Martyn Eggleton 475f17ddfd base now accepts Arrays which improve compatibility with rockstar-strudel 2026-06-03 21:56:26 +01:00
Martyn Eggleton bd68c6a0a7 Documentation tweak 2026-06-03 12:36:41 +01:00
eefano 2635716c87 added midikeys third test 2026-06-02 14:56:55 +02:00
eefano 58f956b57e prettier fix 2026-06-02 14:29:28 +02:00
eefano 0ae2c120b3 updated documentation for midikeys 2026-06-02 13:28:23 +02:00
eefano 05a43ef687 included midichan value in midikeys haps 2026-06-02 13:14:30 +02:00
Martyn Eggleton f21aeb55bd The base param can be a pattern. We also add a max number of digits to produce for each n 2026-06-01 14:02:20 +01:00
eefano 422b03699a fixes 'above' and 'root' modes in renderVoicing() 2026-06-01 02:22:03 +02:00
eefano cb046a3548 fixes 'above' and 'root' modes in renderVoiciing() 2026-06-01 02:16:48 +02:00
Martyn Eggleton d83139980b Creates a pattern of numbers in base x from a number or pattern of numbers 2026-05-27 21:23:55 +01:00
Alex McLean 0c61cd7670 Merge pull request 'Don't force SSL for mqtt websocket connections' (#2058) from mqtt-ws into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2058
2026-05-27 13:35:28 +02:00
alex 13b11efa15 don't force SSL for mqtt websocket connections - fall back on url instead (wss: for SSL, ws: for unencrypted) 2026-05-26 16:11:33 +01:00
jouae fe0be6bc53 Fix falsy value check in getFreqeuncy
|| operator, which causes 0 to be treated as falsy and thus not passed
to getFreq, resulting in incorrect frequency calculation for MIDI note
0.

Replace || with ?? to allow 0 to be correctly passed to getFreq.
2026-05-17 11:49:09 +08:00
Ethan Crawford 4747fab4ed Remove outdated continuation link
Previously, the docs were designed to work as a series of consecutively read
documents.
This linear flow is no longer intended, and as such the link at the end of the
csound document has become obsolete. This commit removes it, as suggested by a
member of the dev team.
2026-05-15 17:24:10 +08:00
froos f73b395648 Merge pull request 'Add error for when name arg in register is a pattern' (#2020) from tyow/localstrudel:registerErrorFix into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2020
2026-05-07 11:46:59 +02:00
froos 9c47efd3cf Merge pull request 'docs: note that all/each require labeled patterns' (#2015) from bwagner/strudel:fix/all-unlabeled-pattern-docs into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2015
2026-05-07 11:28:31 +02:00
froos f4066089f4 Merge pull request 'Add flux function' (#1528) from TodePond/strudel:lu/flux into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1528
2026-05-07 10:51:57 +02:00
Felix Roos 0e81987c4e Merge remote-tracking branch 'origin/main' into lu/flux 2026-05-07 10:36:11 +02:00
Felix Roos 23dbe53013 add snapshots 2026-05-07 10:32:58 +02:00
froos 0e4d0484a3 Merge pull request 'Fix the documentation of stretch' (#1670) from vvolhejn/strudel:vv/fix-stretch-docs into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1670
2026-05-07 09:32:37 +02:00
froos 37bf2e4dc7 Merge pull request 'Added .dockerignore' (#2005) from KuboOrk/strudel:docker/add-dockerignore into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2005
2026-05-07 00:45:30 +02:00
froos e69761397e Merge pull request 'fix midi numbers as notes for osc backend' (#2001) from jagen31/strudel:jagen-midinote-osc into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2001
2026-05-07 00:20:41 +02:00
froos 10c33f24f5 Merge pull request 'LFO documentation' (#2000) from scrappy_fiddler/strudel:lfo_docs into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2000
2026-05-07 00:11:54 +02:00
Felix Roos ea0ec7edb9 fix format 2026-05-06 22:14:11 +02:00
froos f900642359 cross-reference signals page 2026-05-06 22:01:58 +02:00
froos 4cc63b8b01 Merge pull request 'fixed bug where withBase would silence a pattern if freq was unset' (#1994) from tyow/localstrudel:withbase-fix into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1994
2026-05-06 21:29:34 +02:00
tyow d5171e2ab7 named things 2026-04-29 15:35:05 -04:00
tyow 81257b5f06 lt,gt,lte,etc. 2026-04-29 15:30:32 -04:00
Alex McLean 26832c0fe7 Merge pull request 'send manual deploy to cold.strudel.cc, and autodeploy to strudel.cc' (#2037) from move-domains into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2037
2026-04-05 12:42:49 +02:00
tyow 896ec4c643 edgecase 2026-04-01 19:09:45 -04:00
tyow 1723125099 bugfix: transpose now has default octave of 3 2026-04-01 18:51:59 -04:00
tyow 0d73c02314 format 2026-03-26 01:10:42 -04:00
tyow 3dff15eab0 readded chooseIn due to mondough using it 2026-03-25 15:36:26 -04:00
tyow 8f1a0840c0 edit dry tags to add to reference 2026-03-25 14:07:10 -04:00
tyow ae32743f12 format 2026-03-13 14:11:38 -04:00
tyow 7294cd5443 Add error for when name arg in register is a pattern 2026-03-13 14:11:19 -04:00
tyow 948f78da11 wording 2026-03-12 12:23:30 -04:00
tyow 08eaa8a578 more cleanup 2026-03-11 18:04:29 -04:00
tyow aeeaa50071 cleanup 2026-03-11 18:03:50 -04:00
tyow 91681983e2 wording 2026-03-11 17:32:14 -04:00
tyow 6df34625c3 chooseInWith examples, other fixes 2026-03-11 17:31:19 -04:00
tyow 8ad767363f choosein tag 2026-03-11 15:54:42 -04:00
tyow 581bbc05fc room isn't a 0-1 value 2026-03-11 15:24:30 -04:00
tyow a58711a311 bugfix 2026-03-11 14:40:52 -04:00
tyow 4596926ac2 support old functionality 2026-03-11 14:16:26 -04:00
tyow f2acdc580f fix edo bug 2026-03-11 14:01:57 -04:00
tyow beee2bc921 refactor 2026-03-11 13:53:24 -04:00
tyow b586d8c4c4 add notes on missing functionality 2026-03-10 19:39:08 -04:00
tyow c901ffb37f example, deprecate tune, swap xen.mdx sections 2026-03-10 19:24:59 -04:00
tyow c0a43a20dc complete parity with current implementation 2026-03-10 19:02:25 -04:00
tyow fa4d060ed2 add todo 2026-03-10 18:25:44 -04:00
tyow 25bf6856ae format, snapshot 2026-03-10 18:25:01 -04:00
tyow f769b9c7a5 It looks good in repl and bad on test 2026-03-10 18:11:34 -04:00
tyow 7c7da3e783 progress 2026-03-10 18:01:56 -04:00
tyow 3e5c945e12 added thoughts. 2026-03-10 15:00:39 -04:00
tyow a1ccb9c0bb working stack for named scales 2026-03-10 14:39:25 -04:00
tyow 7510cb715f fixed prev bug. next: support stack 2026-03-10 00:41:23 -04:00
tyow 3554a544d6 erroring out 2026-03-10 00:18:34 -04:00
tyow a161d5a5d3 comment in jisc3 2026-03-09 19:44:36 -04:00
tyow 02e0c43f77 thoughts 2026-03-09 19:39:27 -04:00
tyow 814510f637 init 2026-03-09 19:32:28 -04:00
Bernhard Wagner cdae661b24 docs: note that all/each require labeled patterns 2026-03-09 11:15:14 +01:00
tyow e502a3c3e5 change bus tags s.t. it appears in reference 2026-03-08 19:06:49 -04:00
TodePond 63f42faca8 Update packages/core/pattern.mjs 2026-03-03 11:27:05 +01:00
tyow 1ef39a2ea7 small fixes 2026-03-01 12:42:13 -05:00
Václav Volhejn 2624b97500 Merge branch 'main' of ssh://codeberg.org/vvolhejn/strudel into vv/fix-stretch-docs 2026-03-01 11:15:28 +01:00
Václav Volhejn 633b7287c0 Merge branch 'main' of ssh://codeberg.org/vvolhejn/strudel into vv/fix-stretch-docs 2026-03-01 11:13:14 +01:00
tyow ee4b8fa315 cleanup 2026-02-28 14:46:01 -05:00
tyow 36a456eeb4 Update replicate and extend examples to show difference 2026-02-28 14:43:04 -05:00
KuboOrk 3805ba8f76 Added .dockerignore 2026-02-28 13:39:07 +03:00
tyow 37d052a655 Change "tidal" to "strudel" in docs 2026-02-24 01:51:38 -05:00
tyow 44ec79182e added examples to withHap and withHaps 2026-02-23 20:06:38 -05:00
tyow e5a08091f1 format 2026-02-23 17:21:57 -05:00
tyow 70213a26ae added some examples to things 2026-02-23 17:21:27 -05:00
tyow aedd7de2df getDur doc fix 2026-02-23 17:00:29 -05:00
jagen31 82e88d4a28 fix midi numbers as notes for osc backend
note -> midinote
2026-02-23 01:24:53 +01:00
scrappy_fiddler 5cd45d5ea1 typo in lfo docs 2026-02-23 00:22:30 +01:00
scrappy_fiddler d930057000 lfo documentation 2026-02-23 00:18:42 +01:00
scrappy_fiddler daf27ed971 added lfo documentation 2026-02-23 00:11:17 +01:00
tyow 4d0a4902ab enable stack variants to be chained 2026-02-22 17:42:09 -05:00
tyow 58e6b0543f add stackRepeat (already supported, just not exposed to the user) 2026-02-22 17:37:57 -05:00
tyow dfb2335b1c document stackRight, Left, Centre/Center 2026-02-22 17:28:32 -05:00
tyow 53cce1d97b revert undocumented.json 2026-02-22 13:35:10 -05:00
tyow a76a23a43d replace a missing sample with an existing one in example 2026-02-21 23:37:00 -05:00
tyow 787adfb20f format and snapshot 2026-02-21 23:16:15 -05:00
tyow d4ec7e9a55 modify degradeByWith to be more user friendly, add doc 2026-02-21 23:15:45 -05:00
tyow 10b0e30057 a bunch more docs 2026-02-21 22:13:46 -05:00
tyow c2572f4fad deprecate chooseIn 2026-02-21 21:00:26 -05:00
tyow 4f7a0f76d3 make getFreq and getFrequency synonyms and add doc 2026-02-21 20:49:37 -05:00
tyow 88d7868e12 document some math funcs 2026-02-21 20:25:20 -05:00
tyow 8520044f3e fixed bug where withBase would silence a pattern if freq was unset 2026-02-20 13:10:16 -05:00
Václav Volhejn 7c22b29493 Merge branch 'main' of ssh://codeberg.org/vvolhejn/strudel into vv/fix-stretch-docs 2026-01-10 06:34:34 -08:00
Lu[ke] Wilson c77675c492 add another synonym 2025-11-29 18:01:27 +00:00
Lu[ke] Wilson 5197af1aa4 Merge remote-tracking branch 'origin/main' into lu/flux 2025-11-29 17:58:41 +00:00
froos 85f6472894 Merge branch 'main' into lu/flux 2025-10-28 23:53:44 +01:00
Václav Volhejn 54fd877be1 Update snapshot 2025-10-18 13:19:18 +02:00
Václav Volhejn 7d948fd1a7 Fix example snapshot 2025-10-18 12:25:15 +02:00
Václav Volhejn 3373fa39a6 Fix the documentation of stretch 2025-10-18 12:14:39 +02:00
froos 1de8123437 Merge branch 'main' into lu/flux 2025-09-14 01:12:28 +02:00
Lu[ke] Wilson 781c477078 rename to juxFlip 2025-08-29 17:20:18 +01:00
Lu[ke] Wilson e2cdce5ac9 add flux function 2025-08-29 15:35:41 +01:00
87 changed files with 3800 additions and 553 deletions
+11
View File
@@ -0,0 +1,11 @@
**/node_modules
**/.angular
**/dist
.astro/
.forgejo/
.idea/
.vscode/
test/
.gitignore
Dockerfile
.dockerignore
+3 -2
View File
@@ -13,10 +13,11 @@ jobs:
build:
if: ${{ github.event.label.name == 'serve-hot' }}
runs-on: docker
container: node:22-bookworm
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
ref: ${{ github.event.pull_request.head.sha }}
- uses: pnpm/action-setup@v4
with:
version: 9.12.2
@@ -41,4 +42,4 @@ jobs:
ssh-keyscan matrix.toplap.org > ~/.ssh/known_hosts
rsync -atv --delete --delete-after --progress \
./website/dist/ \
strudel@matrix.toplap.org:/home/strudel/deploy/pr-${{ github.event.pull_request.number }}.hot.strudel.cc
strudel@matrix.toplap.org:/home/strudel/deploy/pr-${{ github.event.pull_request.number }}.hot.strudel.cc
+2 -1
View File
@@ -13,6 +13,7 @@ concurrency:
jobs:
build:
runs-on: docker
container: node:22-bookworm
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
steps:
@@ -37,4 +38,4 @@ jobs:
apt update && apt install -y rsync
mkdir ~/.ssh
ssh-keyscan matrix.toplap.org > ~/.ssh/known_hosts
rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy/strudel.cc
rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy/strudel.cc
+2 -1
View File
@@ -10,6 +10,7 @@ concurrency:
jobs:
build:
runs-on: docker
container: node:22-bookworm
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
steps:
@@ -34,4 +35,4 @@ jobs:
apt update && apt install -y rsync
mkdir ~/.ssh
ssh-keyscan matrix.toplap.org > ~/.ssh/known_hosts
rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy/cold.strudel.cc
rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy/cold.strudel.cc
+1
View File
@@ -5,6 +5,7 @@ on: [push, pull_request]
jobs:
build:
runs-on: docker
container: node:22-bookworm
strategy:
matrix:
node-version: [20]
+11 -1
View File
@@ -52,7 +52,17 @@ Strudel is a project handmade by humans, with thought and nuance.
If you have used LLMs (so called 'AI'), please detail that in the pull request. We are still developing our response to the onslaught of LLM technology, but for practical and legal reasons are currently not accepting wholly LLM-generated code. We are also not accepting PRs that add LLM features to strudel itself.
There are #llm-chat and #llm-share channels on our discord. Please do not discuss or share LLM-related things outside of those channels.
There are #llm-chat and #llm-share channels on [our discord](https://discord.com/invite/HGEdXmRkzT). Please do not discuss or share LLM-related things outside of those channels.
## Creating and sharing a new project using strudel
Strudel is free/open source software, and we are also happy to see people making use of it within the following terms.
Please don't use 'strudel' in the name of your project, so people don't assume it's official strudel project. (If you'd like it to be an official strudel project, please check in with the community, e.g. on [the discord](https://discord.com/invite/HGEdXmRkzT).)
Please respect our AGPL license, which e.g. requires you to share/link to the source code of strudel, any modifications you've made to it, and the source code for the rest of your project if it integrates with strudel. You are also required to maintain Strudel's copyright notices in the source code, and include Strudel's copyright notice in your user interface. This is an ad-hoc summary - please [refer to the license](https://codeberg.org/uzu/strudel/src/branch/main/LICENSE) for full details.
You are also encouraged to connect with the community and understand our aims and values.
## Report a Bug
+1 -1
View File
@@ -16,7 +16,7 @@ const drawContext = canvas.getContext('2d');
const drawTime = [-2, 2]; // time window of drawn haps
const editor = new StrudelMirror({
defaultOutput: webaudioOutput,
onSchedule: webaudioOutput,
getTime: () => getAudioContext().currentTime,
transpiler,
root: document.getElementById('editor'),
+1 -1
View File
@@ -12,7 +12,7 @@ registerSynthSounds();
evalScope(import('@strudel/core'), import('@strudel/mini'), import('@strudel/webaudio'), import('@strudel/tonal'));
const { evaluate } = repl({
defaultOutput: webaudioOutput,
onSchedule: webaudioOutput,
getTime: () => ctx.currentTime,
transpiler,
});
+21 -11
View File
@@ -118,7 +118,7 @@ export const { s, sound } = registerControl(['s', 'n', 'gain'], 'sound');
* @param {number | Pattern} position Position in the wavetable from 0 to 1
* @synonyms wavetablePosition
* @example
* s("squelch").bank("wt_digital").seg(8).note("F1").wt("0 0.25 0.5 0.75 1")
* s("wt_digital").seg(8).note("F1").wt("0 0.25 0.5 0.75 1")
*/
export const { wt, wavetablePosition } = registerControl('wt', 'wavetablePosition');
@@ -1988,11 +1988,10 @@ export const { spread } = registerControl('spread');
* Set dryness of reverb. See `room` and `size` for more information about reverb.
*
* @name dry
* @tags superdirt
* @tags superdirt, superdough
* @param {number | Pattern} dry 0 = wet, 1 = dry
* @example
* n("[0,3,7](3,8)").s("superpiano").room(.7).dry("<0 .5 .75 1>").osc()
* @superdirtOnly
*
*/
export const { dry } = registerControl('dry');
@@ -2226,7 +2225,7 @@ export const { orbit } = registerControl('orbit', 'o');
* otherPat.bmod(..) (to modulate another pattern with the bus)
*
* @name bus
* @tags superdirt
* @tags superdough
* @param {number | Pattern} number
*/
export const { bus } = registerControl('bus');
@@ -2235,7 +2234,7 @@ export const { bus } = registerControl('bus');
* Postgain multiplier prior to sending the signal to the audio bus.
*
* @name busgain
* @tags superdirt
* @tags superdough
* @synonyms bgain
* @param {number | Pattern} number
*/
@@ -2352,11 +2351,18 @@ export const { offset } = registerControl('offset');
**/
export const { octaves } = registerControl('octaves');
/**
* Remove anchor note from the voicing. Useful for melody harmonization
* How the voicing is aligned to the anchor
* - `below`: top note <= anchor
* - `duck`: top note <= anchor, anchor excluded
* - `above`: bottom note >= anchor
* - `root`: bottom note is the lowest root of the chord >= anchor
*
* - `oldabove` : old (buggy) behavior of above, kept for legacy reason
* - `oldroot` : old (buggy) behavior of root, kept for legacy reason
*
* @name mode
* @tags tonal
* @param {string | Pattern} modeName one of {below | above | duck | root}
* @param {string | Pattern} modeName one of {below | above | duck | root | oldabove | oldroot}
* @example
* mode("<below above duck root>").chord("C").voicing()
*
@@ -2370,7 +2376,7 @@ export const { mode } = registerControl(['mode', 'anchor']);
*
* @name room
* @tags orbit, superdough
* @param {number | Pattern} level between 0 and 1
* @param {number | Pattern} level
* @example
* s("bd sd [~ bd] sd").room("<0 .2 .4 .6 .8 1>")
* @example
@@ -2582,13 +2588,17 @@ export const { compressorRelease } = registerControl('compressorRelease');
export const { speed } = registerControl('speed');
/**
* Changes the speed of sample playback, i.e. a cheap way of changing pitch.
* Changes the pitch of the sample without changing its speed.
* The frequencies are multiplied by (factor + 1) for positive numbers
* and by max(factor / 4 + 1, 0) for negative numbers.
* So tuning up by octaves can be done with 1, 3, 7, ...
* and tuning down by octaves with -2, -3, -3.5...
*
* @name stretch
* @tags pitch, samples
* @param {number | Pattern} factor -inf to inf, negative numbers play the sample backwards.
* @param {number | Pattern} factor between `-4` and `inf`. Positive increases pitch, 0 does nothing, negative decreases the pitch.
* @example
* s("gm_flute").stretch("1 2 .5")
* s("gm_flute").stretch("<2 1 0 -2>")
*
*/
export const { stretch } = registerControl('stretch');
+7 -6
View File
@@ -10,6 +10,7 @@ import { errorLogger, logger } from './logger.mjs';
export class Cyclist {
constructor({
interval,
onSchedule,
onTrigger,
onToggle,
onError,
@@ -19,6 +20,11 @@ export class Cyclist {
clearInterval,
beforeStart,
}) {
if (onTrigger) {
throw new Error(
'onTrigger has been dropped. use onSchedule(hap, time, cps) instead, see https://codeberg.org/uzu/strudel/pulls/2138',
);
}
this.started = false;
this.beforeStart = beforeStart;
this.cps = 0.5;
@@ -63,12 +69,7 @@ export class Cyclist {
if (hap.hasOnset()) {
const targetTime =
(hap.whole.begin - this.num_cycles_at_cps_change) / this.cps + this.seconds_at_cps_change + latency;
const duration = hap.duration / this.cps;
// the following line is dumb and only here for backwards compatibility
// see https://codeberg.org/uzu/strudel/pulls/1004
const deadline = targetTime - phase;
// this onTrigger has another signature
onTrigger?.(hap, deadline, duration, this.cps, targetTime);
onSchedule?.(hap, targetTime, this.cps);
if (hap.value.cps !== undefined && this.cps != hap.value.cps) {
this.cps = hap.value.cps;
this.num_ticks_since_cps_change = 0;
+45 -19
View File
@@ -59,6 +59,11 @@ export const bjorklund = function (ons, steps) {
* really useful for computer/algorithmic music because they can
* describe a large number of rhythms with a couple of numbers.
*
* A negative number of pulses inverts the pattern
* so pulses happen in the gaps of the normal euclidean rhythm.
*
* A negative number of steps changes it into `euclidLegato`.
*
* @memberof Pattern
* @name euclid
* @tags temporal
@@ -137,8 +142,38 @@ const _euclidRot = function (pulses, steps, rotation) {
return b;
};
const _euclidLegato = function (pulses, steps, rotation, pat) {
var bin_pat = _euclidRot(pulses, steps, 0);
// Because the `gapless` algorithm logic assumes the first entry in bin_pat is a 1,
// we rotate the pattern so the first onset is at the start.
const firstOnset = bin_pat.indexOf(1);
bin_pat = rotate(bin_pat, firstOnset);
steps = Math.abs(steps);
const gapless = bin_pat
.join('')
.split('1')
.slice(1)
.map((s) => [s.length + 1, true]);
return pat.struct(timeCat(...gapless).late(Fraction(rotation + firstOnset).div(steps)));
};
const _euclid = function (pulses, steps, rotation, pat) {
if (steps === 0 || pulses === 0) {
return silence;
}
const cycles = Math.ceil(Math.abs(pulses) / Math.abs(steps));
const totalSteps = cycles * steps;
if (totalSteps < 0) {
return _euclidLegato(pulses, -totalSteps, rotation, pat.fast(cycles)).slow(cycles);
}
return pat
.fast(cycles)
.struct(_euclidRot(pulses, totalSteps, rotation))
.slow(cycles);
};
export const euclid = register('euclid', function (pulses, steps, pat) {
return pat.struct(_euclidRot(pulses, steps, 0));
return _euclid(pulses, steps, 0, pat);
});
export const bjork = register('bjork', function (euc, pat) {
@@ -146,16 +181,18 @@ export const bjork = register('bjork', function (euc, pat) {
euc = [euc];
}
const [pulses, steps = pulses, rot = 0] = euc;
return pat.struct(_euclidRot(pulses, steps, rot));
return _euclid(pulses, steps, rot, pat);
});
export const { euclidrot, euclidRot } = register(['euclidrot', 'euclidRot'], function (pulses, steps, rotation, pat) {
return pat.struct(_euclidRot(pulses, steps, rotation));
return _euclid(pulses, steps, rotation, pat);
});
/**
* Similar to `euclid`, but each pulse is held until the next pulse,
* so there will be no gaps.
*
* A negative number of steps changes it into `euclid`.
* @name euclidLegato
* @memberof Pattern
* @tags temporal
@@ -167,27 +204,16 @@ export const { euclidrot, euclidRot } = register(['euclidrot', 'euclidRot'], fun
* note("c3").euclidLegato(3,8)
*/
const _euclidLegato = function (pulses, steps, rotation, pat) {
if (pulses < 1) {
return silence;
}
const bin_pat = _euclidRot(pulses, steps, 0);
const gapless = bin_pat
.join('')
.split('1')
.slice(1)
.map((s) => [s.length + 1, true]);
return pat.struct(timeCat(...gapless)).late(Fraction(rotation).div(steps));
};
export const euclidLegato = register(['euclidLegato'], function (pulses, steps, pat) {
return _euclidLegato(pulses, steps, 0, pat);
return _euclid(pulses, -steps, 0, pat);
});
/**
* Similar to `euclid`, but each pulse is held until the next pulse,
* so there will be no gaps, and has an additional parameter for 'rotating'
* the resulting sequence
* the resulting sequence.
*
* A negative number of steps changes it into `euclidRot`.
* @name euclidLegatoRot
* @memberof Pattern
* @tags temporal
@@ -198,7 +224,7 @@ export const euclidLegato = register(['euclidLegato'], function (pulses, steps,
* note("c3").euclidLegatoRot(3,5,2)
*/
export const euclidLegatoRot = register(['euclidLegatoRot'], function (pulses, steps, rotation, pat) {
return _euclidLegato(pulses, steps, rotation, pat);
return _euclid(pulses, -steps, rotation, pat);
});
/**
+7 -3
View File
@@ -8,7 +8,12 @@ import { logger } from './logger.mjs';
import { ClockCollator, cycleToSeconds } from './util.mjs';
export class NeoCyclist {
constructor({ onTrigger, onToggle, getTime }) {
constructor({ onSchedule, onToggle, getTime, onTrigger }) {
if (onTrigger) {
throw new Error(
'onTrigger has been dropped. use onSchedule(hap, time, cps) instead, see https://codeberg.org/uzu/strudel/pulls/2138',
);
}
this.started = false;
this.cps = 0.5;
this.getTime = getTime; // get absolute time
@@ -43,8 +48,7 @@ export class NeoCyclist {
if (hap.hasOnset()) {
const timeUntilTrigger = cycleToSeconds(hap.whole.begin - this.cycle, this.cps);
const targetTime = timeUntilTrigger + currentTime + this.latency;
const duration = cycleToSeconds(hap.duration, this.cps);
onTrigger?.(hap, 0, duration, this.cps, targetTime);
onSchedule?.(hap, targetTime, this.cps);
}
});
};
+507 -49
View File
@@ -27,6 +27,7 @@ import {
import drawLine from './drawLine.mjs';
import { errorLogger, logger } from './logger.mjs';
import { strudelScope } from './evaluate.mjs';
import { getClockTime } from './schedulerState.mjs';
let stringParser;
@@ -504,10 +505,25 @@ export class Pattern {
/**
* Returns a new pattern with the given function applied to the list of haps returned by every query.
*
* As an end user, you will most likely not need to use this; look at `withContext`, `withHapSpan`, etc. instead.
* @tags internals
* @param {Function} func
* @returns Pattern
* @noAutocomplete
* @example
* note("[a b, c d] a b c d").s("gm_piano").withHaps(x=>{
* if (x.length > 1) {
* x = x.map(hap => hap.withValue(val => {
* return {
* n: 1,
* // select gm_piano:1 sample
* ...val
* }
* }));
* }
* return x
* })
*/
withHaps(func) {
const result = new Pattern((state) => func(this.query(state), state));
@@ -517,10 +533,23 @@ export class Pattern {
/**
* As with `withHaps`, but applies the function to every hap, rather than every list of haps.
*
* As an end user, you will most likely not need to use this; look at `withContext`, `withHapSpan`, etc. instead.
* @tags internals
* @param {Function} func
* @returns Pattern
* @noAutocomplete
* @example
* note("c a f e").withHap(x=>{
* if (x.value.note == 'c') {
* x.value.s = 'gm_piano'
* }
* else {
* x.value.s = 'gm_piano'
* x.value.n = 2
* }
* return x
* })
*/
withHap(func) {
return this.withHaps((haps) => haps.map(func));
@@ -850,6 +879,26 @@ export class Pattern {
return stack(this, ...pats);
}
stackLeft(...pats) {
return stackLeft(this, ...pats);
}
stackRight(...pats) {
return stackRight(this, ...pats);
}
stackCenter(...pats) {
return stackCenter(this, ...pats);
}
stackCentre(...pats) {
return stackCentre(this, ...pats);
}
stackRepeat(...pats) {
return stackRepeat(this, ...pats);
}
sequence(...pats) {
return sequence(this, ...pats);
}
@@ -872,23 +921,41 @@ export class Pattern {
//////////////////////////////////////////////////////////////////////
// Context methods - ones that deal with metadata
onTrigger(onTrigger, dominant = true) {
/**
* Attaches a function that is called for each hap onset.
* @name onSchedule
* @memberof Pattern
* @example
* s("bd sd").onSchedule((hap, time, cps) => {
* console.log('hap scheduled', time, cps);
* })
*/
onSchedule(onSchedule, dominant = true) {
return this.withHap((hap) =>
hap.setContext({
...hap.context,
onTrigger: (...args) => {
onSchedule: (scheduleHap, t, cps) => {
// run previously set trigger, if it exists
hap.context.onTrigger?.(...args);
onTrigger(...args);
hap.context.onSchedule?.(scheduleHap, t, cps);
onSchedule(scheduleHap, t, cps);
},
// if dominantTrigger is set to true, the default output (webaudio) will be disabled
// if dominantSchedule is set to true, the default output (webaudio) will be disabled
// when using multiple triggers, you cannot flip this flag to false again!
// example: x.csound('CooLSynth').log() as well as x.log().csound('CooLSynth') should work the same
dominantTrigger: hap.context.dominantTrigger || dominant,
dominantSchedule: hap.context.dominantSchedule || dominant,
}),
);
}
// legacy:
onTrigger(onTrigger, dominant = true) {
console.warn('onTrigger is deprecated.. use onSchedule(hap, time, cps) instead');
return this.onSchedule((hap, t, cps) => {
const currentTime = getClockTime();
return onTrigger(hap, currentTime, cps, t);
}, dominant);
}
/**
* Writes the content of the current event to the console (visible in the side menu).
* @tags visualization
@@ -898,7 +965,7 @@ export class Pattern {
* s("bd sd").log()
*/
log(func = (hap) => `[hap] ${hap.showWhole(true)}`, getData = (hap) => ({ hap })) {
return this.onTrigger((...args) => {
return this.onSchedule((...args) => {
logger(func(...args), undefined, getData(...args));
}, false);
}
@@ -1002,7 +1069,7 @@ export const arpWith = register('arpWith', (func, pat) => {
* */
export const arp = register(
'arp',
(indices, pat) => pat.arpWith((haps) => reify(indices).fmap((i) => haps[i % haps.length])),
(indices, pat) => pat.arpWith((haps) => reify(indices).fmap((i) => haps[_mod(i, haps.length)])),
false,
);
@@ -1074,7 +1141,7 @@ const COMPOSERS = {
* @param {Pattern} pat
* @returns {Pattern}
* @memberof Pattern
* @tags internal, combiners
* @tags internals, combiners
* @example
* // because input pattern has `s` set,
* // it overrides the "sine" declared earlier
@@ -1099,7 +1166,7 @@ const COMPOSERS = {
* @param {Pattern} pat
* @memberof Pattern
* @returns {Pattern}
* @tags internal, combiners
* @tags internals, combiners
* @example
* // notes, already defined, will stay "c a f e",
* // while "s", not defined, will be set to "piano"
@@ -1156,24 +1223,163 @@ const COMPOSERS = {
* @tags math
*/
div: [numeralArgs((a, b) => a / b)],
/**
*
* Divides each number by the given factor and returns the remainder
* @name mod
* @memberof Pattern
* @tags math
*/
mod: [numeralArgs(_mod)],
/**
*
* Raises each number to the power of the given factor
* @name pow
* @memberof Pattern
* @tags math
*/
pow: [numeralArgs(Math.pow)],
/**
*
* Performs a bitwise `and` on each number and the given factor
* @name band
* @memberof Pattern
* @tags math
*/
band: [numeralArgs((a, b) => a & b)],
/**
*
* Performs a bitwise `or` on each number and the given factor
* @name bor
* @memberof Pattern
* @tags math
*/
bor: [numeralArgs((a, b) => a | b)],
/**
*
* Performs a bitwise `xor` on each number and the given factor
* @name bxor
* @memberof Pattern
* @tags math
*/
bxor: [numeralArgs((a, b) => a ^ b)],
/**
*
* Performs a bitwise `left shift` on each number by the given factor
* @name blshift
* @memberof Pattern
* @tags math
*/
blshift: [numeralArgs((a, b) => a << b)],
/**
*
* Performs a bitwise `right shift` on each number by the given factor
* @name brshift
* @memberof Pattern
* @tags math
*/
brshift: [numeralArgs((a, b) => a >> b)],
// TODO - force numerical comparison if both look like numbers?
/**
* performs a < b
* @name lt
* @memberof Pattern
* @tags math
* @param {any} a
* @param {any} b
* @returns Pattern with boolean value(s)
*/
lt: [(a, b) => a < b],
/**
* performs a > b
* @name gt
* @memberof Pattern
* @tags math
* @param {any} a
* @param {any} b
* @returns Pattern with boolean value(s)
*/
gt: [(a, b) => a > b],
/**
* performs a <= b
* @name lte
* @memberof Pattern
* @tags math
* @param {any} a
* @param {any} b
* @returns Pattern with boolean value(s)
*/
lte: [(a, b) => a <= b],
/**
* performs a >= b
* @name gte
* @memberof Pattern
* @tags math
* @param {any} a
* @param {any} b
* @returns Pattern with boolean value(s)
*/
gte: [(a, b) => a >= b],
/**
* performs a == b
* @name eq
* @memberof Pattern
* @tags math
* @param {any} a
* @param {any} b
* @returns Pattern with boolean value(s)
*/
eq: [(a, b) => a == b],
/**
* performs a === b (strictly equal)
* @name eqt
* @memberof Pattern
* @tags math
* @param {any} a
* @param {any} b
* @returns Pattern with boolean value(s)
*/
eqt: [(a, b) => a === b],
/**
* performs a != b
* @name ne
* @memberof Pattern
* @tags math
* @param {any} a
* @param {any} b
* @returns Pattern with boolean value(s)
*/
ne: [(a, b) => a != b],
/**
* performs a !== b (strictly not equal)
* @name net
* @memberof Pattern
* @tags math
* @param {any} a
* @param {any} b
* @returns Pattern with boolean value(s)
*/
net: [(a, b) => a !== b],
/**
* performs a && b
* @name and
* @memberof Pattern
* @tags math
* @param {any} a
* @param {any} b
* @returns Pattern with boolean value(s)
*/
and: [(a, b) => a && b],
/**
* performs a || b
* @name or
* @memberof Pattern
* @tags math
* @param {any} a
* @param {any} b
* @returns Pattern with boolean value(s)
*/
or: [(a, b) => a || b],
// bitwise ops
@@ -1449,7 +1655,19 @@ export function sequenceP(pats) {
export function stack(...pats) {
// Array test here is to avoid infinite recursions..
pats = pats.map((pat) => (Array.isArray(pat) ? sequence(...pat) : reify(pat)));
const query = (state) => flatten(pats.map((pat) => pat.query(state)));
const query = (state) =>
flatten(
pats.map((pat) => {
try {
// An error occurring in one part of the stack should not break
// playback of the other parts of the stack.
return pat.query(state);
} catch (e) {
errorLogger(e);
return [];
}
}),
);
const result = new Pattern(query);
if (__steps) {
result._steps = lcm(...pats.map((pat) => pat._steps));
@@ -1470,6 +1688,18 @@ function _stackWith(func, pats) {
return stack(...func(steps, pats));
}
/**
* The input patterns are aligned to the left, with silence padded to the shorter pattern(s) in the final steps if there's a stepcount disparity. See also `stackRight` and `stackCenter/stackCentre`
*
* @name stackLeft
* @tags temporal, stepwise, combiners
* @return {Pattern}
* @example
* stackLeft("0 1 2 3", "3 4", "[4 5 6] 7 8 9 10").scale("c:minor").note()
* @example
* // chainable
* "0 1 2 3".stackLeft("3 4", "[4 5 6] 7 8 9 10").scale("c:minor").note()
*/
export function stackLeft(...pats) {
return _stackWith(
(steps, pats) => pats.map((pat) => (pat._steps.eq(steps) ? pat : stepcat(pat, gap(steps.sub(pat._steps))))),
@@ -1477,6 +1707,18 @@ export function stackLeft(...pats) {
);
}
/**
* The input patterns are aligned to the right, with silence padded to the shorter pattern(s) in the initial steps if there's a stepcount disparity. See also `stackLeft` and `stackCenter/stackCentre`
*
* @name stackRight
* @tags temporal, stepwise, combiners
* @return {Pattern}
* @example
* stackRight("0 1 2 3", "3 4", "[4 5 6] 7 8 9 10").scale("c:minor").note()
* @example
* // chainable
* "0 1 2 3".stackRight("3 4", "[4 5 6] 7 8 9 10").scale("c:minor").note()
*/
export function stackRight(...pats) {
return _stackWith(
(steps, pats) => pats.map((pat) => (pat._steps.eq(steps) ? pat : stepcat(gap(steps.sub(pat._steps)), pat))),
@@ -1484,7 +1726,19 @@ export function stackRight(...pats) {
);
}
export function stackCentre(...pats) {
/**
* The input patterns are aligned to the right, with silence padded to the shorter pattern(s) in the initial steps if there's a stepcount disparity. See also `stackLeft` and `stackRight`
* @name stackCenter
* @tags temporal, stepwise, combiners
* @synonyms stackCentre
* @return {Pattern}
* @example
* stackCenter("0 1 2 3", "3 4", "[4 5 6] 7 8 9 10").scale("c:minor").note()
* @example
* // chainable
* "0 1 2 3".stackCenter("3 4", "[4 5 6] 7 8 9 10").scale("c:minor").note()
*/
export function stackCenter(...pats) {
return _stackWith(
(steps, pats) =>
pats.map((pat) => {
@@ -1498,6 +1752,25 @@ export function stackCentre(...pats) {
);
}
export function stackCentre(...pats) {
return stackCenter(...pats);
}
/**
* Shorthand for `pm(patterns).pace(m)` where m is the max step count in the input patterns
* @name stackRepeat
* @tags temporal, stepwise, combiners
* @return {Pattern}
* @example
* stackRepeat("0 1 2 3", "3 4", "[4 5 6] 7 8 9 10").scale("c:minor").note()
* @example
* // chainable
* "0 1 2 3".stackRepeat("3 4", "[4 5 6] 7 8 9 10").scale("c:minor").note()
*/
export function stackRepeat(...pats) {
return stackBy(reify('repeat'), ...pats);
}
export function stackBy(by, ...pats) {
const [left, ...right] = pats.map((pat) => pat._steps);
const steps = left.maximum(...right);
@@ -1529,7 +1802,9 @@ export function slowcat(...pats) {
// Array test here is to avoid infinite recursions..
pats = pats.map((pat) => (Array.isArray(pat) ? fastcat(...pat) : reify(pat)));
if (pats.length == 1) {
if (!pats.length) {
return silence;
} else if (pats.length == 1) {
return pats[0];
}
@@ -1537,10 +1812,6 @@ export function slowcat(...pats) {
const span = state.span;
const pat_n = _mod(span.begin.sam(), pats.length);
const pat = pats[pat_n];
if (!pat) {
// pat_n can be negative, if the span is in the past..
return [];
}
// A bit of maths to make sure that cycles from constituent patterns aren't skipped.
// For example if three patterns are slowcat-ed, the fourth cycle of the result should
// be the second (rather than fourth) cycle from the first pattern.
@@ -1557,11 +1828,14 @@ export function slowcat(...pats) {
* @return {Pattern}
*/
export function slowcatPrime(...pats) {
if (!pats.length) {
return silence;
}
pats = pats.map(reify);
const query = function (state) {
const pat_n = Math.floor(state.span.begin) % pats.length;
const pat = pats[pat_n]; // can be undefined for same cases e.g. /#cHVyZSg0MikKICAuZXZlcnkoMyxhZGQoNykpCiAgLmxhdGUoLjUp
return pat?.query(state) || [];
const pat_n = _mod(Math.floor(state.span.begin), pats.length);
const pat = pats[pat_n];
return pat.query(state);
};
return new Pattern(query).splitQueries();
}
@@ -1611,10 +1885,10 @@ export function arrange(...sections) {
* @tags combiners
* @return {Pattern}
* @example
seqPLoop(
[0, 2, "bd(3,8)"],
[1, 3, "cp(3,8)"]
).sound()
* seqPLoop(
* [0, 2, "bd(3,8)"],
* [1, 3, "cp(3,8)"]
* ).sound()
*/
export function seqPLoop(...parts) {
let total = Fraction(0);
@@ -1741,6 +2015,12 @@ export const func = curry((a, b) => reify(b).func(a));
*
*/
export function register(name, func, patternify = true, preserveSteps = false, join = (x) => x.innerJoin()) {
if (isPattern(name)) {
throw new Error(
'Name argument for register is a pattern, try using single quotes (\'name\') instead of double quotes ("name")',
);
}
if (Array.isArray(name)) {
const result = {};
for (const name_item of name) {
@@ -2285,11 +2565,11 @@ export const { zoomArc, zoomarc } = register(['zoomArc', 'zoomarc'], function (a
* @param {number} slices to play
* @example
* note("0 1 2 3 4 5 6 7".scale('c:mixolydian'))
*.bite(4, "3 2 1 0")
* .bite(4, "3 2 1 0")
* @example
* sound("bd - bd bd*2, - sd:6 - sd:5 sd:1 - [- sd:2] -, hh [- cp:7]")
.bank("RolandTR909").speed(1.2)
.bite(4, "0 0 [1 2] <3 2> 0 0 [2 1] 3")
* .bank("RolandTR909").speed(1.2)
* .bite(4, "0 0 [1 2] <3 2> 0 0 [2 1] 3")
*/
export const bite = register(
'bite',
@@ -2306,6 +2586,74 @@ export const bite = register(
false,
);
/**
* Similar to `struct`, but every hap in the structure pattern plays the next
* cycle of the main pattern.
* With a different number of cycles in the main pattern and haps in the
* structure pattern, this creates an isorhythm.
*
* Note: This function works on the assumption that the structure pattern
* repeats every cycle. If the structure pattern only repeats after multiple
* cycles, it may skip or repeat certain parts of the melody.
* @tags temporal
* @name isobite
* @synonyms cycleStruct, cyclestruct
* @memberof Pattern
* @param {Pattern} structure structure of the pattern
* @example
* "<0 1 2 3 4 5>".isobite("[x - x*2]*2").n().scale("c:major")
* // the same as "0 - [1 2] 3 - [4 5]".n().scale("c:major")
*
* @example
* "<0 1 2 3 4>".isobite("x - x").n().scale("c:major")
* // the same as "<[0 - 1] [2 - 3] [4 - 0] [1 - 2] [3 - 4]>".n().scale("c:major")
*
* @example
* // Here the structure pattern repeats after 2 cycles,
* // so certain haps from the melody are skipped.
* "<0 1 2 3>".isobite("x x*2".slow(2)).n().scale("c:major")
* // the same as "<[0] [2 3] [2] [2 3]>".n().scale("c:major")
*
* @example
* // This can be avoided by making the struct pattern 1 cycle,
* // and slowing the whole pattern down as follows:
* "<0 1 2 3>".isobite("x x*2").slow(2).n().scale("c:major")
* // the same as "<[0] [1 2] [3] [0 1] [2] [3 0] [1] [2 3]>".n().scale("c:major")
*/
export const { isobite, cyclestruct, cycleStruct } = register(
['isobite', 'cyclestruct', 'cycleStruct'],
(structure, pat) => {
const structPat = new Pattern((state) => {
const haps_cycle = structure
.withQuerySpan((span) => span.begin.wholeCycle())
.query(state)
.filter((hap) => hap.value != 0)
.sort((a, b) => a.whole.begin - b.whole.begin)
.map((hap, i) => hap.withValue((_) => i));
const haps = [];
for (const hap of haps_cycle) {
const new_part = state.span.intersection(hap.part);
if (new_part) {
haps.push(new Hap(hap.whole, new_part, hap.value, hap.context));
}
}
return haps;
}).splitQueries();
const stepsPattern = new Pattern((state) => {
const nrHaps = structure
.withQuerySpan((span) => span.begin.wholeCycle())
.query(state)
.filter((hap) => hap.value != 0).length;
return [new Hap(state.span.begin.wholeCycle(), state.span, nrHaps, {})];
}).splitQueries();
return pat.fast(stepsPattern).bite(stepsPattern, structPat);
},
false,
);
/**
* Selects the given fraction of the pattern and repeats that part to fill the remainder of the cycle.
* @tags temporal
@@ -2412,8 +2760,12 @@ export const off = register('off', function (time_pat, func, pat) {
* Returns a new pattern where every other cycle is played once, twice as
* fast, and offset in time by one quarter of a cycle. Creates a kind of
* breakbeat feel.
* @name brak
* @tags temporal
* @returns Pattern
* @memberof Pattern
* @example
* s("bd hh!2 rim").brak()
*/
export const brak = register('brak', function (pat) {
return pat.when(slowcat(false, true), (x) => fastcat(x, silence)._late(0.25));
@@ -2546,6 +2898,20 @@ export const { juxBy, juxby } = register(['juxBy', 'juxby'], function (by, func,
return stack(left, right).setSteps(__steps ? lcm(left._steps, right._steps) : undefined);
});
/**
* Like juxBy, except it flips the ears each cycle.
* @name juxFlipBy
* @synonyms juxflipby, fluxBy, fluxby
* @example
* s("bd lt [~ ht] mt cp ~ bd hh").juxFlipBy(".8", rev)
*/
export const { juxFlipBy, juxflipby, fluxBy, fluxby } = register(
['juxFlipBy', 'juxflipby', 'fluxBy', 'fluxby'],
function (by, func, pat) {
return pat.juxBy(slowcat(by, -by), func);
},
);
/**
* The jux function creates strange stereo effects, by applying a function to a pattern, but only in the right-hand channel.
* @tags temporal, superdough
@@ -2560,6 +2926,21 @@ export const jux = register('jux', function (func, pat) {
return pat._juxBy(1, func, pat);
});
/**
* Like jux, but flips the ears each cycle.
* @name juxFlip
* @synonyms juxflip, flux
* @example
* s("bd lt [~ ht] mt cp ~ bd hh").juxFlip(rev)
* @example
* s("bd lt [~ ht] mt cp ~ bd hh").juxFlip(press)
* @example
* s("bd lt [~ ht] mt cp ~ bd hh").juxFlip(iter(4))
*/
export const { juxFlip, flux } = register(['juxFlip', 'juxflip', 'flux'], function (func, pat) {
return pat._juxFlipBy(1, func, pat);
});
/**
* Superimpose and offset multiple times, applying the given function each time.
* @tags temporal, functional
@@ -2873,10 +3254,29 @@ export const { ribbon, rib } = register(['ribbon', 'rib'], (offset, cycles, pat)
pat.early(offset).restart(pure(1).slow(cycles)),
);
/**
* Sets the pattern's color to the given [hsla](https://www.w3schools.com/htmL/html_colors_hsl.asp)
*
* @name hsla
* @param {Number} hue (0 - 360)
* @param {Number} saturation percentage (0 - 100)
* @param {Number} lightness percentage (0 - 100)
* @param {Number} alpha (transparency) (0.0 - 1.0)
* @tags visualization
*/
export const hsla = register('hsla', (h, s, l, a, pat) => {
return pat.color(`hsla(${h}turn,${s * 100}%,${l * 100}%,${a})`);
});
/**
* Sets the pattern's color to the given [hsl](https://www.w3schools.com/htmL/html_colors_hsl.asp)
*
* @name hsl
* @param {Number} hue (0 - 360)
* @param {Number} saturation percentage (0 - 100)
* @param {Number} lightness percentage (0 - 100)
* @tags visualization
*/
export const hsl = register('hsl', (h, s, l, pat) => {
return pat.color(`hsl(${h}turn,${s * 100}%,${l * 100}%)`);
});
@@ -3232,7 +3632,7 @@ export const drop = stepRegister('drop', function (i, pat) {
* @tags stepwise
* @example
* stepcat(
* sound("bd bd - cp").extend(2),
* sound("bd bd - <cp hh>").extend(2),
* sound("bd - sd -")
* ).pace(8)
*/
@@ -3243,15 +3643,12 @@ export const extend = stepRegister('extend', function (factor, pat) {
/**
* *Experimental*
*
* `replicate` is similar to `fast` in that it increases its density, but it also increases the step count
* accordingly. So `stepcat("a b".replicate(2), "c d")` would be the same as `"a b a b c d"`, whereas
* `stepcat("a b".fast(2), "c d")` would be the same as `"[a b] [a b] c d"`.
* `replicate` is similar to `extend`, except that `replicate` will "replicate" the thing being extended. This will only differ from `extend` when the input pattern differs from cycle to cycle.
*
* TODO: find out how this function differs from extend
* @tags stepwise
* @example
* stepcat(
* sound("bd bd - cp").replicate(2),
* sound("bd bd - <cp hh>").replicate(2),
* sound("bd - sd -")
* ).pace(8)
*/
@@ -3324,6 +3721,11 @@ Pattern.prototype.shrinklist = function (amount) {
export const shrinklist = (amount, pat) => pat.shrinklist(amount);
Pattern.prototype.growlist = function (amount) {
return this.shrinklist(amount).reverse();
};
export const growlist = (amount, pat) => pat.growlist(amount);
/**
* *Experimental*
*
@@ -3342,8 +3744,8 @@ export const shrinklist = (amount, pat) => pat.shrinklist(amount);
* "tha dhi thom nam".shrink("1 -1").sound().bank("mridangam").pace(4)
* @example
* note("0 1 2 3 4 5 6 7".scale("C:ritusen")).sound("folkharp")
.shrink("1 -1").pace(8)
* .shrink("1 -1").pace(8)
*
*/
export const shrink = register(
@@ -3382,7 +3784,7 @@ export const shrink = register(
* "tha dhi thom nam".grow("1 -1").sound().bank("mridangam").pace(4)
* @example
* note("0 1 2 3 4 5 6 7".scale("C:ritusen")).sound("folkharp")
.grow("1 -1").pace(8)
* .grow("1 -1").pace(8)
*/
export const grow = register(
@@ -3406,17 +3808,17 @@ export const grow = register(
/**
* *Experimental*
*
* Inserts a pattern into a list of patterns. On the first repetition it will be inserted at the end of the list, then moved backwards through the list
* on successive repetitions. The patterns are added together stepwise, with all repetitions taking place over a single cycle. Using `pace` to set the
*
* Inserts a pattern into a list of patterns. On the first repetition it will be inserted at the end of the list, then moved backwards through the list
* on successive repetitions. The patterns are added together stepwise, with all repetitions taking place over a single cycle. Using `pace` to set the
* number of steps per cycle is therefore usually recommended.
*
* @tags stepwise
* @return {Pattern}
* @example
* "[c g]".tour("e f", "e f g", "g f e c").note()
.sound("folkharp")
.pace(8)
* .sound("folkharp")
* .pace(8)
*/
export const tour = function (pat, ...many) {
return pat.tour(...many);
@@ -3434,16 +3836,16 @@ Pattern.prototype.tour = function (...many) {
/**
* *Experimental*
*
* 'zips' together the steps of the provided patterns. This can create a long repetition, taking place over a single, dense cycle.
*
* 'zips' together the steps of the provided patterns. This can create a long repetition, taking place over a single, dense cycle.
* Using `pace` to set the number of steps per cycle is therefore usually recommended.
*
*
* @tags stepwise
* @returns {Pattern}
* @example
* zip("e f", "e f g", "g [f e] a f4 c").note()
.sound("folkharp")
.pace(8)
* .sound("folkharp")
* .pace(8)
*/
export const zip = function (...pats) {
pats = pats.filter((pat) => pat.hasSteps);
@@ -3606,9 +4008,9 @@ export const slice = register(
* s("bd!8").onTriggerTime((hap) => {console.log(hap)})
*/
Pattern.prototype.onTriggerTime = function (func) {
return this.onTrigger((hap, currentTime, _cps, targetTime) => {
const diff = targetTime - currentTime;
window.setTimeout(() => {
return this.onSchedule((hap, targetTime) => {
const diff = targetTime - getClockTime();
setTimeout(() => {
func(hap);
}, diff * 1000);
}, false);
@@ -4092,3 +4494,59 @@ Pattern.prototype.worklet = function (src, ...inputs) {
};
export const worklet = (...args) => pure({}).worklet(...args);
/**
* Creates a pattern of numbers in base b from a number or pattern of numbers
* limited to d digits long from the right
*
* @name base
* @tags generators
* @param {number} n - number to convert (can be a pattern or array)
* @param {number} b - base to convert to (defaults to 10) (can be a pattern)
* @param {number} d - max number of digits to produce for each n (defaults to 0 for all) (can be a pattern)
* @example
* $: note(base("7175 543", 10, 3)).scale("c:major").s("saw")
* // $: note("1 7 5 5 4 3").scale("c:major").s("saw")
*/
export const base = (n, b = 10, d = 0) => {
if (Array.isArray(n)) {
n = sequence(n);
}
n = reify(n);
b = reify(b);
d = reify(d);
return d
.withValue((e) => {
return b
.withValue((c) => {
return n
.withValue((v) => {
let digits = [];
let value = v;
while (value > 0) {
digits.unshift(value % c);
value = Math.floor(value / c);
}
if (e) {
const l = digits.length;
if (l > e) {
digits = digits.slice(-1 * e);
}
/*
if (l < e){
for (let i = l; i < e; i++) {
digits.unshift("~");//0); //Would like to be padding this but ~- doesn't work
}
console.log("digits", digits);
}
*/
}
return sequence(digits);
})
.squeezeJoin();
})
.squeezeJoin();
})
.squeezeJoin();
};
+2
View File
@@ -104,6 +104,8 @@ export const pickmodF = register('pickmodF', function (pickPattern, lookup, pat)
* @param {Pattern} pat
* @param {*} xs
* @returns {Pattern}
* @example
* note("<0*4 1 2*2 3*3>".pickOut(["g a", "e f", "f g f g" , "g c d"]))
*/
export const pickOut = register('pickOut', function (lookup, pat) {
return _pick(lookup, pat, false).outerJoin();
+29 -15
View File
@@ -7,13 +7,15 @@ import {
setIsStarted,
setPattern as exposeSchedulerPattern,
setTime,
setTriggerFunc,
setScheduleFunc,
setClockTime,
} from './schedulerState.mjs';
import { evalScope } from './evaluate.mjs';
import { register, Pattern, isPattern, silence, stack } from './pattern.mjs';
import { reset_state } from './impure.mjs';
export function repl({
onSchedule,
defaultOutput,
onEvalError,
beforeEval,
@@ -30,6 +32,11 @@ export function repl({
id,
mondo = false,
}) {
if (defaultOutput) {
throw new Error(
'defaultOutput has been removed. use onSchedule(hap, time, cps) instead. see https://codeberg.org/uzu/strudel/pulls/2138/',
);
}
const state = {
schedulerError: undefined,
evalError: undefined,
@@ -55,7 +62,7 @@ export function repl({
};
const schedulerOptions = {
onTrigger: getTrigger({ defaultOutput, getTime }),
onSchedule: getSchedule({ onSchedule }),
getTime,
onToggle: (started) => {
updateState({ started });
@@ -73,7 +80,7 @@ export function repl({
// NeoCyclist uses a shared worker to communicate between instances, which is not supported on mobile chrome
const scheduler =
sync && typeof SharedWorker != 'undefined' ? new NeoCyclist(schedulerOptions) : new Cyclist(schedulerOptions);
setTriggerFunc(schedulerOptions.onTrigger);
setScheduleFunc(schedulerOptions.onSchedule);
setCpsFunc(() => scheduler.cps);
let pPatterns = {};
let anonymousIndex = 0;
@@ -224,6 +231,7 @@ export function repl({
return pattern;
};
setTime(() => scheduler.now()); // TODO: refactor?
setClockTime(() => scheduler.getTime()); // TODO: refactor?
// Helper function to apply pattern transformations (solo, each, all)
// this should be abstracted more
@@ -307,8 +315,12 @@ export function repl({
let allTransforms = [];
/**
* Applies a function to all the running patterns. Note that the patterns are groups together into a single `stack` before the function is applied. This is probably what you want, but see `each` for
* Applies a function to all the running patterns. Note that the patterns are grouped together into a single `stack` before the function is applied. This is probably what you want, but see `each` for
* a version that applies the function to each pattern separately.
*
* **Note:** Patterns must be labeled (e.g. with `$:`) to be picked up by `all`. An unlabeled
* pattern such as `note("c4")` is not registered and will produce no audio when `all` is present.
* Use `$: note("c4")` instead.
* ```
* $: sound("bd - cp sd")
* $: sound("hh*8")
@@ -328,6 +340,9 @@ export function repl({
};
/** Applies a function to each of the running patterns separately. This is intended for future use with upcoming 'stepwise' features. See `all` for a version that applies the function to all the patterns stacked together into a single pattern.
*
* **Note:** Patterns must be labeled (e.g. with `$:`) to be picked up by `each`. An unlabeled
* pattern such as `note("c4")` is not registered and will produce no audio when `each` is present.
* Use `$: note("c4")` instead.
* ```
* $: sound("bd - cp sd")
* $: sound("hh*8")
@@ -400,6 +415,7 @@ export function repl({
updateState({ code, pending: true });
await injectPatternMethods();
setTime(() => scheduler.now()); // TODO: refactor?
setClockTime(() => scheduler.getTime()); // TODO: refactor?
await beforeEval?.({ code, blockBased: false });
allTransforms = []; // reset all transforms
@@ -445,6 +461,7 @@ export function repl({
updateState({ code, pending: true });
await injectPatternMethods();
setTime(() => scheduler.now()); // TODO: refactor?
setClockTime(() => scheduler.getTime()); // TODO: refactor?
await beforeEval?.({ code, blockBased: true });
allTransforms = []; // reset all transforms
@@ -553,20 +570,17 @@ export function repl({
return { scheduler, evaluate, evaluateBlock, start, stop, pause, setCps, setPattern, setCode, toggle, state };
}
export const getTrigger =
({ getTime, defaultOutput }) =>
async (hap, deadline, duration, cps, t) => {
// ^ this signature is different from hap.context.onTrigger, as set by Pattern.onTrigger(onTrigger)
// TODO: get rid of deadline after https://codeberg.org/uzu/strudel/pulls/1004
export const getSchedule =
({ onSchedule }) =>
async (hap, t, cps) => {
try {
if (!hap.context.onTrigger || !hap.context.dominantTrigger) {
await defaultOutput(hap, deadline, duration, cps, t);
if (!hap.context.onSchedule || !hap.context.dominantSchedule) {
await onSchedule(hap, t, cps);
}
if (hap.context.onTrigger) {
// call signature of output / onTrigger is different...
await hap.context.onTrigger(hap, getTime(), cps, t);
if (hap.context.onSchedule) {
await hap.context.onSchedule(hap, t, cps);
}
} catch (err) {
errorLogger(err, 'getTrigger');
errorLogger(err, 'getSchedule');
}
};
+33 -5
View File
@@ -7,8 +7,14 @@ This program is free software: you can redistribute it and/or modify it under th
let time;
let cpsFunc;
let pattern;
let triggerFunc;
let scheduleFunc;
let isStarted;
/**
* @name getTime
* @tags internals
* @returns {Number} the current cycle time as a number
*/
export function getTime() {
if (!time) {
throw new Error('no time set! use setTime to define a time source');
@@ -20,10 +26,32 @@ export function setTime(func) {
time = func;
}
let clockTime;
/**
* @name getClockTime
* @tags internals
* @returns {Number} the current clock time in seconds (onSchedule time)
*/
export function getClockTime() {
if (!clockTime) {
throw new Error('no clockTime set! use setClockTime to define a time source');
}
return clockTime();
}
export function setClockTime(func) {
clockTime = func;
}
export function setCpsFunc(func) {
cpsFunc = func;
}
/**
* @name getCps
* @tags internals
* @returns {Number} the current cycles per second
*/
export function getCps() {
return cpsFunc?.();
}
@@ -36,12 +64,12 @@ export function getPattern() {
return pattern;
}
export function setTriggerFunc(func) {
triggerFunc = func;
export function setScheduleFunc(func) {
scheduleFunc = func;
}
export function getTriggerFunc() {
return triggerFunc;
export function getScheduleFunc() {
return scheduleFunc;
}
export function setIsStarted(val) {
+76 -13
View File
@@ -5,7 +5,8 @@ This program is free software: you can redistribute it and/or modify it under th
*/
import { Hap } from './hap.mjs';
import { Pattern, fastcat, pure, register, reify, silence, stack, sequenceP } from './pattern.mjs';
import { Pattern, fastcat, pure, register, reify, silence, stack, sequenceP, isPattern } from './pattern.mjs';
import { _mod } from './util.mjs';
import Fraction from './fraction.mjs';
import { id, keyAlias, getCurrentKeyboardState } from './util.mjs';
@@ -15,6 +16,24 @@ export function steady(value) {
return new Pattern((state) => [new Hap(undefined, state.span, value)]);
}
/**
* Creates a signal given by the input function
*
* @name signal
* @param {*} func
* @returns {Pattern} signal
* @tags generators
* @example
* // sawtooth signal definition
* signal(t=>t%1).segment(16).range(30,40).note()
* @example
* signal(t=>Math.pow(t,2)%1).range(40, 60).add(5).segment(16)
* .off(0, x=>x.add(7))
* .note()
* .s("gm_electric_guitar_jazz,gm_epiano")
* .gain(1.7)
* .pianoroll()
*/
export const signal = (func) => {
const query = (state) => [new Hap(undefined, state.span, func(state.span.begin, state.controls))];
return new Pattern(query);
@@ -33,7 +52,7 @@ export const signal = (func) => {
* .scale('C major')
*
*/
export const saw = signal((t) => t % 1);
export const saw = signal((t) => _mod(t, 1));
/**
* A sawtooth signal between -1 and 1 (like `saw`, but bipolar).
@@ -56,7 +75,7 @@ export const saw2 = saw.toBipolar();
* .scale('C major')
*
*/
export const isaw = signal((t) => 1 - (t % 1));
export const isaw = signal((t) => 1 - _mod(t, 1));
/**
* A sawtooth signal between 1 and -1 (like `saw2`, but flipped).
@@ -113,7 +132,7 @@ export const cosine2 = sine2._early(Fraction(1).div(4));
* n(square.segment(4).range(0,7)).scale("C:minor")
*
*/
export const square = signal((t) => Math.floor((t * 2) % 2));
export const square = signal((t) => Math.floor(_mod(t * 2, 2)));
/**
* A square signal between -1 and 1 (like `square`, but bipolar).
@@ -123,6 +142,22 @@ export const square = signal((t) => Math.floor((t * 2) % 2));
*/
export const square2 = square.toBipolar();
/**
* A square signal between 1 and 0 (like `square` but flipped).
*
* @return {Pattern}
* @tags generators
*/
export const isquare = signal((t) => 1 - Math.floor(_mod(t * 2, 2)));
/**
* A square signal between 1 and -1 (like `isquare`, but bipolar).
*
* @return {Pattern}
* @tags generators
*/
export const isquare2 = isquare.toBipolar();
/**
* A triangle signal between 0 and 1.
*
@@ -172,6 +207,7 @@ export const time = signal(id);
/**
* The mouse's x position value ranges from 0 to 1.
* @name mousex
* @synonyms mouseX
* @return {Pattern}
* @tags external_io
* @example
@@ -182,6 +218,7 @@ export const time = signal(id);
/**
* The mouse's y position value ranges from 0 to 1.
* @name mousey
* @synonyms mouseY
* @return {Pattern}
* @tags external_io
* @example
@@ -396,7 +433,7 @@ export const randrun = (n) => {
.map((n, i) => [n, i])
.sort((a, b) => (a[0] > b[0]) - (a[0] < b[0]))
.map((x) => x[1]);
const i = t.cyclePos().mul(n).floor() % n;
const i = _mod(t.cyclePos().mul(n).floor(), n);
return nums[i];
})._segment(n);
};
@@ -542,6 +579,9 @@ export const __chooseWith = (pat, xs) => {
* @returns {Pattern}
* @example
* note("c2 g2!2 d2 f1").s(chooseWith(sine.fast(2), ["sawtooth", "triangle", "bd:6"]))
* @example
* // Compare with chooseInWith
* note(chooseWith("<0 0.5 1>*3", ["c3", "b3", "d3"]))
*/
export const chooseWith = (pat, xs) => {
return __chooseWith(pat, xs).outerJoin();
@@ -553,6 +593,13 @@ export const chooseWith = (pat, xs) => {
* @tags temporal
* @param {Pattern} pat
* @param {*} xs
* @example
* // Because the structure comes from the chosen values (the notes, here)
* // The choosing pattern will only advance once per cycle (not 3 times per cycle)
* note(chooseInWith("<0 0.5 1>*3", ["c3", "b3", "d3"]))
* @example
* // Here, the chosen values have a structure such that the choosing pattern will advance 3 times per cycle
* note(chooseInWith("<0 0.5 1>*3", ["c3*3", "b3*3", "d3*3"]))
* @returns {Pattern}
*/
export const chooseInWith = (pat, xs) => {
@@ -569,9 +616,7 @@ export const chooseInWith = (pat, xs) => {
*/
export const choose = (...xs) => chooseWith(rand, xs);
// todo: doc
export const chooseIn = (...xs) => chooseInWith(rand, xs);
export const chooseOut = choose;
/**
* Chooses from the given list of values (or patterns of values), according
@@ -717,12 +762,30 @@ export const perlin = signal((t, controls) => _perlin(t, controls.randSeed));
*/
export const berlin = signal((t, controls) => _berlin(t, controls.randSeed));
export const degradeByWith = register(
'degradeByWith',
(withPat, x, pat) => pat.fmap((a) => (_) => a).appLeft(withPat.filterValues((v) => v > x)),
true,
true,
);
/**
* `degradeByWith` is like `degradeBy`, but with control over the random or other signal used to filter out the events.
*
* `degradeBy(x)` is essentially `degradeByWith(rand, x)`
*
* This allows you to do things other than with `rand`, see examples below
*
* @name degradeByWith
* @param {Pattern|number} test
* @param {Pattern|number} testAgainst
* @memberof Pattern
* @tags temporal
* @returns {Pattern}
* @example
* note("c a f e").degradeByWith(sine, .1)
* @example
* note("c a f e").degradeByWith(perlin, .5)
* @example
* note("c a f e").degradeByWith(sine, cosine)
*/
export const degradeByWith = register('degradeByWith', (withPat, x, pat) => {
if (!isPattern(withPat)) withPat = pure(withPat);
return pat.fmap((a) => (_) => a).appLeft(withPat.filterValues((v) => v > x));
});
/**
* Randomly removes events from the pattern by a given amount.
+1 -1
View File
@@ -32,7 +32,7 @@ function triggerSpeech(words, lang, voice) {
}
export const speak = register('speak', function (lang, voice, pat) {
return pat.onTrigger((hap) => {
return pat.onSchedule((hap) => {
triggerSpeech(hap.value, lang, voice);
});
});
+77
View File
@@ -29,6 +29,44 @@ describe('euclid', () => {
.map((a) => a.showWhole(true)),
).toStrictEqual(['0/1 → 1/8: a', '1/4 → 3/8: a', '3/8 → 1/2: a', '5/8 → 3/4: a', '3/4 → 7/8: a']);
});
it('Can create euclidLegato using negative steps', () => {
expect(
fastcat('a')
.euclid(3, -8)
.firstCycle()
.sort((a, b) => a.part.begin.sub(b.part.begin))
.map((a) => a.showWhole(true)),
).toStrictEqual(
fastcat('a')
.euclidLegato(3, 8)
.firstCycle()
.sort((a, b) => a.part.begin.sub(b.part.begin))
.map((a) => a.showWhole(true)),
);
expect(
fastcat('a')
.euclid(5, -8)
.firstCycle()
.sort((a, b) => a.part.begin.sub(b.part.begin))
.map((a) => a.showWhole(true)),
).toStrictEqual(
fastcat('a')
.euclidLegato(5, 8)
.firstCycle()
.sort((a, b) => a.part.begin.sub(b.part.begin))
.map((a) => a.showWhole(true)),
);
});
it('stretches over multiple cycles if pulses is greater than steps', () => {
expect(
fastcat('a')
.euclid(5, 4)
.fast(2)
.firstCycle()
.sort((a, b) => a.part.begin.sub(b.part.begin))
.map((a) => a.showWhole(true)),
).toStrictEqual(['0/1 → 1/8: a', '1/4 → 3/8: a', '3/8 → 1/2: a', '5/8 → 3/4: a', '3/4 → 7/8: a']);
});
});
describe('euclidRot', () => {
@@ -67,6 +105,45 @@ describe('euclidLegato', () => {
.map((a) => a.showWhole(true)),
).toStrictEqual(['0/1 → 1/4: a', '1/4 → 3/8: a', '3/8 → 5/8: a', '5/8 → 3/4: a', '3/4 → 1/1: a']);
});
it('Can create euclid using negative steps', () => {
expect(
fastcat('a')
.euclidLegato(3, -8)
.firstCycle()
.sort((a, b) => a.part.begin.sub(b.part.begin))
.map((a) => a.showWhole(true)),
).toStrictEqual(
fastcat('a')
.euclid(3, 8)
.firstCycle()
.sort((a, b) => a.part.begin.sub(b.part.begin))
.map((a) => a.showWhole(true)),
);
expect(
fastcat('a')
.euclidLegato(5, -8)
.firstCycle()
.sort((a, b) => a.part.begin.sub(b.part.begin))
.map((a) => a.showWhole(true)),
).toStrictEqual(
fastcat('a')
.euclid(5, 8)
.firstCycle()
.sort((a, b) => a.part.begin.sub(b.part.begin))
.map((a) => a.showWhole(true)),
);
});
it('stretches over multiple cycles if pulses is greater than steps', () => {
expect(
fastcat('a')
.euclidLegato(5, 4)
.fast(2)
.firstCycle()
.filter((hap) => hap.hasOnset())
.sort((a, b) => a.part.begin.sub(b.part.begin))
.map((a) => a.showWhole(true)),
).toStrictEqual(['0/1 → 1/4: a', '1/4 → 3/8: a', '3/8 → 5/8: a', '5/8 → 3/4: a', '3/4 → 1/1: a']);
});
});
describe('euclidLegatoRot', () => {
+72 -3
View File
@@ -18,6 +18,7 @@ import {
fastcat,
firstOf,
slowcat,
slowcatPrime,
cat,
sequence,
palindrome,
@@ -45,6 +46,7 @@ import {
rev,
time,
run,
randrun,
binaryN,
pick,
stackLeft,
@@ -53,9 +55,10 @@ import {
stepcat,
sometimes,
expand,
arp,
} from '../index.mjs';
import { log, logValues } from '../pattern.mjs';
import { log, logValues, stackCenter } from '../pattern.mjs';
import { steady } from '../signal.mjs';
@@ -72,6 +75,10 @@ const sameFirst = (a, b) => {
return expect(a.sortHapsByPart().firstCycle()).toStrictEqual(b.sortHapsByPart().firstCycle());
};
const sameFirstX = (x, a, b) => {
return expect(a.fast(x).sortHapsByPart().firstCycle()).toStrictEqual(b.fast(x).sortHapsByPart().firstCycle());
};
describe('TimeSpan', () => {
describe('equals()', () => {
it('Should be equal to the same value', () => {
@@ -544,6 +551,9 @@ describe('Pattern', () => {
});
});
describe('slowcat()', () => {
it('Can be empty', () => {
expect(slowcat().firstCycle()).toStrictEqual([]);
});
it('Can concatenate things slowly', () => {
expect(
slowcat('a', 'b')
@@ -576,6 +586,38 @@ describe('Pattern', () => {
sameFirst(slowcat('a', ['b', 'c']).fast(4), sequence('a', ['b', 'c']).fast(2));
});
});
describe('slowcatPrime()', () => {
it('Can be empty', () => {
expect(slowcatPrime().firstCycle()).toStrictEqual([]);
});
it('Can slowcat patterns swapping back and forth skipping the expected notes', () => {
expect(
slowcatPrime(fastcat(0, 1, 2, 3).slow(2), fastcat(4, 5, 6, 7).slow(2))
.fast(4)
.firstCycle()
.map((a) => a.value),
).toStrictEqual([0, 1, 6, 7, 0, 1, 6, 7]);
});
it('Can go into negative time', () => {
expect(
slowcatPrime(fastcat(0, 1, 2, 3).slow(2), fastcat(4, 5, 6, 7).slow(2))
.fast(4)
.late(8)
.firstCycle()
.map((a) => a.value),
).toStrictEqual([0, 1, 6, 7, 0, 1, 6, 7]);
});
});
describe('arp()', () => {
it('It wraps around with both positive and negative indices', () => {
expect(
stack('a', 'b', 'c')
.arp(fastcat(-3, -2, -1, 0, 1, 2, 3, 4))
.firstCycle()
.map((a) => a.value),
).toStrictEqual(['a', 'b', 'c', 'a', 'b', 'c', 'a', 'b']);
});
});
describe('rev()', () => {
it('Can reverse things', () => {
expect(
@@ -945,6 +987,17 @@ describe('Pattern', () => {
it('Can run', () => {
expect(run(4).firstCycle()).toStrictEqual(sequence(0, 1, 2, 3).firstCycle());
});
it('Can go into negative time', () => {
expect(run(4).late(1).firstCycle()).toStrictEqual(sequence(0, 1, 2, 3).firstCycle());
});
});
describe('randrun', () => {
it('Can randrun', () => {
expect(randrun(4).firstCycle()).toStrictEqual(sequence(2, 1, 3, 0).firstCycle());
});
it('Can go into negative time', () => {
expect(randrun(4).late(1).firstCycle()).toStrictEqual(sequence(1, 2, 0, 3).firstCycle());
});
});
describe('binaryN', () => {
it('Can make a binary pattern from a decimal', () => {
@@ -1166,6 +1219,7 @@ describe('Pattern', () => {
expect(stackRight(fastcat(0, 1, 2), fastcat(3, 4))._steps).toStrictEqual(Fraction(3));
// maybe this should double when they are either all even or all odd
expect(stackCentre(fastcat(0, 1, 2), fastcat(3, 4))._steps).toStrictEqual(Fraction(3));
expect(stackCenter(fastcat(0, 1, 2), fastcat(3, 4))._steps).toStrictEqual(Fraction(3));
expect(fastcat(0, 1).ply(3)._steps).toStrictEqual(Fraction(6));
expect(fastcat(0, 1).setSteps(undefined).ply(3)._steps).toStrictEqual(undefined);
expect(fastcat(0, 1).fast(3)._steps).toStrictEqual(Fraction(2));
@@ -1271,6 +1325,21 @@ describe('Pattern', () => {
);
});
});
describe('isobite', () => {
it('can play a melody with a structure', () => {
sameFirst(
slowcat(0, 1, 2, 3, 4, 5).isobite(fastcat(true, silence, [true, true]).fast(2)),
fastcat(0, silence, [1, 2], 3, silence, [4, 5]),
);
});
it('syncopates over multiple cycles if the number of haps in the patterns differ', () => {
sameFirstX(
4,
slowcat(0, 1, 2, 3).isobite(fastcat(true, [true, true])),
slowcat(0, [1, 2], 3, [0, 1], 2, [3, 0], 1, [2, 3]).fast(2),
);
});
});
describe('unjoin', () => {
it('destructures a pattern into subcycles', () => {
sameFirst(
@@ -1314,7 +1383,7 @@ describe('Pattern', () => {
// Force a trigger
haps.forEach((hap) => {
hap.context?.onTrigger?.(hap);
hap.context?.onSchedule?.(hap);
});
expect(mockConsoleLog).toHaveBeenCalledWith(
@@ -1332,7 +1401,7 @@ describe('Pattern', () => {
// Force a trigger
haps.forEach((hap) => {
hap.context?.onTrigger?.(hap);
hap.context?.onSchedule?.(hap);
});
expect(mockConsoleLog).toHaveBeenCalledWith(
+66 -6
View File
@@ -8,7 +8,23 @@ import Fraction from 'fraction.js';
import { describe, it, expect, vi } from 'vitest';
import { saw, saw2, isaw, isaw2, per, perx, cyclesPer } from '../signal.mjs';
import {
saw,
saw2,
isaw,
isaw2,
tri,
tri2,
itri,
itri2,
square,
square2,
isquare,
isquare2,
per,
perx,
cyclesPer,
} from '../signal.mjs';
import { fastcat, sequence, State, TimeSpan, Hap, note } from '../index.mjs';
const st = (begin, end) => new State(ts(begin, end));
@@ -24,17 +40,61 @@ const sameFirst = (a, b) => {
describe('signal()', () => {
it('Can make saw/saw2', () => {
expect(saw.struct(true, true, true, true).firstCycle()).toStrictEqual(
sequence(0, 1 / 4, 1 / 2, 3 / 4).firstCycle(),
);
expect(saw.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(0, 0.25, 0.5, 0.75).firstCycle());
expect(saw2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(-1, -0.5, 0, 0.5).firstCycle());
});
it('Can make isaw/isaw2', () => {
expect(isaw.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.75, 0.5, 0.25).firstCycle());
expect(isaw2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.5, 0, -0.5).firstCycle());
});
it('Can make tri/tri2', () => {
expect(tri.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(0, 0.5, 1, 0.5).firstCycle());
expect(tri2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(-1, 0, 1, 0).firstCycle());
});
it('Can make itri/itri2', () => {
expect(itri.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.5, 0, 0.5).firstCycle());
expect(itri2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0, -1, 0).firstCycle());
});
it('Can make square/square2', () => {
expect(square.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(0, 0, 1, 1).firstCycle());
expect(square2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(-1, -1, 1, 1).firstCycle());
});
it('Can make isquare/isquare2', () => {
expect(isquare.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 1, 0, 0).firstCycle());
expect(isquare2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 1, -1, -1).firstCycle());
});
it('Can go into negative time', () => {
expect(saw.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(
sequence(0, 0.25, 0.5, 0.75).firstCycle(),
);
expect(saw2.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(
sequence(-1, -0.5, 0, 0.5).firstCycle(),
);
expect(isaw.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(
sequence(1, 0.75, 0.5, 0.25).firstCycle(),
);
expect(isaw2.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(
sequence(1, 0.5, 0, -0.5).firstCycle(),
);
expect(tri.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(
sequence(0, 0.5, 1, 0.5).firstCycle(),
);
expect(tri2.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(-1, 0, 1, 0).firstCycle());
expect(itri.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(
sequence(1, 0.5, 0, 0.5).firstCycle(),
);
expect(itri2.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0, -1, 0).firstCycle());
expect(square.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(0, 0, 1, 1).firstCycle());
expect(square2.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(
sequence(-1, -1, 1, 1).firstCycle(),
);
expect(isquare.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(
sequence(1, 1, 0, 0).firstCycle(),
);
expect(isquare2.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(
sequence(1, 1, -1, -1).firstCycle(),
);
});
});
describe('cyclesPer', () => {
+29 -15
View File
@@ -70,17 +70,6 @@ export const getEventOffsetMs = (targetTimeSeconds, currentTimeSeconds) => {
return (targetTimeSeconds - currentTimeSeconds) * 1000;
};
/**
* @deprecated does not appear to be referenced or invoked anywhere in the codebase
* @noAutocomplete
*/
export const getFreq = (noteOrMidi) => {
if (typeof noteOrMidi === 'number') {
return midiToFreq(noteOrMidi);
}
return midiToFreq(noteToMidi(noteOrMidi));
};
const pcs = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'];
/**
* @deprecated only used in workshop (first-notes)
@@ -130,25 +119,50 @@ export const getPlayableNoteValue = (hap) => {
return note;
};
/**
* Given a hap, returns the frequency of the note or freq, if defined
*
* Given a note, returns the frequency of that note
* `getFreq('c3')`
*
* Given a midi note number, returns the frequency of that midi note
* `getFreq('c3')`
*
* @name getFrequency
* @tags pitch
* @synonyms getFreq
* @param {Hap | note | number} input
* @returns {Number}
* @example
* i("4 8 9 10 - - 5 7 9 11 - -").tune("tranh3")
* .mul(getFreq('c3'))
* .freq().clip(.5).room(1)
*/
export const getFrequency = (hap) => {
let { value, context } = hap;
let { value, context } =
typeof hap == 'object'
? hap
: // default: interpret value as midi note.
{ value: hap, context: { type: 'note' } };
// if value is number => interpret as midi number as long as its not marked as frequency
if (typeof value === 'object') {
if (value.freq) {
return value.freq;
}
return getFreq(value.note || value.n || value.value);
return getFreq(value.note ?? value.n ?? value.value);
}
if (typeof value === 'number' && context.type !== 'frequency') {
value = midiToFreq(hap.value);
value = midiToFreq(value);
} else if (typeof value === 'string' && isNote(value)) {
value = midiToFreq(noteToMidi(hap.value));
value = midiToFreq(noteToMidi(value));
} else if (typeof value !== 'number') {
throw new Error('not a note or frequency: ' + value);
}
return value;
};
export const getFreq = (hap) => getFrequency(hap);
// rotate array by n steps (to the left)
export const rotate = (arr, n) => arr.slice(n).concat(arr.slice(0, n));
+5 -3
View File
@@ -23,7 +23,7 @@ export const csound = register('csound', (instrument, pat) => {
instrument = instrument || 'triangle';
init(); // not async to support csound inside other patterns + to be able to call pattern methods after it
// TODO: find a alternative way to wait for csound to load (to wait with first time playback)
return pat.onTrigger((hap, currentTime, _cps, targetTime) => {
return pat.onSchedule((hap, targetTime) => {
if (!_csound) {
logger('[csound] not loaded yet', 'warning');
return;
@@ -36,9 +36,10 @@ export const csound = register('csound', (instrument, pat) => {
const controls = Object.entries({ ...hap.value, freq })
.flat()
.join('/');
// TODO: find out how to send a precise ctx based time
// http://www.csounds.com/manual/html/i.html
const timeOffset = targetTime - currentTime; // latency ?
const timeOffset = targetTime - getAudioContext().currentTime; // latency ?
//const timeOffset = time_deprecate - getAudioContext().currentTime
const params = [
`"${instrument}"`, // p1: instrument name
@@ -144,7 +145,7 @@ export const csoundm = register('csoundm', (instrument, pat) => {
p1 = `"${instrument}"`;
}
init(); // not async to support csound inside other patterns + to be able to call pattern methods after it
return pat.onTrigger((hap, currentTime, _cps, targetTime) => {
return pat.onSchedule((hap, targetTime) => {
if (!_csound) {
logger('[csound] not loaded yet', 'warning');
return;
@@ -152,6 +153,7 @@ export const csoundm = register('csoundm', (instrument, pat) => {
if (typeof hap.value !== 'object') {
throw new Error('csound only support objects as hap values');
}
const currentTime = getAudioContext().currentTime;
// Time in seconds counting from now.
const p2 = targetTime - currentTime;
const p3 = hap.duration.valueOf() + 0;
+3 -1
View File
@@ -1,3 +1,4 @@
import { getAudioContext } from '@strudel/webaudio/index.mjs';
import { Invoke } from './utils.mjs';
import { Pattern, getEventOffsetMs, noteToMidi } from '@strudel/core';
@@ -6,7 +7,8 @@ const OFF_MESSAGE = 0x80;
const CC_MESSAGE = 0xb0;
Pattern.prototype.midi = function (output) {
return this.onTrigger((hap, currentTime, cps, targetTime) => {
return this.onSchedule((hap, targetTime, cps) => {
const currentTime = getAudioContext().currentTime;
let { note, nrpnn, nrpv, ccn, ccv, velocity = 0.9, gain = 1 } = hap.value;
//magic number to get audio engine to line up, can probably be calculated somehow
const latencyMs = 34;
+5 -3
View File
@@ -1,12 +1,14 @@
import { Pattern, ClockCollator } from '@strudel/core';
import { parseControlsFromHap } from 'node_modules/@strudel/osc/osc.mjs';
import { parseControlsFromHap } from '@strudel/osc/osc.mjs';
import { Invoke } from './utils.mjs';
import { getAudioContext } from '@strudel/webaudio/index.mjs';
const collator = new ClockCollator({});
export async function oscTriggerTauri(hap, currentTime, cps = 1, targetTime) {
export async function tauriOscOutput(hap, targetTime, cps = 1) {
const controls = parseControlsFromHap(hap, cps);
const params = [];
const currentTime = getAudioContext().currentTime;
const timestamp = collator.calculateTimestamp(currentTime, targetTime);
Object.keys(controls).forEach((key) => {
@@ -32,5 +34,5 @@ export async function oscTriggerTauri(hap, currentTime, cps = 1, targetTime) {
});
}
Pattern.prototype.osc = function () {
return this.onTrigger(oscTriggerTauri);
return this.onSchedule(tauriOscOutput);
};
+1
View File
@@ -23,6 +23,7 @@
},
"dependencies": {
"@strudel/core": "workspace:*",
"@strudel/webaudio": "workspace:*",
"@tauri-apps/api": "^2.2.0"
},
"homepage": "https://codeberg.org/uzu/strudel#readme",
+75
View File
@@ -0,0 +1,75 @@
/*
dough.mjs
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/osc/osc.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { register, noteToMidi } from '@strudel/core';
import { Dough, doughsamples } from 'dough-synth';
import { getAudioContext, ensureMinimalOutput } from '@strudel/webaudio';
import wasm from 'dough-synth/dough.wasm?url';
import workletCode from 'dough-synth/dough.js?raw';
Object.assign(globalThis, { doughsamples });
let D;
/**
*
* initializes dough ahead of time. you don't need to use this, but if you do, you can await it, so dough is ready before the pattern starts.
*
* @name initDough
* @tags external_io
* @memberof Pattern
* @example
* await initDough()
* $: chord("<Dm9 Dm11 Dm7>").offset("<-1 0 1 0>").voicing()
* .phaser(3).fm(1.4).fmh(1.01)
* .dough()
*/
export function initDough() {
if (!D || D.audioContext !== getAudioContext()) {
D = new Dough({
wasm,
workletCode,
audioContext: getAudioContext(),
});
}
return D.ready;
}
export async function doughOutput(hap, targetTime, cps = 1) {
const offset = D.context_offset?.[0];
if (!offset) {
return; // not ready
}
hap.ensureObjectValue();
const event = {
dough: 'play',
...hap.value,
time: targetTime - offset,
duration: hap.duration / cps,
};
if (typeof event.note === 'string') {
event.note = noteToMidi(event.note);
}
D.evaluate(event);
}
/**
*
* Uses dough as the audio engine. more info at https://dough.strudel.cc
*
* @name dough
* @tags external_io
* @memberof Pattern
* @returns Pattern
* @example
* $: chord("<Dm9 Dm11 Dm7>").offset("<-1 0 1 0>").voicing()
* .phaser(3).fm(1.4).fmh(1.01)
* .dough()
*/
export const dough = register('dough', (pat) => {
initDough();
ensureMinimalOutput();
return pat.onSchedule(doughOutput);
});
+42
View File
@@ -0,0 +1,42 @@
{
"name": "@strudel/dough",
"version": "1.3.2",
"description": "dough synth integration for strudel",
"main": "dough.mjs",
"type": "module",
"publishConfig": {
"main": "dist/index.mjs"
},
"scripts": {
"build": "vite build",
"prepublishOnly": "npm run build"
},
"repository": {
"type": "git",
"url": "git+https://codeberg.org/uzu/strudel.git"
},
"keywords": [
"tidalcycles",
"strudel",
"pattern",
"livecoding",
"algorave"
],
"author": "Felix Roos <flix91@gmail.com>",
"license": "AGPL-3.0-or-later",
"bugs": {
"url": "https://codeberg.org/uzu/strudel/issues"
},
"homepage": "https://codeberg.org/uzu/strudel#readme",
"dependencies": {
"@strudel/core": "workspace:*",
"@strudel/webaudio": "workspace:*",
"dough-synth": "0.2.4"
},
"devDependencies": {
"vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
}
}
+82 -71
View File
@@ -13,7 +13,7 @@ import {
getIsStarted,
getPattern,
getTime,
getTriggerFunc,
getScheduleFunc,
isPattern,
logger,
ref,
@@ -21,8 +21,8 @@ import {
} from '@strudel/core';
import { noteToMidi, getControlName } from '@strudel/core';
import { Note } from 'webmidi';
import { getAudioContext } from '@strudel/webaudio';
import { scheduleAtTime } from '../superdough/helpers.mjs';
import { getAudioContext, getClockBridge } from '@strudel/webaudio';
import { scheduleAtTime, ensureMinimalOutput } from '../superdough/helpers.mjs';
import { getMidiDeviceNamesString, getDevice } from './util.mjs';
import { MidiInput } from './input.mjs';
@@ -171,6 +171,23 @@ function normalize(value = 0, min = 0, max = 1, exp = 1) {
return Math.pow(normalized, exp);
}
const isFirefox = navigator?.userAgent?.includes('Firefox');
// call fn either directly with given time (non-firefox) or after scheduleAtTime with undefined (firefox)
// the scheduleAtTime approach is still jittery, but the best we can be on firefox
// firefox bug: https://bugzilla.mozilla.org/show_bug.cgi?id=2062997
function timedSend(timeMs, fn) {
if (isFirefox) {
const audioTime = getClockBridge().getAudioContextTime(timeMs);
if (!audioTime) {
logger('[midi]: skip event, not ready');
return;
}
scheduleAtTime(() => fn(undefined), audioTime);
} else {
fn(timeMs);
}
}
function mapCC(mapping, value) {
return Object.keys(value)
.filter((key) => !!mapping[getControlName(key)])
@@ -182,7 +199,7 @@ function mapCC(mapping, value) {
}
// sends a cc message to the given device on the given channel
function sendCC(ccn, ccv, device, midichan, targetTime) {
function sendCC(ccn, ccv, device, midichan, timeMs) {
if (typeof ccv !== 'number' || ccv < 0 || ccv > 1) {
throw new Error('expected ccv to be a number between 0 and 1');
}
@@ -190,23 +207,19 @@ function sendCC(ccn, ccv, device, midichan, targetTime) {
throw new Error('expected ccn to be a number or a string');
}
const scaled = Math.round(ccv * 127);
scheduleAtTime(() => {
device.sendControlChange(ccn, scaled, midichan);
}, targetTime);
timedSend(timeMs, (timeMs) => device.sendControlChange(ccn, scaled, { channels: midichan, time: timeMs }));
}
// sends a program change message to the given device on the given channel
function sendProgramChange(progNum, device, midichan, targetTime) {
function sendProgramChange(progNum, device, midichan, timeMs) {
if (typeof progNum !== 'number' || progNum < 0 || progNum > 127) {
throw new Error('expected progNum (program change) to be a number between 0 and 127');
}
scheduleAtTime(() => {
device.sendProgramChange(progNum, midichan);
}, targetTime);
timedSend(timeMs, (timeMs) => device.sendProgramChange(progNum, { channels: midichan, time: timeMs }));
}
// sends a sysex message to the given device on the given channel
function sendSysex(sysexid, sysexdata, device, targetTime) {
function sendSysex(sysexid, sysexdata, device, timeMs) {
if (Array.isArray(sysexid)) {
if (!sysexid.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
throw new Error('all sysexid bytes must be integers between 0 and 255');
@@ -221,13 +234,11 @@ function sendSysex(sysexid, sysexdata, device, targetTime) {
if (!sysexdata.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
throw new Error('all sysex bytes must be integers between 0 and 255');
}
scheduleAtTime(() => {
device.sendSysex(sysexid, sysexdata);
}, targetTime);
timedSend(timeMs, (timeMs) => device.sendSysex(sysexid, sysexdata, { time: timeMs }));
}
// sends a NRPN message to the given device on the given channel
function sendNRPN(nrpnn, nrpv, device, midichan, targetTime) {
function sendNRPN(nrpnn, nrpv, device, midichan, timeMs) {
if (Array.isArray(nrpnn)) {
if (!nrpnn.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
throw new Error('all nrpnn bytes must be integers between 0 and 255');
@@ -235,34 +246,29 @@ function sendNRPN(nrpnn, nrpv, device, midichan, targetTime) {
} 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');
}
scheduleAtTime(() => {
device.sendNRPN(nrpnn, nrpv, midichan);
}, targetTime);
timedSend(timeMs, (timeMs) => device.sendNrpnValue(nrpnn, nrpv, { channels: midichan, time: timeMs }));
}
// sends a pitch bend message to the given device on the given channel
function sendPitchBend(midibend, device, midichan, targetTime) {
function sendPitchBend(midibend, device, midichan, timeMs) {
if (typeof midibend !== 'number' || midibend < -1 || midibend > 1) {
throw new Error('expected midibend to be a number between -1 and 1');
}
scheduleAtTime(() => {
device.sendPitchBend(midibend, midichan);
}, targetTime);
timedSend(timeMs, (timeMs) => device.sendPitchBend(midibend, { channels: midichan, time: timeMs }));
}
// sends a channel aftertouch message to the given device on the given channel
function sendAftertouch(miditouch, device, midichan, targetTime) {
function sendAftertouch(miditouch, device, midichan, timeMs) {
if (typeof miditouch !== 'number' || miditouch < 0 || miditouch > 1) {
throw new Error('expected miditouch to be a number between 0 and 1');
}
scheduleAtTime(() => {
device.sendChannelAftertouch(miditouch, midichan);
}, targetTime);
timedSend(timeMs, (timeMs) => device.sendChannelAftertouch(miditouch, { channels: midichan, time: timeMs }));
}
// sends a note message to the given device on the given channel
function sendNote(note, velocity, duration, device, midichan, targetTime) {
function sendNote(note, velocity, duration, device, midichan, timeMs) {
if (note == null || note === '') {
throw new Error('note cannot be null or empty');
}
@@ -273,11 +279,10 @@ function sendNote(note, velocity, duration, device, midichan, targetTime) {
throw new Error('duration must be a positive number');
}
const midiNumber = typeof note === 'number' ? note : noteToMidi(note);
const midiNote = new Note(midiNumber, { attack: velocity, duration });
const midiNote = new Note(midiNumber, { attack: velocity });
scheduleAtTime(() => {
device.playNote(midiNote, midichan);
}, targetTime);
timedSend(timeMs, (timeMs) => device.sendNoteOn(midiNote, { channels: midichan, time: timeMs }));
timedSend(timeMs + duration, (timeMs) => device.sendNoteOff(midiNote, { channels: midichan, time: timeMs }));
}
/**
@@ -287,8 +292,6 @@ function sendNote(note, velocity, duration, device, midichan, targetTime) {
* @param {object} options Additional MIDI configuration options
* @example
* note("c4").midichan(1).midi('IAC Driver Bus 1')
* @example
* note("c4").midichan(1).midi('IAC Driver Bus 1', { controller: true, latency: 50 })
*/
Pattern.prototype.midi = function (midiport, options = {}) {
@@ -314,7 +317,7 @@ Pattern.prototype.midi = function (midiport, options = {}) {
let midiConfig = {
// Default configuration values
isController: false, // Disable sending notes for midi controllers
noteOffsetMs: 10, // Default note-off offset to prevent glitching in ms
noteOffsetMs: isFirefox ? 10 : 1, // Default note-off offset to prevent glitching in ms. firefox needs more slack
midichannel: 1, // Default MIDI channel
velocity: 0.9, // Default velocity
gain: 1, // Default gain
@@ -337,11 +340,19 @@ Pattern.prototype.midi = function (midiport, options = {}) {
logger(`Midi device disconnected! Available: ${getMidiDeviceNamesString(outputs)}`),
});
return this.onTrigger((hap, _currentTime, cps, targetTime) => {
ensureMinimalOutput();
return this.sortHapsByPart().onSchedule((hap, targetTime, cps) => {
if (!WebMidi.enabled) {
logger('Midi not enabled');
return;
}
const timeMs = getClockBridge().getPerformanceTime(targetTime);
if (!timeMs) {
logger('[midi] clockbridge not ready');
return;
}
hap.ensureObjectValue();
// midi event values from hap with configurable defaults
@@ -379,7 +390,7 @@ Pattern.prototype.midi = function (midiport, options = {}) {
// if midimap is set, send a cc messages from defined controls
if (midicontrolMap.has(midimap)) {
const ccs = mapCC(midicontrolMap.get(midimap), hap.value);
ccs.forEach(({ ccn, ccv }) => sendCC(ccn, ccv, device, midichan, targetTime));
ccs.forEach(({ ccn, ccv }) => sendCC(ccn, ccv, device, midichan, timeMs));
} else if (midimap !== 'default') {
// Add warning when a non-existent midimap is specified
logger(`[midi] midimap "${midimap}" not found! Available maps: ${[...midicontrolMap.keys()].join(', ')}`);
@@ -387,16 +398,20 @@ Pattern.prototype.midi = function (midiport, options = {}) {
// Handle note
if (note !== undefined && !midiConfig.isController) {
// note off messages will often a few ms arrive late,
// try to prevent glitching by subtracting noteOffsetMs from the duration length
const duration = (hap.duration.valueOf() / cps) * 1000 - midiConfig.noteOffsetMs;
// note off time is calculated early, together with note on time
// when the note off is due, the clock might have drifted, and the next note on message might happen before the note off
// this would lead to the next note being cut off
// this is why we make notes shorter by noteOffsetMs, so note offs happen earlier than the note ons after
const hapDuration = (hap.duration.valueOf() / cps) * 1000;
const offset = Math.min(midiConfig.noteOffsetMs, hapDuration / 2);
const duration = hapDuration - offset;
sendNote(note, velocity, duration, device, midichan, targetTime);
sendNote(note, velocity, duration, device, midichan, timeMs);
}
// Handle program change
if (progNum !== undefined) {
sendProgramChange(progNum, device, midichan, targetTime);
sendProgramChange(progNum, device, midichan, timeMs);
}
// Handle sysex
@@ -406,63 +421,53 @@ Pattern.prototype.midi = function (midiport, options = {}) {
// if sysexid is an array the first byte is 0x00
if (sysexid !== undefined && sysexdata !== undefined) {
sendSysex(sysexid, sysexdata, device, targetTime);
sendSysex(sysexid, sysexdata, device, timeMs);
}
// Handle control change
if (ccv !== undefined && ccn !== undefined) {
sendCC(ccn, ccv, device, midichan, targetTime);
sendCC(ccn, ccv, device, midichan, timeMs);
}
// Handle NRPN non-registered parameter number
if (nrpnn !== undefined && nrpv !== undefined) {
sendNRPN(nrpnn, nrpv, device, midichan, targetTime);
sendNRPN(nrpnn, nrpv, device, midichan, timeMs);
}
// Handle midibend
if (midibend !== undefined) {
sendPitchBend(midibend, device, midichan, targetTime);
sendPitchBend(midibend, device, midichan, timeMs);
}
// Handle miditouch
if (miditouch !== undefined) {
sendAftertouch(miditouch, device, midichan, targetTime);
sendAftertouch(miditouch, device, midichan, timeMs);
}
// Handle midicmd
if (hap.whole.begin + 0 === 0) {
// we need to start here because we have the timing info
scheduleAtTime(() => {
device.sendStart();
}, targetTime);
timedSend(timeMs, (timeMs) => device.sendStart({ time: timeMs }));
}
if (['clock', 'midiClock'].includes(midicmd)) {
scheduleAtTime(() => {
device.sendClock();
}, targetTime);
timedSend(timeMs, (timeMs) => device.sendClock({ time: timeMs }));
} else if (['start'].includes(midicmd)) {
scheduleAtTime(() => {
device.sendStart();
}, targetTime);
timedSend(timeMs, (timeMs) => device.sendStart({ time: timeMs }));
} else if (['stop'].includes(midicmd)) {
scheduleAtTime(() => {
device.sendStop();
}, targetTime);
timedSend(timeMs, (timeMs) => device.sendStop({ time: timeMs }));
} else if (['continue'].includes(midicmd)) {
scheduleAtTime(() => {
device.sendContinue();
}, targetTime);
timedSend(timeMs, (timeMs) => device.sendContinue({ time: timeMs }));
} else if (Array.isArray(midicmd)) {
if (midicmd[0] === 'progNum') {
sendProgramChange(midicmd[1], device, midichan, targetTime);
sendProgramChange(midicmd[1], device, midichan, timeMs);
} else if (midicmd[0] === 'cc') {
if (midicmd.length === 2) {
sendCC(midicmd[0], midicmd[1] / 127, device, midichan, targetTime);
sendCC(midicmd[0], midicmd[1] / 127, device, midichan, timeMs);
}
} else if (midicmd[0] === 'sysex') {
if (midicmd.length === 3) {
const [_, id, data] = midicmd;
sendSysex(id, data, device, targetTime);
sendSysex(id, data, device, timeMs);
}
}
}
@@ -537,6 +542,9 @@ export async function midin(input) {
* The note length is fixed as Superdough is not currently set up for undetermined
* note durations
*
* The 'midichan' control value contains the number of the channel the note is coming from
* so it could be filtered or manipulated further in the chain.
*
* @name midikeys
* @tags external_io, midi
* @param {string | number} input MIDI device name or index defaulting to 0
@@ -552,14 +560,18 @@ export async function midin(input) {
* .s("saw")
* .add(note(rand.mul(0.3)))
* .lpf(1000).lpe(2).room(0.5)
* @example
* // discard all notes not coming out from midi channel 2
* const kb = await midikeys('Arturia KeyStep 32')
* kb().filterValues(v=>v.midichan==2).s("tri")
*/
const kHaps = {};
const kListeners = {};
function _triggerKeyboard(input, cps, now, latencyCycles) {
const pattern = getPattern();
const trigger = getTriggerFunc();
if (!pattern || !trigger) {
const schedule = getScheduleFunc();
if (!pattern || !schedule) {
return false;
}
const t = now + latencyCycles;
@@ -576,8 +588,7 @@ function _triggerKeyboard(input, cps, now, latencyCycles) {
return;
}
const t = ctxNow + (hap.whole.begin - now) / cps;
const duration = hap.duration / cps;
trigger(hap, t - ctxNow, duration, cps, t);
schedule(hap, t, cps);
});
return true;
@@ -612,7 +623,7 @@ export async function midikeys(input) {
noteoff ||= noteon && velocity === 0; // handle devices which may use velocity = 0 to signal noteoff
const key = `${input}_${note}`;
const cps = getCps() ?? 0.5;
const triggerAvailable = !!(getPattern() && getTriggerFunc());
const triggerAvailable = !!(getPattern() && getScheduleFunc());
const latencySeconds = triggerAvailable ? 0.01 : 0.06; // avoid missing notes due to cyclist / trigger latency
const now = getTime();
const t = now + latencySeconds * cps;
@@ -633,7 +644,7 @@ export async function midikeys(input) {
*/
return;
} else {
value = { ...value, note: Math.round(note), velocity: velocity / 127 };
value = { ...value, note: Math.round(note), velocity: velocity / 127, midichan: message.channel };
}
kHaps[input].push(new Hap(span, span, value, {}));
if (!noteoff && triggerAvailable) {
+50
View File
@@ -84,6 +84,22 @@ describe('mini', () => {
it('supports patterning euclidean rhythms', () => {
expect(minS('[a(<3 5>, <8 16>)]*2')).toEqual(minS('a(3,8) a(5,16)'));
});
it('supports inverted euclidean rhythms', () => {
expect(minS('a(-3, 8)')).toEqual(['a: 1/8 - 1/4', 'a: 1/4 - 3/8', 'a: 1/2 - 5/8', 'a: 5/8 - 3/4', 'a: 7/8 - 1']);
});
it('supports euclidean legato rhythms', () => {
expect(minS('a(3, -8)')).toEqual(['a: 0 - 3/8', 'a: 3/8 - 3/4', 'a: 3/4 - 1']);
});
it('supports inverted euclidean legato rhythms', () => {
expect(minS('a(-3, -8)')).toEqual([
'a: -1/8 - 1/8',
'a: 1/8 - 1/4',
'a: 1/4 - 1/2',
'a: 1/2 - 5/8',
'a: 5/8 - 7/8',
'a: 7/8 - 9/8',
]);
});
it("reproduces Toussaint's example euclidean algorithms", () => {
const checkEuclid = function (spec, target) {
expect(minS(`x(${spec[0]},${spec[1]})`)).toEqual(minS(target));
@@ -118,6 +134,40 @@ describe('mini', () => {
checkEuclid([11, 24], 'x ~ ~ x ~ x ~ x ~ x ~ x ~ ~ x ~ x ~ x ~ x ~ x ~');
checkEuclid([13, 24], 'x ~ x x ~ x ~ x ~ x ~ x ~ x x ~ x ~ x ~ x ~ x ~');
});
it("reproduces Toussaint's example euclidean legato algorithms", () => {
const checkEuclid = function (spec, target) {
expect(minS(`x(${spec[0]},${spec[1]})`)).toEqual(minS(target));
};
checkEuclid([1, -2], 'x _');
checkEuclid([1, -3], 'x _ _');
checkEuclid([1, -4], 'x _ _ _');
checkEuclid([4, -12], 'x _ _ x _ _ x _ _ x _ _');
checkEuclid([2, -5], 'x _ x _ _');
// checkEuclid([3, 4], "x _ x x"); // Toussaint is wrong..
checkEuclid([3, -4], 'x x x _'); // correction
checkEuclid([3, -5], 'x _ x _ x');
checkEuclid([3, -7], 'x _ x _ x _ _');
checkEuclid([3, -8], 'x _ _ x _ _ x _');
checkEuclid([4, -7], 'x _ x _ x _ x');
checkEuclid([4, -9], 'x _ x _ x _ x _ _');
checkEuclid([4, -11], 'x _ _ x _ _ x _ _ x _');
// checkEuclid([5, 6], "x _ x x x x"); // Toussaint is wrong..
checkEuclid([5, -6], 'x x x x x _'); // correction
checkEuclid([5, -7], 'x _ x x _ x x');
checkEuclid([5, -8], 'x _ x x _ x x _');
checkEuclid([5, -9], 'x _ x _ x _ x _ x');
checkEuclid([5, -11], 'x _ x _ x _ x _ x _ _');
checkEuclid([5, -12], 'x _ _ x _ x _ _ x _ x _');
// checkEuclid([5, 16], "x _ _ x _ _ x _ _ x _ _ x _ _ _ _"); // Toussaint is wrong..
checkEuclid([5, -16], 'x _ _ x _ _ x _ _ x _ _ x _ _ _'); // correction
// checkEuclid([7, 8], "x _ x x x x x x"); // Toussaint is wrong..
checkEuclid([7, -8], 'x x x x x x x _'); // Correction
checkEuclid([7, -12], 'x _ x x _ x _ x x _ x _');
checkEuclid([7, -16], 'x _ _ x _ x _ x _ _ x _ x _ x _');
checkEuclid([9, -16], 'x _ x x _ x _ x _ x x _ x _ x _');
checkEuclid([11, -24], 'x _ _ x _ x _ x _ x _ x _ _ x _ x _ x _ x _ x _');
checkEuclid([13, -24], 'x _ x x _ x _ x _ x _ x _ x x _ x _ x _ x _ x _');
});
it('supports the - alias for ~', () => {
expect(minS('a - b [- c]')).toEqual(minS('a ~ b [~ c]'));
});
-1
View File
@@ -46,7 +46,6 @@ lib[':'] = tail;
lib['..'] = range;
lib['def'] = () => silence;
lib['or'] = (...children) => chooseIn(...children); // always has structure but is cyclewise.. e.g. "s oh*8.dec[.04 | .5]"
//lib['or'] = (...children) => chooseOut(...children); // "s oh*8.dec[.04 | .5]" is better but "dec[.04 | .5].s oh*8" has no struct
function evaluator(node, scope) {
const { type } = node;
+38 -33
View File
@@ -4,7 +4,7 @@ Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Pattern, isPattern } from '@strudel/core';
import { Pattern, getClockTime, createParams } from '@strudel/core';
import Paho from 'paho-mqtt';
const connections = {};
@@ -61,7 +61,6 @@ Pattern.prototype.mqtt = function (
const props = {
onSuccess: onConnect,
onFailure: onFailure,
useSSL: true,
};
if (username) {
@@ -81,41 +80,47 @@ Pattern.prototype.mqtt = function (
}
cx.connect(props);
}
return this.withHap((hap) => {
const onTrigger = (hap, currentTime, cps, targetTime) => {
let msg_topic = topic;
if (!cx || !cx.isConnected()) {
return;
}
let message = '';
if (typeof hap.value === 'object') {
let value = hap.value;
return this.onSchedule((hap, targetTime, cps) => {
let msg_topic = topic;
if (!cx || !cx.isConnected()) {
return;
}
let message = '';
if (typeof hap.value === 'object') {
let value = hap.value;
// Try to take topic from pattern if it's not set
if (typeof msg_topic === 'undefined' && 'topic' in value) {
msg_topic = value.topic;
if (Array.isArray(msg_topic)) {
msg_topic = msg_topic.join('/');
}
msg_topic = '/' + msg_topic;
// Try to take topic from pattern if it's not set
if (typeof msg_topic === 'undefined' && 'topic' in value) {
msg_topic = value.topic;
if (Array.isArray(msg_topic)) {
msg_topic = msg_topic.join('/');
}
if (add_meta) {
const duration = hap.duration.div(cps);
value = { ...value, duration: duration.valueOf(), cps: cps };
}
message = JSON.stringify(value);
} else {
message = hap.value;
msg_topic = '/' + msg_topic;
}
message = new Paho.Message(message);
message.destinationName = msg_topic;
if (add_meta) {
const duration = hap.duration.div(cps);
value = { ...value, duration: duration.valueOf(), cps: cps };
}
message = JSON.stringify(value);
} else {
message = hap.value;
}
message = new Paho.Message(message);
message.destinationName = msg_topic;
const offset = (targetTime - currentTime + latency) * 1000;
const offset = (targetTime - getClockTime() + latency) * 1000;
window.setTimeout(function () {
cx.send(message);
}, offset);
};
return hap.setContext({ ...hap.context, onTrigger, dominantTrigger: true });
window.setTimeout(function () {
cx.send(message);
}, offset);
});
};
// This adds the 'move' and 'motor' commands to strudel
export const { move, motor } = createParams('move', 'motor');
window.move = move;
window.motor = motor;
// This adds the 'robot' command
Pattern.prototype.robot = function (robot_id, address = 'ws://192.168.8.248:9001/mqtt') {
return this.mqtt(undefined, undefined, '/move/' + robot_id, address);
};
+16 -8
View File
@@ -4,7 +4,8 @@ Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { logger, parseNumeral, register, isNote, noteToMidi, ClockCollator } from '@strudel/core';
import { logger, parseNumeral, register, isNote, noteToMidi } from '@strudel/core';
import { ensureMinimalOutput, getClockBridge } from '@strudel/webaudio';
let connection; // Promise<OSC>
function connect() {
@@ -42,10 +43,9 @@ export function parseControlsFromHap(hap, cps) {
if (isNote(controls.note)) {
controls.midinote = noteToMidi(controls.note, controls.octave || 3);
} else {
controls.note = parseNumeral(controls.note);
controls.midinote = parseNumeral(controls.note);
}
}
controls.bank && (controls.s = controls.bank + controls.s);
controls.roomsize && (controls.size = parseNumeral(controls.roomsize));
// speed adjustment for CPS is handled on the DSP side in superdirt and pattern side in Strudel,
// so we need to undo the adjustment before sending the message to superdirt.
@@ -55,13 +55,18 @@ export function parseControlsFromHap(hap, cps) {
return controls;
}
const collator = new ClockCollator({});
export async function oscTrigger(hap, currentTime, cps = 1, targetTime) {
export async function oscOutput(hap, targetTime, cps = 1) {
const ws = await connect();
const timeMs = getClockBridge().getPerformanceTime(targetTime);
if (!timeMs) {
logger('[osc] clockbridge not ready');
return;
}
const ts = performance.timeOrigin + timeMs;
const controls = parseControlsFromHap(hap, cps);
const keyvals = Object.entries(controls).flat();
const ts = collator.calculateTimestamp(currentTime, targetTime) * 1000;
const msg = { address: '/dirt/play', args: keyvals, timestamp: ts };
if ('oschost' in hap.value) {
@@ -83,4 +88,7 @@ export async function oscTrigger(hap, currentTime, cps = 1, targetTime) {
* @memberof Pattern
* @returns Pattern
*/
export const osc = register('osc', (pat) => pat.onTrigger(oscTrigger));
export const osc = register('osc', (pat) => {
ensureMinimalOutput();
return pat.onSchedule(oscOutput);
});
+1
View File
@@ -38,6 +38,7 @@
"homepage": "https://codeberg.org/uzu/strudel#readme",
"dependencies": {
"@strudel/core": "workspace:*",
"@strudel/webaudio": "workspace:*",
"osc": "^2.4.5",
"ws": "^8.18.3"
},
+4 -7
View File
@@ -1,10 +1,7 @@
/* import { oscTriggerTauri } from '../desktopbridge/oscbridge.mjs';
/* import { tauriOscOutput } from '../desktopbridge/oscbridge.mjs';
import { isTauri } from '../desktopbridge/utils.mjs'; */
import { oscTrigger } from './osc.mjs';
import { oscOutput } from './osc.mjs';
const trigger = /* isTauri() ? oscTriggerTauri : */ oscTrigger;
const output = /* isTauri() ? tauriOscOutput : */ oscOutput;
export const superdirtOutput = (hap, deadline, hapDuration, cps, targetTime) => {
const currentTime = performance.now() / 1000;
return trigger(hap, currentTime, cps, targetTime);
};
export const superdirtOutput = (hap, targetTime, cps) => output(hap, targetTime, cps);
+1 -1
View File
@@ -37,7 +37,7 @@ if (typeof HTMLElement !== 'undefined') {
const drawContext = getDrawContext();
const drawTime = [-2, 2];
this.editor = new StrudelMirror({
defaultOutput: webaudioOutput,
onSchedule: webaudioOutput,
getTime: () => getAudioContext().currentTime,
transpiler,
root: container,
+1
View File
@@ -98,6 +98,7 @@ if (args.includes('--json')) {
}
writeFileSync(outFile, json, 'utf8');
console.log(`Wrote json to ${outFile}`);
process.exit(0);
}
console.log(
+40 -43
View File
@@ -4,7 +4,7 @@ Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Pattern, isPattern } from '@strudel/core';
import { Pattern, getClockTime } from '@strudel/core';
var writeMessagers = {};
var choosing = false;
@@ -64,53 +64,50 @@ function crc16(data) {
}
Pattern.prototype.serial = function (br = 115200, sendcrc = false, singlecharids = false, name = 'default') {
return this.withHap((hap) => {
if (!(name in writeMessagers)) {
getWriter(name, br);
}
const onTrigger = (hap, currentTime, _cps, targetTime) => {
var message = '';
var chk = 0;
if (typeof hap.value === 'object') {
if ('action' in hap.value) {
var action = hap.value['action'];
if (!(name in writeMessagers)) {
getWriter(name, br);
}
return this.onSchedule((hap, targetTime) => {
var message = '';
var chk = 0;
if (typeof hap.value === 'object') {
if ('action' in hap.value) {
var action = hap.value['action'];
if (singlecharids) {
action = action.charAt(0);
}
message += action + '(';
var first = true;
for (var [key, val] of Object.entries(hap.value)) {
if (key === 'action') {
continue;
}
if (first) {
first = false;
} else {
message += ',';
}
if (singlecharids) {
action = action.charAt(0);
}
message += action + '(';
var first = true;
for (var [key, val] of Object.entries(hap.value)) {
if (key === 'action') {
continue;
}
if (first) {
first = false;
} else {
message += ',';
}
if (singlecharids) {
key = key.charAt(0);
}
message += key + ':' + val;
}
message += ')';
if (sendcrc) {
chk = crc16(message);
}
} else {
for (const [key, val] of Object.entries(hap.value)) {
message += `${key}:${val}`;
key = key.charAt(0);
}
message += key + ':' + val;
}
message += ')';
if (sendcrc) {
chk = crc16(message);
}
} else {
message = hap.value;
for (const [key, val] of Object.entries(hap.value)) {
message += `${key}:${val}`;
}
}
const offset = (targetTime - currentTime + latency) * 1000;
} else {
message = hap.value;
}
const offset = (targetTime - getClockTime() + latency) * 1000;
window.setTimeout(function () {
writeMessagers[name](message, chk);
}, offset);
};
return hap.setContext({ ...hap.context, onTrigger, dominantTrigger: true });
window.setTimeout(function () {
writeMessagers[name](message, chk);
}, offset);
});
};
+2 -3
View File
@@ -479,7 +479,7 @@ export default {
gm_electric_bass_finger: [
// Electric Bass (finger): Bass
'0330_JCLive_sf2_file',
'0330_FluidR3_GM_sf2_fible',
'0330_FluidR3_GM_sf2_file',
'0330_Aspirin_sf2_file',
//0330_Chaos_sf2_file // same as last
'0330_GeneralUserGS_sf2_file',
@@ -527,7 +527,7 @@ export default {
'0370_Aspirin_sf2_file',
// 0370_Chaos_sf2_file // same as last
'0370_FluidR3_GM_sf2_file',
'0370_GeneralUserGS_sf2_fil e',
'0370_GeneralUserGS_sf2_file',
'0370_JCLive_sf2_file',
//0370_SBLive_sf2
//0370_SoundBlasterOld_sf2
@@ -1782,6 +1782,5 @@ export default {
'1272_GeneralUserGS_sf2_file',
'1273_GeneralUserGS_sf2_file',
'1274_GeneralUserGS_sf2_file',
'',
],
};
+1 -1
View File
@@ -3,7 +3,7 @@ import { getAudioContext, registerSound } from '@strudel/webaudio';
import { loadSoundfont as _loadSoundfont, startPresetNote } from 'sfumato';
Pattern.prototype.soundfont = function (sf, n = 0) {
return this.onTrigger((h, ct, cps, targetTime) => {
return this.onSchedule((h, targetTime) => {
const ctx = getAudioContext();
const note = getPlayableNoteValue(h);
const preset = sf.presets[n % sf.presets.length];
+19 -3
View File
@@ -7,15 +7,24 @@ Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
let audioContext;
import { clearNodePool } from './nodePools.mjs';
import { ClockBridge } from './clockbridge.mjs';
let audioContext, clockBridge;
export const setDefaultAudioContext = () => {
audioContext = new AudioContext();
return audioContext;
return setAudioContext(new AudioContext());
};
export const setAudioContext = (context) => {
// Existing nodes in the node pool contain references to the previous AudioContext,
// so all the nodes in the pool must be cleared when we set a new AudioContext.
clearNodePool();
if (audioContext && audioContext.state !== 'closed') {
audioContext.close();
}
audioContext = context;
clockBridge = new ClockBridge(audioContext);
return audioContext;
};
@@ -27,6 +36,13 @@ export const getAudioContext = () => {
return audioContext;
};
export function getClockBridge() {
if (!clockBridge) {
getAudioContext(); // creates clockBridge
}
return clockBridge;
}
export function getAudioContextCurrentTime() {
return getAudioContext().currentTime;
}
+41
View File
@@ -0,0 +1,41 @@
/*
clockbridge.mjs
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/index.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// thanks freya https://youtu.be/LSNQuFEDOyQ?si=ukZI2IGgWV_NDZzP&t=2979
export function expDecay(a, b, decay, dt) {
return b + (a - b) * Math.exp(-decay * dt);
}
// translates between audio and performance clock
export class ClockBridge {
p; // smoothed clock offset
lastTime;
audioContext;
constructor(audioContext) {
this.audioContext = audioContext;
}
// delta between audio and performance time in ms
getOffset() {
const { contextTime, performanceTime } = this.audioContext.getOutputTimestamp();
if (!contextTime || !performanceTime) {
return;
}
const offset = performanceTime - contextTime * 1000; // clock offset in ms
const dt = performanceTime - (this.lastTime ?? performanceTime); // delta time since last hap
const decay = 1 / 10000; // how fast offset changes have an effect
this.p = expDecay(this.p ?? offset, offset, decay, dt); // smooth clock offset
this.lastTime = performanceTime;
return this.p;
}
getPerformanceTime(audioContextTime) {
const offset = this.getOffset();
return audioContextTime * 1000 + offset; // this is now correct in performance time (ms)
}
getAudioContextTime(performanceTime) {
const offset = this.getOffset();
return (performanceTime - offset) / 1000; // this is now correct in audio context time (seconds)
}
}
+4 -3
View File
@@ -67,13 +67,14 @@ if (typeof window !== 'undefined') {
});
}
export const dough = async (code) => {
export const rawdsp = async (code) => {
const ac = getAudioContext();
stop();
worklet = await dspWorklet(ac, code);
worklet.node.connect(ac.destination);
};
export function doughTrigger(hap, currentTime, cps, targetTime) {
window.postMessage({ time: targetTime, dough: hap.value, currentTime, duration: hap.duration, cps });
export function rawdspOutput(hap, time, cps) {
const currentTime = getAudioContext().currentTime;
window.postMessage({ time, dough: hap.value, currentTime, duration: hap.duration, cps });
}
+18 -3
View File
@@ -6,12 +6,27 @@ import { clamp, nanFallback, midiToFreq, noteToMidi } from './util.mjs';
export const noises = ['pink', 'white', 'brown', 'crackle'];
export function gainNode(value) {
const node = getAudioContext().createGain();
export function gainNode(value, audioContext = getAudioContext()) {
const node = audioContext.createGain();
node.gain.value = value;
return node;
}
// this helper makes sure the audio context is "used", meaning it outputs something
// this prevents the browser from throttling timing accuracy
// it happened when only midi was running, the clock got more drifty without this
let constantNode, constantNodeAudioContext;
export function ensureMinimalOutput() {
if (constantNode && constantNodeAudioContext === getAudioContext()) {
return;
}
constantNodeAudioContext = getAudioContext();
constantNode = new ConstantSourceNode(constantNodeAudioContext);
constantNode.offset.value = 1e-7;
constantNode.connect(constantNodeAudioContext.destination);
constantNode.start();
}
export function effectSend(input, effect, wet) {
const send = gainNode(wet);
input.connect(send);
@@ -374,7 +389,7 @@ export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) {
// Certain browsers requires audio nodes to be connected in order for their onended events
// to fire, so we _mute it_ and then connect it to the destination
const zeroGain = gainNode(0);
const zeroGain = gainNode(0, audioContext);
zeroGain.connect(audioContext.destination);
constantNode.connect(zeroGain);
+1
View File
@@ -14,3 +14,4 @@ export * from './modulators.mjs';
export * from './dspworklet.mjs';
export * from './audioContext.mjs';
export * from './wavetable.mjs';
export * from './clockbridge.mjs';
+13
View File
@@ -5,11 +5,24 @@ Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { releaseAudioNode } from './helpers.mjs';
const nodePools = new Map();
const POOL_KEY = Symbol('nodePoolKey');
export const isPoolable = (node) => !!node[POOL_KEY];
export const clearNodePool = () => {
for (const pool of nodePools) {
for (const node of pool) {
if (node instanceof AudioNode) {
releaseAudioNode(node);
}
}
}
nodePools.clear();
};
const getNodeTime = (node) => {
return node.context?.currentTime ?? 0;
};
+3 -2
View File
@@ -36,8 +36,9 @@ function humanFileSize(bytes, si) {
*
* Note: `must` be called with await, otherwise you'll get a pending Promise object.
*
* @name getDuration,getDur
* @tag samples
* @name getDuration
* @synonym getDur
* @tags samples
* @param {string} sampleName
* @param {number} (optional) n
*
-4
View File
@@ -1048,7 +1048,3 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
}
});
};
export const superdoughTrigger = (t, hap, ct, cps) => {
superdough(hap, t - ct, hap.duration / cps, cps);
};
+8 -1
View File
@@ -137,7 +137,14 @@ export const { transpose, trans } = register(['transpose', 'trans'], function tr
const interval = !isNaN(Number(intervalOrSemitones))
? Interval.fromSemitones(intervalOrSemitones)
: String(intervalOrSemitones);
const targetNote = Note.transpose(note, interval);
let n = Note.get(note);
if (n.oct == undefined) {
n.oct = 3;
let n1 = Note.get(Note.transpose(n, interval));
n.oct = n1.oct == 3 ? undefined : 3;
}
const targetNote = Note.transpose(n, interval);
if (typeof hap.value === 'object') {
return hap.withValue(() => ({ ...hap.value, note: targetNote }));
}
+14 -3
View File
@@ -134,6 +134,16 @@ let modeTarget = {
duck: (v) => v.slice(-1)[0],
above: (v) => v[0],
root: (v) => v[0],
oldabove: (v) => v[0],
oldroot: (v) => v[0],
};
let modeMult = {
below: 1,
duck: 1,
above: -1,
root: -1,
oldabove: 1,
oldroot: 1,
};
export function renderVoicing({ chord, dictionary, offset = 0, n, mode = 'below', anchor = 'c5', octaves = 1 }) {
@@ -144,19 +154,20 @@ export function renderVoicing({ chord, dictionary, offset = 0, n, mode = 'below'
const voicings = dictionary[symbol].map((voicing) =>
(typeof voicing === 'string' ? voicing.split(' ') : voicing).map(step2semitones),
);
const mult = modeMult[mode];
let minDistance, bestIndex;
// calculate distances up from voicing top notes
let chromaDiffs = voicings.map((v, i) => {
const targetStep = modeTarget[mode](v);
const diff = _mod(anchorChroma - targetStep - rootChroma, 12);
const diff = _mod((anchorChroma - targetStep - rootChroma) * mult, 12);
if (minDistance === undefined || diff < minDistance) {
minDistance = diff;
bestIndex = i;
}
return diff;
return diff * mult;
});
if (mode === 'root') {
if (mode === 'root' || mode === 'oldroot') {
bestIndex = 0;
}
+3
View File
@@ -185,6 +185,9 @@ export const rootNotes = register('rootNotes', function (octave, pat) {
* - `below`: top note <= anchor
* - `duck`: top note <= anchor, anchor excluded
* - `above`: bottom note >= anchor
* - `root`: bottom note is the lowest root of the chord >= anchor
* - `oldabove` : old (buggy) behavior of above, kept for legacy reason
* - `oldroot` : old (buggy) behavior of root, kept for legacy reason
* - `offset`: whole number that shifts the voicing up or down to the next voicing
* - `n`: if set, the voicing is played like a scale. Overshooting numbers will be octaved
*
+2 -1
View File
@@ -6,7 +6,7 @@ export * from '@strudel/mini';
export * from '@strudel/edo';
export * from '@strudel/tonal';
export * from '@strudel/webaudio';
import { Pattern, evalScope, setTime } from '@strudel/core';
import { Pattern, evalScope, setTime, setClockTime } from '@strudel/core';
import { initAudioOnFirstClick, registerSynthSounds, webaudioRepl } from '@strudel/webaudio';
// import { registerSoundfonts } from '@strudel/soundfonts';
import { evaluate as _evaluate, transpiler } from '@strudel/transpiler';
@@ -41,6 +41,7 @@ export function initStrudel(options = {}) {
return repl;
})();
setTime(() => repl.scheduler.now());
setClockTime(() => repl.scheduler.getTime()); // TODO: refactor?
return initDone;
}
+1 -1
View File
@@ -19,7 +19,7 @@ initAudioOnFirstClick();
const ctx = getAudioContext();
const { scheduler } = repl({
defaultOutput: webaudioOutput,
onSchedule: webaudioOutput,
getTime: () => ctx.currentTime
});
+3 -3
View File
@@ -20,7 +20,7 @@ const soundMap = new Map();
const loadedSounds = new Map();
Pattern.prototype.supradough = function () {
return this.onTrigger((hap, __, cps, begin) => {
return this.onSchedule((hap, begin, cps) => {
hap.value._begin = begin;
hap.value._duration = hap.duration / cps;
!doughWorklet && initDoughWorklet();
@@ -114,11 +114,11 @@ async function fetchSample(url) {
return { channels, sampleRate: buffer.sampleRate };
}
export async function doughsamples(sampleMap, baseUrl) {
export async function supradoughsamples(sampleMap, baseUrl) {
if (typeof sampleMap === 'string') {
const [json, base] = await fetchSampleMap(sampleMap);
// console.log('json', json, 'base', base);
return doughsamples(json, base);
return supradoughsamples(json, base);
}
Object.entries(sampleMap).map(async ([key, urls]) => {
if (key !== '_base') {
+91 -31
View File
@@ -9,7 +9,7 @@ import {
superdough,
getAudioContext,
setLogger,
doughTrigger,
rawdspOutput,
registerWorklet,
setAudioContext,
initAudio,
@@ -32,8 +32,8 @@ const hap2value = (hap) => {
};
// uses more precise, absolute t if available, see https://github.com/tidalcycles/strudel/pull/1004
// TODO: refactor output callbacks to eliminate deadline
export const webaudioOutput = (hap, _deadline, hapDuration, cps, t) => {
export const webaudioOutput = (hap, t, cps) => {
const hapDuration = hap.duration / cps;
return superdough(hap2value(hap), t, hapDuration, cps, hap.whole?.begin.valueOf());
};
@@ -56,32 +56,14 @@ export async function renderPatternAudio(
maxPolyphony,
multiChannelOrbits,
});
logger('[webaudio] preloading');
// Calling superdough(...) in ascending onset time order is important
// for controls that depend on the audio graph state like `cut`
let haps = pattern
.queryArc(begin, end, { _cps: cps })
.sort((a, b) => a.whole.begin.valueOf() - b.whole.begin.valueOf());
for (const hap of haps) {
if (hap.hasOnset()) {
try {
await superdough(
hap2value(hap),
(hap.whole.begin.valueOf() - begin) / cps,
hap.duration / cps,
cps,
(hap.whole?.begin.valueOf() - begin) / cps,
);
} catch (err) {
errorLogger(err, 'webaudio');
}
}
}
logger('[webaudio] start rendering');
return audioContext
.startRendering()
// Firefox currently doesn't support suspending an OfflineAudioContext,
// so no chunked rendering. Bad performance, but at least it works.
return (
audioContext.suspend === undefined
? renderPatternAudioWhole(audioContext, pattern, cps, begin, end)
: renderPatternAudioInChunks(audioContext, pattern, cps, begin, end, 1)
)
.then((renderedBuffer) => {
const wavBuffer = audioBufferToWav(renderedBuffer);
const blob = new Blob([wavBuffer], { type: 'audio/wav' });
@@ -102,19 +84,97 @@ export async function renderPatternAudio(
});
}
async function renderPatternAudioWhole(audioContext, pattern, cps, begin, end) {
logger(`[webaudio] preloading`);
await scheduleHapsChunk(pattern, cps, begin, begin, end);
logger('[webaudio] start rendering');
return audioContext.startRendering();
}
async function renderPatternAudioInChunks(audioContext, pattern, cps, begin, end, chunkSizeInCycles) {
let currentCycle = begin;
let renderPromise = null;
logger('[webaudio] start rendering');
while (currentCycle <= end) {
const chunkStart = currentCycle;
const chunkEnd = Math.min(currentCycle + chunkSizeInCycles, end);
logger(`[webaudio] preloading cycles ${chunkStart} - ${chunkEnd}`);
await scheduleHapsChunk(pattern, cps, begin, chunkStart, chunkEnd);
logger(`[webaudio] rendering cycles ${chunkStart} - ${chunkEnd}`);
currentCycle += chunkSizeInCycles;
// According to the MDN docs, suspends should be scheduled while
// the audioContext is not currently running for better precision.
// So we schedule the suspend first, and await after resuming.
var suspendPromise;
if (currentCycle < end) {
// Make sure to suspend one cycle before the next currentCycle
// so the next haps can be scheduled on time.
suspendPromise = audioContext.suspend((currentCycle - begin - 1) / cps);
}
if (renderPromise === null) {
renderPromise = audioContext.startRendering();
} else {
await audioContext.resume();
}
if (currentCycle < end) {
await suspendPromise;
}
}
logger('[webaudio] finish rendering');
return renderPromise;
}
async function scheduleHapsChunk(pattern, cps, begin, chunkStart, chunkEnd) {
// Calling superdough(...) in ascending onset time order is important
// for controls that depend on the audio graph state like `cut`
let haps = pattern
.queryArc(chunkStart, chunkEnd, { _cps: cps })
.sort((a, b) => a.whole.begin.valueOf() - b.whole.begin.valueOf());
for (const hap of haps) {
if (hap.hasOnset()) {
try {
await superdough(
hap2value(hap),
(hap.whole.begin.valueOf() - begin) / cps,
hap.duration / cps,
cps,
(hap.whole?.begin.valueOf() - begin) / cps,
);
} catch (err) {
errorLogger(err, 'webaudio');
}
}
}
}
export function webaudioRepl(options = {}) {
const audioContext = options.audioContext ?? getAudioContext();
setAudioContext(audioContext);
options = {
getTime: () => audioContext.currentTime,
defaultOutput: webaudioOutput,
onSchedule: webaudioOutput,
...options,
};
return repl(options);
}
Pattern.prototype.dough = function () {
return this.onTrigger(doughTrigger, 1);
Pattern.prototype.rawdsp = function () {
return this.onSchedule(rawdspOutput, 1);
};
function audioBufferToWav(buffer, opt) {
+3
View File
@@ -8,11 +8,14 @@ import Tune from './tunejs.js';
import { register } from '@strudel/core';
/**
* Deprecated.
*
* Assumes pattern contains numerical scale degrees on the `i` control (see examples below). Accepts a scale name or list of frequencies (see all available names at the link on the reference). Returns a new pattern with all values mapped to a frequency ratio. Similar to `xen`.
* @name tune
* @returns Pattern
* @memberof Pattern
* @param {(string | number[] )} scale
* @deprecated
* @example
* i("0 1 2 3 4 5").tune("hexany15").mul("220").freq()
* @example
+106 -29
View File
@@ -4,7 +4,7 @@ Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { register, _mod, parseNumeral, removeUndefineds } from '@strudel/core';
import { register, _mod, parseNumeral, removeUndefineds, Pattern, parray, State } from '@strudel/core';
import Tune from './tunejs.js';
// returns a list of frequency ratios for given edo scale
@@ -61,14 +61,8 @@ function xenOffset(xenScale, offset, index = 0) {
const trimFreq = (freq) => parseFloat(freq.toPrecision(10));
// accepts a scale name such as 31edo, and a pattern
// pattern expected to follow format such that a value can be mapped
// to an edostep within the scale. Returns the pattern with
// values mapped to the frequencies associated with the given edosteps
// scaleNameOrRatios: string || number[], steps?: number
/**
* Assumes a numerical pattern of scale steps, and a scale. Scales accepted are all preset scale names of `tune`, arbitrary edos such as 31edo, or an array of frequency ratios. Assumes scales repeat at octave (2/1). Returns a new pattern with all values mapped to their associated frequency, assuming a base frequency of 220hz.
* Assumes a numerical pattern of scale steps, and a scale. Scales accepted are all preset scale names of `tune`, arbitrary edos such as 31edo, or an array of frequency ratios. Assumes scales repeat at octave (2/1). Returns a new pattern with all values mapped to their associated frequency, assuming a base frequency of 220hz. See first 4 examples for the different ways you can input a scale into `xen`
*
* @name xen
* @returns Pattern
@@ -87,6 +81,15 @@ const trimFreq = (freq) => parseFloat(freq.toPrecision(10));
* Math.pow(2, 18/31),
* ]).piano()
* @example
* // However, with the above you won't get highlighting of the scale indices,
* // instead you could do something like:
* i("[<2 ->, 0 1 <- 2> 3]*2").xen([
* "1:1", // tonic
* "5:4", // Major 3rd
* "[3:2 4:3]", // Perfect 5th, 4th
* "5:3" // Major 6th
* ]).s("tri")
* @example
* // xen also supports all scale names that
* // tune does:
* i("0 1 2 3 4 5").xen("hexany15")
@@ -96,27 +99,100 @@ const trimFreq = (freq) => parseFloat(freq.toPrecision(10));
* i("0 1 2 3 4 5 6 7").xen("<5edo 10edo 15edo hexany15>")
*/
export const xen = register('xen', function (scaleNameOrRatios, pat) {
return pat.withHaps((haps) => {
haps = haps.map((hap) => {
let hVal = hap.value;
const isObject = typeof hVal === 'object';
if (!isObject) {
throw new Error(`Expected hap to have control 'i' set, but received ${hap.value.i}, try wrapping input in i()`);
}
const { i, ...otherValues } = hVal;
const scale = getXenScale(scaleNameOrRatios);
let freq = xenOffset(scale, parseNumeral(hVal.i));
// 10 is somewhat arbitrary
freq = trimFreq(freq);
hap.value = { ...otherValues, freq };
return isEdo(scaleNameOrRatios)
? hap.setContext({ ...hap.context, edoSize: scaleNameOrRatios.match(/^([1-9]+[0-9]*)edo$/)[1] })
: hap;
});
return removeUndefineds(haps);
});
});
export const xen = register(
'xen',
function (scaleNameOrRatios, pat) {
const JIsc2 = (input) => {
let k = parray(input);
return new Pattern((state) => {
let res = k.withValue((x) => x.map((y) => eval(y.join('/')))).query(state);
return res;
});
};
const query = (state) => {
let haps = pat.query(state);
let resHaps = [];
haps.forEach((hap) => {
let hVal = hap.value;
const isObject = typeof hVal === 'object';
if (!isObject) {
throw new Error(
`Expected hap to have control 'i' set, but received ${hap.value.i}, try wrapping input in i()`,
);
}
const { i, ...otherValues } = hVal;
let edo = false;
let st = new State(hap.wholeOrPart().intersection(state.span), state.controls);
// Format: An array of values
// Each value is a pattern, representing an index on the scale
let sc = scaleNameOrRatios.query(st);
// Check to see if input is attempting to be a named scale or a raw scale
let isStr = typeof sc[0].value == 'string';
let isRawScale = Array.isArray(sc[0].value) && sc[0].value.every((x) => typeof x == 'number');
if (!isStr && !isRawScale) {
sc = sc[0].value;
} else if (isRawScale) {
sc = getXenScale(sc[0].value);
}
if (!isStr && !isRawScale) {
let scaleByIndices = [];
for (let j = 0; j < sc.length; j++) {
scaleByIndices[j] = sc[j].query(st);
}
/*
Format: An array of 1 or more Haps, each Hap has an array of values of
one or more scale index definitions (contains whole scale)
Assumption: inner value array should be same length between each
*/
let sc2 = JIsc2(sc).query(st);
let index = _mod(i, sc.length);
for (let j = 0; j < scaleByIndices[index].length; j++) {
let scale = getXenScale(sc2[j].value);
let freq = xenOffset(scale, parseNumeral(i));
let newHap = hap;
freq = trimFreq(freq);
newHap.value = { ...otherValues, freq };
newHap = newHap.setContext(newHap.combineContext(scaleByIndices[index][j]));
resHaps.push(newHap);
}
} else if (isRawScale) {
let freq = xenOffset(sc, parseNumeral(i));
let newHap = hap;
freq = trimFreq(freq);
newHap.value = { ...otherValues, freq };
resHaps.push(newHap);
} else if (isStr) {
// scale is named scale(s)
for (let j = 0; j < sc.length; j++) {
let scaleName = sc[j].value;
let scale = getXenScale(scaleName);
let freq = xenOffset(scale, parseNumeral(i));
let newHap = hap;
freq = trimFreq(freq);
newHap.value = { ...otherValues, freq };
newHap = newHap.setContext(newHap.combineContext(sc[j]));
if (isEdo(scaleName)) {
edo = scaleName.match(/^([1-9]+[0-9]*)edo$/)[1];
newHap = newHap.setContext({ ...newHap.context, edoSize: edo });
}
resHaps.push(newHap);
}
} else {
throw new Error('failed to parse scale');
}
});
return resHaps;
};
return new Pattern(query);
},
false,
);
/**
* Assumes pattern of frequencies tuned to some `base` frequency, such as the output of `xen`
@@ -149,6 +225,7 @@ export const withBase = register('withBase', (b, pat) => {
let hVal = hap.value;
const isObject = typeof hVal === 'object';
let freq = isObject ? hVal.freq : hVal;
if (!freq) return hap;
freq = (freq * base) / originalBase;
hap.value = isObject ? { ...hap.value, freq } : { freq };
return hap;
+43 -1
View File
@@ -299,10 +299,29 @@ importers:
'@strudel/core':
specifier: workspace:*
version: link:../core
'@strudel/webaudio':
specifier: workspace:*
version: link:../webaudio
'@tauri-apps/api':
specifier: ^2.2.0
version: 2.2.0
packages/dough:
dependencies:
'@strudel/core':
specifier: workspace:*
version: link:../core
'@strudel/webaudio':
specifier: workspace:*
version: link:../webaudio
dough-synth:
specifier: 0.2.4
version: 0.2.4
devDependencies:
vite:
specifier: ^6.0.11
version: 6.0.11(@types/node@22.10.10)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.7.0)
packages/draw:
dependencies:
'@strudel/core':
@@ -470,6 +489,9 @@ importers:
'@strudel/core':
specifier: workspace:*
version: link:../core
'@strudel/webaudio':
specifier: workspace:*
version: link:../webaudio
osc:
specifier: ^2.4.5
version: 2.4.5
@@ -808,6 +830,9 @@ importers:
'@strudel/desktopbridge':
specifier: workspace:*
version: link:../packages/desktopbridge
'@strudel/dough':
specifier: workspace:*
version: link:../packages/dough
'@strudel/draw':
specifier: workspace:*
version: link:../packages/draw
@@ -2066,6 +2091,7 @@ packages:
'@lerna/create@8.1.9':
resolution: {integrity: sha512-DPnl5lPX4v49eVxEbJnAizrpMdMTBz1qykZrAbBul9rfgk531v8oAt+Pm6O/rpAleRombNM7FJb5rYGzBJatOQ==}
engines: {node: '>=18.0.0'}
deprecated: This package is an implementation detail of Lerna and is no longer published separately.
'@lezer/common@1.2.3':
resolution: {integrity: sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==}
@@ -3065,6 +3091,7 @@ packages:
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
'@vite-pwa/astro@0.5.0':
resolution: {integrity: sha512-Yd3Pug/c1EUQJXWvzYh6eTtoqzmSKcdCqWCcNquZeaD13tLWpBb2FIPJ4HMULVY6+GfxMvrT+OBuMrbHQCvftw==}
@@ -3716,6 +3743,7 @@ packages:
conventional-changelog-core@5.0.1:
resolution: {integrity: sha512-Rvi5pH+LvgsqGwZPZ3Cq/tz4ty7mjijhr3qR4m9IBXNbxGGYgTVVO+duXzz9aArmHxFtwZ+LRkrNIMDQzgoY4A==}
engines: {node: '>=14'}
deprecated: Deprecated and no longer maintained. Please use conventional-changelog instead.
conventional-changelog-preset-loader@3.0.0:
resolution: {integrity: sha512-qy9XbdSLmVnwnvzEisjxdDiLA4OmV3o8db+Zdg4WiFw14fP3B6XNz98X0swPPpkTd/pc1K7+adKgEDM1JCUMiA==}
@@ -4047,6 +4075,10 @@ packages:
resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==}
engines: {node: '>=12'}
dough-synth@0.2.4:
resolution: {integrity: sha512-qMnMKVj1B2ivUgobNCwqJqNzZHOMN+5339OmCOk07zIDEK7iCYdufa+ZDE2BEgmKrWDyhGS7oTNFjHtTwzEv9w==}
hasBin: true
dset@3.1.4:
resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==}
engines: {node: '>=4'}
@@ -4579,6 +4611,7 @@ packages:
git-raw-commits@3.0.0:
resolution: {integrity: sha512-b5OHmZ3vAgGrDn/X0kS+9qCfNKWe4K/jFnhwzVWWg0/k5eLa3060tZShrRg8Dja5kPc+YjS0Gc6y7cRr44Lpjw==}
engines: {node: '>=14'}
deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead.
hasBin: true
git-remote-origin-url@2.0.0:
@@ -4588,6 +4621,7 @@ packages:
git-semver-tags@5.0.1:
resolution: {integrity: sha512-hIvOeZwRbQ+7YEUmCkHqo8FOLQZCEn18yevLHADlFPZY02KJGsu5FZt9YW/lybfK2uhWFI7Qg/07LekJiTv7iA==}
engines: {node: '>=14'}
deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead.
hasBin: true
git-up@7.0.0:
@@ -4615,15 +4649,17 @@ packages:
glob@10.4.5:
resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
glob@9.3.5:
resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==}
engines: {node: '>=16 || 14 >=14.17'}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
globalize@0.1.1:
resolution: {integrity: sha512-5e01v8eLGfuQSOvx2MsDMOWS0GFtCx1wPzQSmcHw4hkxFzrQDBO3Xwg/m8Hr/7qXMrHeOIE29qWVzyv06u1TZA==}
@@ -6406,6 +6442,7 @@ packages:
prebuild-install@7.1.1:
resolution: {integrity: sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==}
engines: {node: '>=10'}
deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
hasBin: true
precinct@12.1.2:
@@ -7230,6 +7267,7 @@ packages:
tar@6.2.1:
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
engines: {node: '>=10'}
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
temp-dir@1.0.0:
resolution: {integrity: sha512-xZFXEGbG7SNC3itwBzI3RYjq/cEhBkx2hJuKGIUOcEULmkQExXiHat2z/qkISYsuR+IKumhEfKKbV5qXmhICFQ==}
@@ -7357,6 +7395,7 @@ packages:
tsconfck@3.1.4:
resolution: {integrity: sha512-kdqWFGVJqe+KGYvlSO9NIaWn9jT1Ny4oKVzAJsKii5eoE9snzTJzL4+MMVOMn+fikWGFmKEylcXL710V/kIPJQ==}
engines: {node: ^18 || >=20}
deprecated: unmaintained
hasBin: true
peerDependencies:
typescript: ^5.0.0
@@ -7636,6 +7675,7 @@ packages:
uuid@10.0.0:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
validate-npm-package-license@3.0.4:
@@ -11757,6 +11797,8 @@ snapshots:
dotenv@16.4.7: {}
dough-synth@0.2.4: {}
dset@3.1.4: {}
dunder-proto@1.0.1:
File diff suppressed because it is too large Load Diff
+16 -12
View File
@@ -7203,8 +7203,12 @@ exports[`renders tunes > tune: holyflute 1`] = `
exports[`renders tunes > tune: juxUndTollerei 1`] = `
[
"[ -99/200 ⇜ (0/1 → 1/200) | note:63 s:triangle pan:0 cutoff:758.852817928549 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]",
"[ -99/200 ⇜ (0/1 → 1/200) | note:67 s:triangle pan:1 color:green cutoff:758.852817928549 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]",
"[ 0/1 → 1/4 | note:c3 s:sawtooth pan:0 cutoff:1100 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]",
"[ 0/1 → 1/4 | note:bb3 s:sawtooth pan:1 color:green cutoff:1100 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]",
"[ 1/200 → 101/200 | note:55 s:triangle pan:0 cutoff:1103.534282651425 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]",
"[ 1/200 → 101/200 | note:65 s:triangle pan:1 color:green cutoff:1103.534282651425 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]",
"[ 1/4 → 1/2 | note:eb3 s:sawtooth pan:0 cutoff:1275.581289814515 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]",
"[ 1/4 → 1/2 | note:g3 s:sawtooth pan:1 color:green cutoff:1275.581289814515 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]",
"[ 1/2 → 3/4 | note:g3 s:sawtooth pan:0 cutoff:1444.415089128581 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]",
@@ -7318,12 +7322,12 @@ exports[`renders tunes > tune: randomBells 1`] = `
[
"[ -9/8 ⇜ (0/1 → 3/8) | gain:0.6 note:A3 velocity:0.598990320228 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
"[ -3/4 ⇜ (0/1 → 3/4) | gain:0.6 note:C5 velocity:0.836992966942 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
"[ 0/1 → 3/2 | note:F2 s:bass clip:1 gain:0.8 ]",
"[ 0/1 → 3/2 | note:D2 s:bass clip:1 gain:0.8 ]",
"[ 0/1 → 9/4 | gain:0.6 note:D3 velocity:0.5 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
"[ 3/8 → 21/8 | gain:0.6 note:F5 velocity:0.921303892508 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
"[ 3/4 → 3/1 | gain:0.6 note:C5 velocity:0.842607785016 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
"[ 3/2 → 9/4 | note:F2 s:bass clip:1 gain:0.8 ]",
"[ 9/4 → 3/1 | note:F2 s:bass clip:1 gain:0.8 ]",
"[ 3/2 → 9/4 | note:D2 s:bass clip:1 gain:0.8 ]",
"[ 9/4 → 3/1 | note:D2 s:bass clip:1 gain:0.8 ]",
"[ 9/4 → 9/2 | gain:0.6 note:D4 velocity:0.700696257874 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
"[ 21/8 → 39/8 | gain:0.6 note:C4 velocity:0.650794394314 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
"[ 3/1 → 9/2 | note:D2 s:bass clip:1 gain:0.8 ]",
@@ -7335,12 +7339,12 @@ exports[`renders tunes > tune: randomBells 1`] = `
"[ (21/4 → 6/1) ⇝ 27/4 | gain:0.6 note:D4 velocity:0.698815540411 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
"[ 39/8 ⇜ (6/1 → 51/8) | gain:0.6 note:D5 velocity:0.875811336562 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
"[ 21/4 ⇜ (6/1 → 27/4) | gain:0.6 note:D4 velocity:0.698815540411 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
"[ 6/1 → 15/2 | note:D2 s:bass clip:1 gain:0.8 ]",
"[ 6/1 → 15/2 | note:A2 s:bass clip:1 gain:0.8 ]",
"[ 6/1 → 33/4 | gain:0.6 note:G4 velocity:0.75977108255 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
"[ 51/8 → 69/8 | gain:0.6 note:G4 velocity:0.774316444062 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
"[ 27/4 → 9/1 | gain:0.6 note:C5 velocity:0.836244757287 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
"[ 15/2 → 33/4 | note:D2 s:bass clip:1 gain:0.8 ]",
"[ 33/4 → 9/1 | note:D2 s:bass clip:1 gain:0.8 ]",
"[ 15/2 → 33/4 | note:A2 s:bass clip:1 gain:0.8 ]",
"[ 33/4 → 9/1 | note:A2 s:bass clip:1 gain:0.8 ]",
"[ 33/4 → 21/2 | gain:0.6 note:A3 velocity:0.59140187595 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
"[ 69/8 → 87/8 | gain:0.6 note:G4 velocity:0.754063542932 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
"[ 9/1 → 21/2 | note:A2 s:bass clip:1 gain:0.8 ]",
@@ -7352,12 +7356,12 @@ exports[`renders tunes > tune: randomBells 1`] = `
"[ (45/4 → 12/1) ⇝ 51/4 | gain:0.6 note:A4 velocity:0.797278589569 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
"[ 87/8 ⇜ (12/1 → 99/8) | gain:0.6 note:F4 velocity:0.734787144698 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
"[ 45/4 ⇜ (12/1 → 51/4) | gain:0.6 note:A4 velocity:0.797278589569 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
"[ 12/1 → 27/2 | note:A2 s:bass clip:1 gain:0.8 ]",
"[ 12/1 → 27/2 | note:G2 s:bass clip:1 gain:0.8 ]",
"[ 12/1 → 57/4 | gain:0.6 note:G5 velocity:0.979763559997 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
"[ 99/8 → 117/8 | gain:0.6 note:C4 velocity:0.666239210404 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
"[ 51/4 → 15/1 | gain:0.6 note:F5 velocity:0.95169514697 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
"[ 27/2 → 57/4 | note:A2 s:bass clip:1 gain:0.8 ]",
"[ 57/4 → 15/1 | note:A2 s:bass clip:1 gain:0.8 ]",
"[ 27/2 → 57/4 | note:G2 s:bass clip:1 gain:0.8 ]",
"[ 57/4 → 15/1 | note:G2 s:bass clip:1 gain:0.8 ]",
"[ 57/4 → 33/2 | gain:0.6 note:F5 velocity:0.918253320269 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
"[ 117/8 → 135/8 | gain:0.6 note:G3 velocity:0.571157105267 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
"[ 15/1 → 33/2 | note:G2 s:bass clip:1 gain:0.8 ]",
@@ -7369,12 +7373,12 @@ exports[`renders tunes > tune: randomBells 1`] = `
"[ (69/4 → 18/1) ⇝ 75/4 | gain:0.6 note:F3 velocity:0.508127085865 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
"[ 135/8 ⇜ (18/1 → 147/8) | gain:0.6 note:F5 velocity:0.945647025481 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
"[ 69/4 ⇜ (18/1 → 75/4) | gain:0.6 note:F3 velocity:0.508127085865 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
"[ 18/1 → 39/2 | note:G2 s:bass clip:1 gain:0.8 ]",
"[ 18/1 → 39/2 | note:F2 s:bass clip:1 gain:0.8 ]",
"[ 18/1 → 81/4 | gain:0.6 note:A3 velocity:0.608644555323 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
"[ 147/8 → 165/8 | gain:0.6 note:F3 velocity:0.506259439513 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
"[ 75/4 → 21/1 | gain:0.6 note:D4 velocity:0.671621939167 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
"[ 39/2 → 81/4 | note:G2 s:bass clip:1 gain:0.8 ]",
"[ 81/4 → 21/1 | note:G2 s:bass clip:1 gain:0.8 ]",
"[ 39/2 → 81/4 | note:F2 s:bass clip:1 gain:0.8 ]",
"[ 81/4 → 21/1 | note:F2 s:bass clip:1 gain:0.8 ]",
"[ 81/4 → 45/2 | gain:0.6 note:D4 velocity:0.704345900565 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
"[ 165/8 → 183/8 | gain:0.6 note:D5 velocity:0.887838857248 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
"[ 21/1 → 45/2 | note:F2 s:bass clip:1 gain:0.8 ]",
+2
View File
@@ -99,6 +99,7 @@ const toneHelpersMocked = {
'_spectrum',
'markcss',
'p',
'dough',
].forEach((mock) => {
strudel.Pattern.prototype[mock] = function () {
return this;
@@ -174,6 +175,7 @@ evalScope(
getDuration,
setcps: id,
setcpm: id,
initDough: id,
Clock: {}, // whatever
},
);
+2 -2
View File
@@ -597,8 +597,8 @@
],
"/packages/superdough/dspworklet.mjs": [
"dspWorklet",
"dough",
"doughTrigger"
"rawdsp",
"rawdspTrigger"
],
"/packages/superdough/index.mjs": [],
"/packages/tonal/tonleiter.mjs": [
+10
View File
@@ -78,6 +78,7 @@ export default defineConfig({
urlPattern: ({ url }) =>
[
/^https:\/\/raw\.githubusercontent\.com\/.*/i,
/^https:\/\/strudel\.b-cdn\.net\/.*/i,
/^https:\/\/freesound\.org\/.*/i,
/^https:\/\/cdn\.freesound\.org\/.*/i,
/^https:\/\/shabda\.ndre\.gr\/.*/i,
@@ -144,4 +145,13 @@ export default defineConfig({
// external: ['fraction.js'], // https://github.com/infusion/Fraction.js/issues/51
},
},
server: {
// these are needed for dough, which uses shared memory
// see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer#security_requirements
// this is only for the dev server, so the server strudel runs on, also needs those
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'credentialless',
},
},
});
+1
View File
@@ -41,6 +41,7 @@
"@strudel/motion": "workspace:*",
"@strudel/mqtt": "workspace:*",
"@strudel/osc": "workspace:*",
"@strudel/dough": "workspace:*",
"@strudel/serial": "workspace:*",
"@strudel/soundfonts": "workspace:*",
"@strudel/tidal": "workspace:*",
+1 -1
View File
@@ -24,7 +24,7 @@ const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL
<base href={BASE_URL} />
<!-- Scrollable a11y code helper -->
<!-- Scrollable accessibility code helper -->
<script src={`${baseNoTrailing}/make-scrollable-code-focusable.js`} is:inline></script>
<script src="/src/pwa.ts"></script>
+1 -1
View File
@@ -23,7 +23,7 @@ const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL
<base href={BASE_URL} />
<!-- Scrollable a11y code helper -->
<!-- Scrollable accessibility code helper -->
<script src={`${baseNoTrailing}/make-scrollable-code-focusable.js`} is:inline></script>
<script src="/src/pwa.ts"></script>
+1 -1
View File
@@ -16,7 +16,7 @@ const imageAlt = frontmatter.image?.alt ?? OPEN_GRAPH.image.alt;
<!-- Page Metadata -->
<link rel="canonical" href={canonicalUrl} />
<!-- OpenGraph Tags -->
<!-- og Tags -->
<meta property="og:title" content={formattedContentTitle} />
<meta property="og:type" content="article" />
<meta property="og:url" content={canonicalUrl} />
+1
View File
@@ -98,6 +98,7 @@ export const SIDEBAR: Sidebar = {
{ text: 'Random Modifiers', link: 'learn/random-modifiers' },
{ text: 'Conditional Modifiers', link: 'learn/conditional-modifiers' },
{ text: 'Accumulation', link: 'learn/accumulation' },
{ text: 'LFOs', link: 'learn/lfo' },
{ text: 'Tonal Functions', link: 'learn/tonal' },
{ text: 'Stepwise Functions', link: 'learn/stepwise' },
],
+4 -4
View File
@@ -22,7 +22,7 @@ export function MiniRepl({
tunes,
hideHeader = false,
canvasHeight = 100,
onTrigger,
onSchedule,
punchcard,
punchcardLabels = true,
claviature,
@@ -48,7 +48,7 @@ export function MiniRepl({
const editor = new StrudelMirror({
id,
defaultOutput: webaudioOutput,
onSchedule: webaudioOutput,
getTime: () => getAudioContext().currentTime,
transpiler,
autodraw,
@@ -58,8 +58,8 @@ export function MiniRepl({
drawTime,
drawContext,
editPattern: (pat, id) => {
if (onTrigger) {
pat = pat.onTrigger(onTrigger, false);
if (onSchedule) {
pat = pat.onSchedule(onSchedule, false);
}
if (claviature) {
pat = pat.onPaint((ctx, time, haps, drawTime) => {
+1 -1
View File
@@ -36,7 +36,7 @@ const githubEditUrl = `${CONFIG.GITHUB_EDIT_URL}/${currentFile}`;
<Header currentPage={currentPage} />
</header>
<main class="relative pt-16">
<div class="h-full top-0 overflow-auto min-w-[300px] flex xl:justify-center pr-4 pl-4 md:pl-[300px] xl:pl-0">
<div class="h-full top-0 overflow-auto min-w-[300px] flex xl:justify-center pr-4 pl-4 md:pl-[300px] xl:pr-[300px]">
<aside title="Site Navigation" class="w-[300px] px-6 left-0 hidden md:block fixed h-full">
<LeftSidebar currentPage={currentPage} />
</aside>
-2
View File
@@ -150,5 +150,3 @@ In the future, the integration could be improved by passing all patterned contro
This could work by a unique [channel](https://kunstmusik.github.io/icsc2022-csound-web/tutorial2-interacting-with-csound/#step-4---writing-continuous-data-channels)
for each value. Channels could be read [like this](https://github.com/csound/csound/blob/master/Android/CsoundForAndroid/CsoundAndroidExamples/src/main/res/raw/multitouch_xy.csd).
Also, it might make sense to have a standard library of csound instruments for strudel's effects.
Now, let's dive into the [Functional JavaScript API](/functions/intro)
+1 -1
View File
@@ -33,7 +33,7 @@ The signal chain in Strudel is as follows:
- This is where the main (volume) ADSR happens
- A lowpass filter (`lpf`)
- A highpass filter (`hpf`)
- A bandpass filter (`bandpass`)
- A bandpass filter (`bpf`)
- A vowel filter (`vowel`)
- Sample rate reduction (`coarse`)
- Bit crushing (`crush`)
+17
View File
@@ -52,6 +52,15 @@ There are multiple ways to load your sample collection. Some methods are good fo
- Serve a folder of samples locally using the [strudel 'sampler' commandline tool](https://strudel.cc/learn/samples/#from-disk-via-strudelsampler). This can be most reliable method, but requires [nodejs](https://nodejs.org) to be installed.
- Host your sound library online on the web and [load them from an URL](/learn/samples/#loading-custom-samples)
## Can I create a new project based on Strudel?
Strudel is free/open source software, and we are always happy to see people making use of it within the following terms:
- Please don't use 'strudel' in the name of your project (e.g. strudel2000, foo-strudel), so people don't assume it's official strudel project. (If you'd like it to be an official strudel project, please check in with the community, e.g. on the [discord chat](https://discord.com/invite/HGEdXmRkzT).)
- Please respect our AGPL license, which e.g. requires you to share/link to the source code of strudel, any modifications you've made to it, and the source code for the rest of your project if it integrates with strudel. You are also required to maintain Strudel's copyright notices in the source code, and include Strudel's copyright notice in your user interface. This is an ad-hoc summary - please [refer to the license](https://codeberg.org/uzu/strudel/src/branch/main/LICENSE) for full details.
You are also encouraged to connect with [the community](https://discord.com/invite/HGEdXmRkzT) to understand our aims and values.
## Can I use Strudel with AI/LLM tools?
You are free to do what you like with Strudel, within the terms of the free/open source AGPLv3 license.
@@ -337,3 +346,11 @@ s increase by one semitone, i.e. sharp, works for steps of scales, note names
$: at the start of a line, defines a member of the stack. is the only stack name that should occur multiple names
_ before a stack name: mutes the stack, i.e. hush(), for example _$: s("bd"), see above for a different usage.
```
## What is a 'hap'?
A 'hap' is a name for a thing that happens, e.g. `note("c3 -")` playing a note and then stopping after a while. Haps have a start and an end. Every separate sound that is produced by strudel is a hap on its own.
We would have called it 'event' if this particular name wasn't used by JavaScript to describe things that happen on html pages, like clicking on a button or hovering over a line of text.
Since strudel is written in JavaScript, this confusion of having the same name for different things is avoided by calling events 'haps'.
+301
View File
@@ -0,0 +1,301 @@
---
title: Low-frequency oscillators (LFO)
layout: ../../layouts/MainLayout.astro
---
import { MiniRepl } from '../../docs/MiniRepl';
import { JsDoc } from '../../docs/JsDoc';
# Low frequency oscillators (LFO)
A low frequency oscillator (or short LFO) is a common way on synthesizers to continuously modulate various signals.
This documentation is an interactive version of glossing's tutorial https://www.youtube.com/watch?v=11frBA9L638
## Signals vs LFOs
In Strudel, there are two ways to modulate:
1. [signals](/learn/signals/) for pattern-level modulation
2. `lfo` (this page) for audio-level modulation
## Applying an LFO
Here, the LFO will change the frequency of the `saw`. Put a comment like this `//.lfo()` to see and hear how it changes, and remove the comment again.
<MiniRepl
client:idle
tune={`
s("saw").lfo()
.lpf(800)
._spectrum({height: 300, width: 800})`}
/>
By default, the LFO will modulate the control parameter which is right before `.lfo()`:
<MiniRepl
client:idle
tune={`
s("saw")
.lpf(800).lfo()
._spectrum({height: 300, width: 800})`}
/>
Here, the LFO will modulate the low pass filter `.lpf`.
## Moving away from the default
The following sections explain how to pass parameters to `.lfo`. Similar to `._spectrum` above, almost all the configuration of `lfo` lives inside a json object, starting with `{` and ending with `}`.
All the parameters (except `id`) are written as `key: value` inside and separated by `,`.
The reference refers to them as `config.key`, i.e. for the following one as `config.control` but you use them like below.
## Control
`control` determines which parameter will be modulated. This allows you to place your `lfo` at different places,
not necessarily immediately after the controlled parameter.
Here, we place `lfo` after the `s`, but it modulates the low pass filter.
<MiniRepl
client:idle
tune={`
s("saw")
.lfo({control:'lpf'})
.lpf(800)
._spectrum({height: 300, width: 800})`}
/>
You can even influence parameters which are always present, even if you haven't explicitly written them, like `gain`.
<MiniRepl
client:idle
tune={`
s("saw")
.lfo({control:'gain'})
.lpf(800)
._spectrum({height: 300, width: 800})`}
/>
`control` has an alias `c`.
## Rate
`rate` determines how often the `lfo` oscillates per second:
<MiniRepl
client:idle
tune={`
s("saw")
.lfo({c:'gain', rate:"<2 4>"})
.lpf(800)
._spectrum({height: 300, width: 800})`}
/>
The alias of `rate` is `r`.{' '}
## Sync
Instead of controlling the frequency with `rate` by setting a frequency in Hz, you can use `sync`
to snychronize your lfo with your other patterns.
`sync` expresses this frequency as "times per cycle"
Try removing the `sync: "<2 4 8 0.5 >"` from the pattern and notice that there is something not in sync.
<MiniRepl
client:idle
tune={`
$: s("bd*4").bank("TR909").postgain(0.5)
$: s("saw")
.lpf(800)
.lfo({sync: "<2 4 8 0.5 >"})
._spectrum({height: 300, width: 800})`}
/>
You can put patterns into the parameters of `lfo` if you want them to change over time, as seen above.
## Relative Depth
`.depth` is relative depth, a value of 1 (the default) means that
the value goes above and below by half of the value which is being modulated
E.g. for depth `1`, it modulates the frequency to oscillate between 32 = (64/2) and 96 (= 64 + 64/2).
<MiniRepl
client:idle
tune={`
s("saw").freq(64)
.lfo({r: 2, depth: "<1 2 3>"})
.lpf(800)
._spectrum({height: 300, width: 800})`}
/>
The `freq` is not needed here, as this is the default frequency, it's just for instructive purposes.
The aliases of `depth` are `dr` and `dep`.
## Absolute Depth
`depthabs` controls the absolute modulation depth. For example you can modulate
the low pass filter by exactly 250 Hz up and below:
<MiniRepl
client:idle
tune={`
s("saw")
.lpf(800)
.lfo({r: 2, depthabs: "250"})`}
/>
The alias of `depthabs` is `da`.
## DC offset
If you don't want to go up or down by the same amount with your modulation, then you can shift the center of the modulation with `dcoffset`.
The default value is -0.5, which is the middle point between:
"dcoffset = 0: All modulations increase the control parameter (or keep it constant)"
"dcoffset = -1: All modulations decrease the control parameter (or keep it constant)"
Other values will work as well.
<MiniRepl
client:idle
tune={`
s("saw")
.lpf(600)
.lfo({r: 2, da: "200", dcoffset: "<-0.5 -1 -0.5 0>"})`}
/>
The marvellous alias of `dcoffset` is `dc`.
## Shape
You can change the shape of the modulation with `shape`. The default is `triangle`, but other shapes are available too:
<MiniRepl
client:idle
tune={`
s("saw")
.lpf(800)
.lfo({r: 2, shape: "<triangle sine ramp saw square>"}) `}
/>
You can add `._spectrum()` to see the shape of the modulations in the spectrum.
You can also get these shapes by using numbers:
| Shape | number |
| -------- | ------ |
| triangle | 0 |
| sine | 1 |
| ramp | 2 |
| saw | 3 |
| square | 4 |
this way, you can use a function like `irand` to generate numbers:
<MiniRepl
client:idle
tune={`
s("saw")
.lpf(800)
.lfo({r: 2, shape: irand(4)}) `}
/>
The alias of `shape` is `sh`.
## Skewing some shapes
You can influence some of the shapes (`triangle` and `square`) in more detail.
The default skew is 0.5, it does different things for these two.
For `triangle`, it skews the top of the triangle to the left or right,
where 0 makes it look like `saw` and 1 makes it look like `ramp`.
For `square`, the skew influences the pulse width (see reference for `pulse` and `.pw`):
<MiniRepl
client:idle
tune={`
s("saw")
.lpf(800)
.lfo({r: 2, sh: "<triangle square>/5",
skew: "<0 0.25 0.5 0.75 1>"}) `}
/>
The alias of `skew` is `sk`.{' '}
## Curve
You can change the curves of your lfo and can make it more intense.
The default value is 1. Writing numbers greater than one can make it more intense,
numbers between 0 and 1 will make it less intense.
This will raise the lfo to the power of curve, so larger numbers (such as 10) can have very unexpected results.
<MiniRepl
client:idle
tune={`
s("saw")
.lpf(800)
.lfo({r: 2, sh: "<triangle>", curve: "<1.3 1 0.75 1>"}) `}
/>
## Referencing your lfos with id
All the lfos are numbered from the first starting with 0, the second having an `id` of 1 and so on.
You can refer to this in a later call if you want to modify a specific `lfo`.
The `id` is outside of the config json (which is different from the other parameters of `lfo()`
Try out how the sound changes when you replace the 0 with a 1.
<MiniRepl
client:idle
tune={`
s("saw").lfo().lpf(800)
.lfo({s: "<4 8 0.5>"})
.sometimes(x => x.lfo({dr: "4"},0))`}
/>
You can also name your lfos and refer to them by name, using the `id` parameter
<MiniRepl
client:idle
tune={`
s("saw").lfo({}, "lfo_freq_saw").lpf(800)
.lfo({s: "<4 8 0.5>"}, "lfo_lpf")
.sometimes(x => x.lfo({dr: "4"},"<lfo_lpf lfo_freq_saw>"))`}
/>
## FX index
If you are using `FX()` to reorder your effects, you dont need to write your lfos inside the `FX`,
but instead can refer to them by their FX index (starting with 0)
<MiniRepl
client:idle
tune={`
s("saw").FX(
distort(3),
gain(0.3), // this has the fx index 1
lpf(400)
).lfo({s: 16, dr:2, c:"gain", fxi: 1})`}
/>
## Modulating other LFOs with Sub-control
LFOs can modulate other lfos and will modulate their frequency (given by `r` or `s`):
<MiniRepl
client:idle
tune={`
s("saw").lpf(400).gain(0.8)
.lfo({s: 16, dr:2, c:"gain"})
.lfo({s: 0.3, dc:-1, dr: 0.8})`}
/>
To modulate other parameters of the first lfo (like `skew`, `depth` and so on), we can specify this with `subControl`or
its alias `sc`
<MiniRepl
client:idle
tune={`
s("saw").lpf(400).gain(0.8)
.lfo({s: 4, dr:2, c:"gain"})
.lfo({s: 0.3, sc: "skew"})`}
/>
+8
View File
@@ -55,6 +55,14 @@ There is also `saw2`, `sine2`, `cosine2`, `tri2`, `square2` and `rand2` which ha
<JsDoc client:idle name="brandBy" h={0} />
## time
<JsDoc client:idle name="time" h={0} />
## creating your own signal
<JsDoc client:idle name="signal" h={0} />
## mouseX
<JsDoc client:idle name="mousex" h={0} />
+6 -6
View File
@@ -12,6 +12,12 @@ import { JsDoc } from '../../docs/JsDoc';
These functions allow the use of scales other than your typical chromatic 12 based ones.
### xen(scaleOrRatios)
{/* TODO add explanation of EDO to documentation */}
<JsDoc client:idle name="Pattern.xen" h={0} />
### tune(scale)
{/* TODO (maybe): combine jsdoc things in tune.mjs with here */}
@@ -110,9 +116,3 @@ You can also give tune a list of frequencies to use as the scale:
523.2511306012
]).mul(220).freq();`}
/>
### xen(scaleOrRatios)
{/* TODO add explanation of EDO to documentation */}
<JsDoc client:idle name="Pattern.xen" h={0} />
@@ -186,9 +186,9 @@ These functions are more low level, probably not needed by the live coder.
# Other
## onTrigger
## onSchedule
<JsDoc client:idle name="Pattern#onTrigger" h={0} />
<JsDoc client:idle name="Pattern#onSchedule" h={0} />
## log
@@ -8,7 +8,7 @@ import { JsDoc } from '../../docs/JsDoc';
# Patterns
Patterns are the essence of Tidal. Its patterns are abstract entities that represent flows of time as functions, adapting a technique called pure functional reactive programming. Taking a time span as its input, a Pattern can output a set of events that happen within that time span. It depends on the structure of the Pattern how the events are located in time.
Patterns are the essence of Uzu languages such as Strudel and Tidal. Its patterns are abstract entities that represent flows of time as functions, adapting a technique called pure functional reactive programming. Taking a time span as its input, a Pattern can output a set of events that happen within that time span. It depends on the structure of the Pattern how the events are located in time.
From now on, this process of generating events from a time span will be called **querying**.
Example:
+6 -10
View File
@@ -17,7 +17,7 @@ Besides a UI for playback control and meta information, the main part of the REP
1. The user writes and updates code. Each update transpiles and evaluates it to create a `Pattern` instance
2. While the REPL is running, the `Scheduler` queries the active `Pattern` by a regular interval, generating `Events` (also known as `Haps` in Strudel) for the next time span.
3. For each scheduling tick, all generated `Events` are triggered by calling their `onTrigger` method, which is set by the output.
3. For each scheduling tick, all generated `Events` are triggered by calling their `onSchedule` method, which is set by the output.
<img src="https://codeberg.org/uzu/strudel/raw/branch/talk/talk/public/strudelflow.png" width="600" />
@@ -134,10 +134,7 @@ let minLatency = 0.1; // min time before a hap should trigger
setInterval(() => {
const haps = pattern.queryArc(time, time + interval);
time += interval; // increment time
haps.forEach((hap) => {
const deadline = hap.whole.begin - time + minLatency;
onTrigger(hap, deadline, duration);
});
haps.forEach((hap) => onSchedule(hap, hap.whole.begin + minLatency));
}, interval * 1000); // query each "interval" seconds
```
@@ -181,20 +178,19 @@ This example creates the custom control parameters `x` and `y` which are then us
### Outputs
Now that we know how the value of an event is manipulated using control parameters, we can look at how outputs can use that value to generate anything. The scheduler above was calling the `onTrigger` function which is used to implement the output. A very simple version of the web audio output could look like this:
Now that we know how the value of an hap is manipulated using control parameters, we can look at how outputs can use that value to generate anything. The scheduler above was calling the `onSchedule` function which is used to implement the output. A very simple version of the web audio output could look like this:
```js
function onTrigger(hap, deadline, duration) {
function onSchedule(hap, time, cps) {
const { note } = hap.value;
const time = getAudioContext().currentTime + deadline;
const o = getAudioContext().createOscillator();
o.frequency.value = getFreq(note);
o.start(time);
o.stop(time + event.duration);
o.stop(time + hap.duration / cps);
o.connect(getAudioContext().destination);
}
```
The above example will create an `OscillatorNode` for each event, where the frequency is controlled by the `note` param. In essence, this is how the WebAudio API output of Strudel works, only with many more parameters to control synths, samples and effects.
The above example will create an `OscillatorNode` for each hap, where the frequency is controlled by the `note` param. In essence, this is how the WebAudio API output of Strudel works, only with many more parameters to control synths, samples and effects.
I want to help, how do I contribute to the [Docs](/technical-manual/docs)?
+4 -1
View File
@@ -287,7 +287,10 @@ The modes are:
- `below`: the top note of the voicing is lower than or equal to the anchor (default)
- `above`: the bottom note of the voicing is higher than or equal to the anchor
- `duck`: the top note of the voicing is lower than the anchor
- `root`: the bottom note of the voicing is always the root note closest to the anchor
- `root`: the bottom note of the voicing is always the lowest root note higher than or equal to the anchor
- `oldabove` : old (buggy) behavior of above, kept for legacy reasons
- `oldroot` : old (buggy) behavior of root, kept for legacy reasons
The `anchor` can also be set from within the `mode` function:
+117
View File
@@ -0,0 +1,117 @@
---
title: Movement with Strudel
layout: ../../layouts/MainLayout.astro
---
import { MiniRepl } from '@src/docs/MiniRepl';
import Box from '@components/Box.astro';
import QA from '@components/QA';
# Controlling motors with Strudel
Strudel is mainly made for making music, but it's possible to pattern other things with it, including motors.
We're going to use a microcontroller for this, called an "[Inventor 2040W](https://shop.pimoroni.com/products/inventor-2040-w)", which is
a [Pico W](https://shop.pimoroni.com/products/inventor-2040-w?variant=40053063155795) with extra ports added including some for controlling motors.
![image](https://shop.pimoroni.com/cdn/shop/products/Inventor2040_1of3_1500x1500_crop_center.jpg?v=1656927927)
## Technical details
Feel free to gloss over these!
- The Inventor 2040W connects to the internet wirelessly, and it can power from a battery or USB. Hopefully the batteries last!
- It's running [some code](https://github.com/patternclub/alpacalab/blob/main/course/main.py) that listens for messages using an "Internet of Things" network protocol (called MQTT). When it receives a message, it moves a motor.
- It connects to a small server (running software called 'mosquitto') on Alex's laptop.
- Strudel can send these messages instead of triggering sounds - that's how we use it to pattern movement.
## First movement
Let's get a motor running!
1. Note the letter drawn on a label on the back of the microcontroller.
2. Plug a battery into your microcontroller.
3. Plug a motor into 'servo' (not motor) plug numbered 1, with the yellow (lightest) cable closest to the '1', and the brown (darkest) cable outward
4. Run the below to set up some values, changing the `x` in 'robot('x')` to the letter on your microcontroller.
<MiniRepl
client:visible
tune={`
$: move("-60 80").motor("0").robot('x');
`}
/>
<Box>
If you refresh the page, you'll need to change the letter to match your robot again.
If your motor starts moving unexpectedly, someone else might have put your letter in by mistake!
Note that in the above, we start counting motors from '0', so motor 1 on the board is motor 0 in the code.
</Box>
## Patterning movement
Many strudel features for playing with sound patterns will work when
playing with motor patterns. Try playing with the mininotation in the
`move` command:
<MiniRepl
client:visible
tune={`
$: move("-10 0 10 [20 30]*2").motor("0").slow(2).robot('x')
`}
/>
The move instructions are in the range from -90 to 90.
<box>
If your motors stop working at some point, and your code looks right, try pressing the 'reset' button on the
microcontroller.
</box>
It's possible to make smooth movements based on different 'waveforms', for example a smooth sinewave:
<MiniRepl
client:visible
tune={`
$: move(sine.range(-30, 30).segment(16)).motor("0").slow(2).robot('x');
`}
/>
The movement is still quite jerky, because the 'segment' command is
only taking 16 positions from the sinewave. Try increasing it to 32 or 64. It's best not too much higher than that, as the microcontroller
might get overwhelmed with a backlog of instructions!
<box>
Try replacing `sine` with other waveforms: `saw` (sawtooth wave), `tri` (triangular wave) are good, and there is also
`rand` (random wave) and `perlin` (a kind of smoothed-out randomness).
</box>
## Patterning more than one motor
You can pattern the `motor` command separately from the `move` one:
<MiniRepl
client:visible
tune={`
$: move("-10 0 10 [20 30]*2").motor("0 1").slow(2).robot('x');
`}
/>
Alternatively, you can pattern two motors in separate patterns. The below sends the same pattern for the first two motors, but with the second one running slower:
<MiniRepl
client:visible
tune={`
$: move("-10 0 10 [20 30]\*2").motor("0").slow(2).robot('x');
$: move("-10 0 10 [20 30]\*2").motor("1").slow(3).robot('x');
`}
/>
+2 -2
View File
@@ -628,7 +628,7 @@ export const debugAudiograph = async (argOptions = {}) => {
/*global all*/
all((pat) =>
pat.onTrigger(async (hap, duration, cps, t) => {
pat.onSchedule(async (hap, t, cps) => {
hap_count++;
const key = Object.entries(hap.value)
.map((param) => param.join('/'))
@@ -643,7 +643,7 @@ export const debugAudiograph = async (argOptions = {}) => {
return;
}
await webaudioOutput(hap, t, hap.duration / cps, cps, t);
await webaudioOutput(hap, t, cps);
if (hapsBatch && hap_count % hapsBatch === 0) drawDiagram();
}),
@@ -214,7 +214,12 @@ export const Reference = memo(function Reference() {
</p>
)}
<p dangerouslySetInnerHTML={{ __html: entry.description }}></p>
<p
dangerouslySetInnerHTML={{
// If we have an external url make sure it opens in another tab to not interrupt the player
__html: entry.description.replaceAll(/<a href="https:\/\//g, `<a target="_blank" href="https://`),
}}
></p>
<ul>
{entry.params?.map(({ name, type, description }, i) => (
<li key={i}>
@@ -36,8 +36,9 @@ export function WelcomeTab({ context }) {
<a href="https://tidalcycles.org/" target="_blank">
tidalcycles
</a>
, which is a popular live coding language for music, written in Haskell. Strudel is free/open source software:
you can redistribute and/or modify it under the terms of the{' '}
, which is a popular live coding language for music, written in Haskell. Strudel is free/open source software,
with copyright owned by its [contributors](https://codeberg.org/uzu/strudel/activity/contributors). You can
redistribute and/or modify it under the terms of the{' '}
<a href="https://codeberg.org/uzu/strudel/src/branch/main/LICENSE" target="_blank">
GNU Affero General Public License
</a>
+2 -2
View File
@@ -68,7 +68,7 @@ const initialCode = `// LOADING`;
export function useReplContext() {
const { isSyncEnabled, audioEngineTarget, prebakeScript, includePrebakeScriptInShare } = useSettings();
const shouldUseWebaudio = audioEngineTarget !== audioEngineTargets.osc;
const defaultOutput = shouldUseWebaudio ? webaudioOutput : superdirtOutput;
const onSchedule = shouldUseWebaudio ? webaudioOutput : superdirtOutput;
const getTime = shouldUseWebaudio ? getAudioContextCurrentTime : getPerformanceTimeSeconds;
const init = useCallback(() => {
setActivePattern(getViewingPatternData().id);
@@ -76,7 +76,7 @@ export function useReplContext() {
const drawContext = getDrawContext();
const editor = new StrudelMirror({
sync: isSyncEnabled,
defaultOutput,
onSchedule,
getTime,
setInterval,
clearInterval,
+1
View File
@@ -85,6 +85,7 @@ export function loadModules() {
import('@strudel/motion'),
import('@strudel/mqtt'),
import('@strudel/mondo'),
import('@strudel/dough'),
];
if (isTauri()) {
modules = modules.concat([
+1 -1
View File
@@ -138,7 +138,7 @@ export const setIsZen = (active) => settingsMap.setKey('isZen', !!active);
const patternSetting = (key) =>
register(key, (value, pat) =>
pat.onTrigger(() => {
pat.onSchedule(() => {
value = Array.isArray(value) ? value.join(' ') : value;
if (value !== settingsMap.get()[key]) {
settingsMap.setKey(key, value);