Compare commits

..

176 Commits

Author SHA1 Message Date
Aria e15ce7d402 Add replace and insert code helpers to codemirror 2026-03-14 14:48:57 -05:00
Aria ef5ddc90c2 Merge pull request 'Bug Fix: Fix typo in phase computation' (#1974) from glossing/phases-bugfix into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1974
2026-03-14 20:44:37 +01:00
Aria 28af99914f Merge branch 'main' into glossing/phases-bugfix 2026-03-03 18:42:33 +01:00
froos 4a801ab668 Merge pull request 'fix bug where delaytime didn't appear in reference' (#1984) from tyow/localstrudel:delayt into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1984
2026-02-19 23:27:24 +01:00
froos 8f61a0aa90 Merge pull request 'getDuration' (#1980) from tyow/localstrudel:sampleDur into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1980
2026-02-19 23:26:21 +01:00
tyow 1e2705c07f format 2026-02-19 16:01:19 -05:00
tyow 3cefbf80bf moved to sampler 2026-02-19 13:59:30 -05:00
jeromew 81342fb469 Merge pull request '@strudel/transpiler plugin system' (#1985) from jeromew/strudel:transpiler-plugin into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1985
2026-02-19 17:43:31 +01:00
Felix Roos 761e2e347e refactor: localize, remove helpers file when there's no reuse 2026-02-18 23:47:47 +01:00
Felix Roos 3911142443 fix: remove outdated feature note 2026-02-18 20:56:41 +01:00
jeromew bc8517ade7 @strudel/transpiler plugin system 2026-02-18 10:57:25 +00:00
tyow a926346d89 fix bug where delaytime didn't appear in reference 2026-02-17 18:14:16 -05:00
tyow 0f4e6af49d fixed failing test 2026-02-16 17:16:00 -05:00
tyow c20a6673af Added getDuration, need to fix failing test 2026-02-15 16:58:35 +01:00
froos 6b00837a4d Merge pull request 'fix delaytime typo' (#1979) from tyow/localstrudel:delayt into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1979
2026-02-15 15:54:54 +01:00
tyow 807cce521c fix delaytime typo 2026-02-15 08:45:51 -05:00
froos aa49d9dcc2 Merge pull request 'fix: shuffle(1) returns source pattern' (#1968) from tyow/localstrudel:shuffleFix into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1968
2026-02-15 00:24:30 +01:00
froos 60da901952 Merge pull request 'move log2 from composer to registered function' (#1977) from tyow/localstrudel:log2 into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1977
2026-02-14 23:34:53 +01:00
froos 30cc92e863 Merge pull request 'Add packages/edo for edoScale() function' (#1430) from robmckinnon/strudel-edo-fork:main into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1430
2026-02-14 23:19:16 +01:00
froos 91203233c9 Merge pull request 'set and keep documentation' (#1975) from tyow/localstrudel:composerDocs into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1975
2026-02-14 22:49:53 +01:00
tyow c52e8f3b38 move log2 from composer to registered function 2026-02-14 16:40:02 -05:00
tyow d5dbc5c428 typo 2026-02-14 16:16:16 -05:00
tyow 161d2ce056 Added note about pattern alignment 2026-02-14 16:13:37 -05:00
tyow 7cb8a151a4 slight language change 2026-02-13 14:36:22 -05:00
froos 6f108f7f28 Merge pull request 'Edit prebake in settings' (#1958) from JohnBjrk/strudel:edit-prebake-in-settings into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1958
2026-02-13 17:13:37 +01:00
John Björk 7c607c8d06 fix: vim :q command 2026-02-13 16:21:50 +01:00
Felix Roos be0b3c0d8b fix: change default prebake to not use global effect (to prevent glitches) 2026-02-13 13:46:35 +01:00
Felix Roos d3e9b9c020 chore: localize 2026-02-13 13:45:52 +01:00
Felix Roos c16b300144 fix: shove prebake eval out of react 2026-02-13 13:40:51 +01:00
Felix Roos 29a3543918 chore: dedupe cx + cleanup 2026-02-13 13:40:13 +01:00
Felix Roos 1e502c7e8c chore: simplify 2026-02-13 13:21:50 +01:00
Felix Roos 27a22d7609 fix: shove logic out of react 2026-02-13 10:02:58 +01:00
Felix Roos c515ba89a0 fix: don't use id attribute in reusable component.. 2026-02-13 10:02:44 +01:00
Felix Roos 7e3063038e chore: minimize changes 2026-02-13 10:02:14 +01:00
Felix Roos 9bc560b17f fix: cleanup keybinding events 2026-02-13 09:37:34 +01:00
Felix Roos bdeafbaf75 fix: remove prebake specific logic in transpiler 2026-02-13 08:42:20 +01:00
Felix Roos 8984055ea6 fix: remove empty body error -> fall back to silence 2026-02-13 08:40:27 +01:00
Felix Roos 04dcbda9a3 fix: cleanup 2026-02-13 08:36:46 +01:00
tyow d7067f3562 typo 2026-02-12 22:55:30 -05:00
tyow a39c9e6096 Attempt at keep documentation 2026-02-12 21:52:54 -05:00
tyow 9690f78c30 Initial set documentation 2026-02-12 21:13:17 -05:00
Aria cf845ddbab Fix typo in phase computation 2026-02-12 18:30:50 -06:00
froos 3f72b9b97a Merge pull request 'fix: delaytime jsdoc' (#1973) from delaytime-fix into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1973
2026-02-12 00:35:15 +01:00
Felix Roos a347bd607f fix: delaytime jsdoc 2026-02-12 00:12:04 +01:00
yaxu 3966659f18 Update .forgejo/workflows/deploy-hot.yml 2026-02-09 21:25:21 +01:00
yaxu 6d9b899ae4 Update .forgejo/workflows/deploy-hot.yml 2026-02-09 20:56:48 +01:00
yaxu d802907dac Update .forgejo/workflows/deploy-hot.yml 2026-02-09 20:49:38 +01:00
yaxu 2bea102dc1 Update .forgejo/workflows/deploy-hot.yml 2026-02-09 16:04:11 +01:00
yaxu d9e5c5b0c8 See if it runs without this test.. 2026-02-09 15:59:47 +01:00
yaxu 7c1304690f Update .forgejo/workflows/deploy-hot.yml 2026-02-09 15:58:14 +01:00
yaxu 3c366b37ae Update .forgejo/workflows/deploy-hot.yml 2026-02-09 15:57:36 +01:00
Alex McLean 52a206ceca hotfix: try to serve PRs hot 2026-02-09 14:44:50 +00:00
John Björk 23f6ff9bc4 Moved evaluateUserPrebake 2026-02-08 22:47:56 +01:00
John Björk 3e1d02cb3d Added codemirrors deps to website
Moved codemirrors deps to catalog
Added deps to website
Changed deps in codemirror package to use catalog deps
2026-02-08 22:34:33 +01:00
tyow 8a72f21257 add test 2026-02-08 12:45:47 -05:00
froos 2755a7eebf Merge pull request 'Feature: reconnectable MIDI inputs that keep track of last known CC state' (#1852) from unframework/strudel:nm/midi-input-state into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1852
2026-02-08 11:22:27 +01:00
unframework deb69ced9f Fix setting that uses a specific MIDI channel 2026-02-08 06:26:44 +01:00
tyow d5a9019220 shuffleFix 2026-02-07 21:17:49 -05:00
John Björk d0d2459e14 Missed one import - thank you eslint 2026-02-07 23:30:35 +01:00
John Björk a60a54147c Moved PrebakeCodeMirror to its own file 2026-02-07 23:15:12 +01:00
Felix Roos 6707c7893b move prebake nav down 2026-02-07 21:51:00 +01:00
froos 1d0e2bd0c7 Merge pull request 'Added host as prefix to pattern export' (#1962) from JohnBjrk/strudel:add-host-prefix-to-pattern-export into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1962
2026-02-07 21:40:02 +01:00
froos b5ef12a2b1 Merge pull request 'Bug: Set active pattern to viewing on init' (#1961) from JohnBjrk/strudel:fix-active-viewing-pattern-out-of-sync into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1961
2026-02-07 21:38:37 +01:00
froos e3dd4ab126 Merge pull request 'coarse: handle non-integer parameters more smoothly' (#1964) from pulu/strudel:non-integer-coarse into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1964
2026-02-07 16:16:09 +01:00
miranda 751f924a4d coarse: handle non-integer parameters more smoothly 2026-02-07 16:53:06 +02:00
John Björk 7b58bbf12b Remove icon buttons 2026-02-05 16:49:23 +01:00
John Björk b2cfd3dc3d Use ctrl-enter also on mac 2026-02-05 16:49:23 +01:00
John Björk 9f2237e11d Prebake in settings
Update prebake without reload
2026-02-05 16:49:23 +01:00
John Björk 6d99a7f399 Added host as prefix to pattern export 2026-02-05 16:46:18 +01:00
John Björk 5921bec5f4 Set active pattern to viewing on init 2026-02-05 16:35:36 +01:00
froos b4e171f5b4 Merge pull request 'fix 'Maximum call stack size exceeded'' (#1951) from Dsm0/strudel:overflow_fix into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1951
2026-02-05 10:26:30 +01:00
froos 90ad12300a Merge pull request 'feat: Add Helix keybindings' (#1821) from space-shell/strudel:space-shell/helix-keybindings into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1821
2026-02-05 10:12:58 +01:00
froos ffc6d12d87 Merge pull request 'scale & xen parity' (#1955) from tyow/localstrudel:xen-feats into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1955
2026-02-05 09:47:21 +01:00
tyow 1334276ec0 format 2026-02-04 20:29:44 -05:00
tyow cbf472fe0a creation of i control for tune and xen functions 2026-02-04 20:28:19 -05:00
tyow 4439599117 Merge branch 'main' into xen-feats 2026-02-04 15:31:33 -05:00
tyow 32754ef97a formatting uzu/strudel#1944 2026-02-04 12:44:49 -05:00
tyow 67bce82a62 added jsdoc documentation for tune re uzu/strudel#1944 2026-02-04 12:44:35 -05:00
tyow 5da3ae8df4 prelim work on uzu/strudel#1944 2026-02-04 12:42:59 -05:00
tyow eb3818eebb formatting uzu/strudel#1944 2026-02-04 12:34:51 -05:00
tyow e6b824ebff added jsdoc documentation for tune re uzu/strudel#1944 2026-02-04 12:34:28 -05:00
tyow c74dd95bd0 prelim work on uzu/strudel#1944 2026-02-04 12:31:57 -05:00
tyow 897a750f70 tagging 2026-02-02 16:10:47 -05:00
tyow 81d02cf5ae format 2026-02-02 16:08:01 -05:00
tyow a6dc6bb831 withBase 2026-02-02 16:07:40 -05:00
tyow 00a3c1acc0 clarified an example 2026-02-02 13:40:48 -05:00
tyow 8ff51d4468 small fixes 2026-02-01 18:16:19 -05:00
tyow 2e041e8647 added xen example 2026-02-01 13:53:50 -05:00
tyow 55095ba4e8 added examples to ftrans 2026-02-01 13:34:22 -05:00
tyow 530f45a7bb format 2026-02-01 00:39:42 -05:00
tyow 99295275ec ftrans draft 2026-02-01 00:39:22 -05:00
tyow c2e45eb36c code format 2026-01-31 14:07:26 -05:00
tyow 35e32a32e8 added frequency rounding 2026-01-31 14:07:13 -05:00
tyow 8def3f6af6 add note about octave assumption uzu/strudel#1944 2026-01-31 14:06:50 -05:00
tyow aa3698c4f4 Small wording changes, bring up to date with main, uzu/strudel#1944 2026-01-31 14:06:50 -05:00
tyow 6b525b4819 formatting uzu/strudel#1944 2026-01-31 14:06:50 -05:00
tyow 77ced19144 added jsdoc documentation for tune re uzu/strudel#1944 2026-01-31 14:06:28 -05:00
tyow 14cc5d989b feat: added Tune scales to Xen, improved documentation re uzu/strudel#1944 2026-01-31 14:06:28 -05:00
tyow 47ad4bb171 minor: remove space between Xen and Harmonic re uzu/strudel#1944 2026-01-31 14:05:36 -05:00
tyow e3ad6b1f0a added documentation for xen() re: uzu/strudel#1944 2026-01-31 14:05:36 -05:00
tyow 1e771d6150 prelim work on uzu/strudel#1944 2026-01-31 14:05:36 -05:00
froos 3ac4649536 Merge pull request 'improve xen documentation' (#1946) from tyow/localstrudel:xen-docs into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1946
2026-01-31 10:02:54 +01:00
tyow 14be1ddf7f code format 2026-01-30 17:22:52 -05:00
tyow 207ce9cbec added frequency rounding 2026-01-30 16:59:33 -05:00
tyow 3abcc59874 adjust wording 2026-01-30 14:13:36 -05:00
tyow 7fc8f1c5c9 xen/scale parity improvements 2026-01-30 14:12:31 -05:00
Dsm0 016315afbc fix 'Maximum call stack size exceeded' 2026-01-28 17:06:16 -08:00
space-shell b3c0d9179f Merge branch 'main' into space-shell/helix-keybindings 2026-01-28 09:59:38 +01:00
froos 993dc53e92 Merge pull request 'fix: pickF / pickmodF with object lookup' (#1924) from robojumper/strudel:fix/pickF-object-name-lookup into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1924
2026-01-28 00:03:57 +01:00
robojumper 60345db1bd Fix pickF / pickmodF with object lookup 2026-01-27 23:16:29 +01:00
tyow a09349e457 add note about octave assumption uzu/strudel#1944 2026-01-26 17:52:25 -05:00
tyow 71f538b115 Small wording changes, bring up to date with main, uzu/strudel#1944 2026-01-26 17:50:38 -05:00
Alex McLean 70be560a7a Merge pull request 'block based eval' (#1714) from Dsm0/strudel:block-based-eval-squashed into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1714
2026-01-25 23:57:37 +01:00
Alex McLean 269758ebdb Merge branch 'main' into block-based-eval-squashed 2026-01-25 23:49:44 +01:00
froos 98d613fbdf Merge pull request 'add lots of synonyms' (#1801) from TodePond/strudel:lu/synonyms into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1801
2026-01-25 20:30:51 +01:00
Felix Roos f5e21a220b Merge remote-tracking branch 'origin/main' into lu/synonyms 2026-01-25 20:27:52 +01:00
froos f6e6df78a8 Merge pull request 'improve reference tags' (#1945) from reference-tagging into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1945
2026-01-25 14:14:36 +01:00
tyow 05ae80b0ca formatting uzu/strudel#1944 2026-01-24 14:30:15 -05:00
tyow 3bbaf77b20 added jsdoc documentation for tune re uzu/strudel#1944 2026-01-24 14:18:51 -05:00
Felix Roos a9975a2b41 reference: show synonyms in the same row, grayed out, instead of adding a row for each synonym 2026-01-24 02:01:36 +01:00
tyow 2b3f719484 feat: added Tune scales to Xen, improved documentation re uzu/strudel#1944 2026-01-23 18:09:03 -05:00
Felix Roos 4051fd5241 oh snap 2026-01-23 23:52:59 +01:00
Felix Roos 55ffb59b9c tagging a lot of superdough functions 2026-01-23 23:40:51 +01:00
tyow 93cf9fcb6a minor: remove space between Xen and Harmonic re uzu/strudel#1944 2026-01-23 17:18:38 -05:00
tyow a5b867f1b1 added documentation for xen() re: uzu/strudel#1944 2026-01-23 16:32:41 -05:00
tyow d8645e9a71 prelim work on uzu/strudel#1944 2026-01-23 13:36:20 -05:00
Felix Roos b824ad16ae Merge remote-tracking branch 'origin/main' into reference-tagging 2026-01-23 10:00:17 +01:00
space-shell 03b221a2a2 Merge branch 'main' into space-shell/helix-keybindings 2026-01-23 09:17:09 +01:00
froos 28db6d1fd4 Merge pull request 'clean up crumbs of crunchy design' (#1942) from stylez into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1942
2026-01-22 22:15:01 +01:00
Aria dc9cadc979 Merge pull request 'Feat: Make default alignment configurable' (#1941) from glossing/alignment into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1941
Reviewed-by: Switch Angel AKA Jade Rose <daslyfe@noreply.codeberg.org>
2026-01-22 21:50:28 +01:00
Aria 0001d9c27b Change name to setDefaultJoin 2026-01-22 14:24:45 -06:00
Aria d79d122f68 Add docstring and example 2026-01-22 14:08:26 -06:00
Aria 6e61f3cdb6 Make default alignment configurable 2026-01-22 13:52:57 -06:00
froos 7f2860eb14 Merge pull request 'crunch up layout' (#1938) from stylez into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1938
2026-01-22 10:28:08 +01:00
froos b684e183a0 Merge pull request 'add muted theme color + tweak pane styles' (#1936) from stylez into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1936
2026-01-21 00:27:43 +01:00
Aria 6105d5027d Merge pull request 'Feat: Add retrig option for LFOs' (#1934) from glossing/lfo-retrig into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1934
2026-01-20 04:37:19 +01:00
Dsm0 c96b1c2faf fixed anonymous label casing 2026-01-19 11:53:08 -08:00
Dsm0 87e723649e fixed highlighting issue 2026-01-19 11:53:08 -08:00
Aria 62bf9abed3 Add retrig option for LFOs 2026-01-19 09:52:49 -08:00
space-shell e35a2000de reverts changelog changes because human... 2026-01-19 10:15:10 +01:00
space-shell 4b8d80016b Remove trailin whitespace from CHANGELOG.md 2026-01-19 10:13:48 +01:00
space-shell d2f2c6df02 Merge branch 'main' into space-shell/helix-keybindings 2026-01-19 10:05:14 +01:00
Felix Roos 9bcaaa3ac4 Merge branch 'main' into block-based-eval-squashed 2026-01-18 13:09:00 +01:00
Dsm0 2e7ec9ea27 variable declarations with active patterns now have highlighting 2026-01-15 19:27:54 -08:00
Nick Matantsev bc9e97b9ac Add comments 2026-01-15 00:13:49 -05:00
Nick Matantsev 00f0ef56bc Merge remote-tracking branch 'origin/main' into nm/midi-input-state 2026-01-14 23:55:02 -05:00
Nick Matantsev 44ad05fb20 Whitespace fixes 2026-01-14 23:37:26 -05:00
space-shell 18cc1e4466 Merge branch 'space-shell/helix-keybindings' of codeberg.org:space-shell/strudel into space-shell/helix-keybindings 2026-01-13 19:49:49 +01:00
space-shell 055f009357 feat: Refactor Vim/Helix keybindings with shared helper functions
Factor out the repl-eval and repl-stop event dispatching code into
replEval() and replStop() helper functions that are shared between
Vim and Helix keybindings.

This refactoring:
- Reduces code duplication between Vim :w/:q and Helix :w/:q commands
- Makes the keybinding handlers more maintainable
- Properly implements Helix commands using the commands.of() API
- Ensures consistent behavior across both keybinding modes

The Helix keybindings now properly integrate with the REPL using the
same event dispatch mechanism as Vim, dispatching custom repl-evaluate
and repl-stop events with fallbacks to keyboard events.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-13 19:47:12 +01:00
Nick Matantsev 2fe1712dae Merge remote-tracking branch 'upstream/main' into nm/midi-input-state 2026-01-10 12:33:23 -05:00
space-shell 0ef30d5e8d Merge branch 'main' into space-shell/helix-keybindings 2026-01-05 10:49:16 +01:00
Dsm0 160ef84b47 move evaluate/evaluateBlock to different functions 2025-12-30 20:14:32 -06:00
Dsm0 1a7f464998 requested revisions 1 (and fix failing test) 2025-12-30 18:34:26 -06:00
Nick Matantsev ee326c709e Fix MIDI input logging 2025-12-25 21:43:06 -05:00
Nick Matantsev a0291e8a42 Fix comments 2025-12-25 21:07:39 -05:00
Nick Matantsev ecada58edc Carry out MIDI input wrapper into dedicated file 2025-12-25 20:59:50 -05:00
Nick Matantsev bb7f587025 Encapsulate more logic 2025-12-25 20:42:11 -05:00
Nick Matantsev dab1a018e4 Support delayed connection and reconnection of MIDI inputs 2025-12-25 20:42:11 -05:00
Nick Matantsev 0c01a997ac Avoid referencing found MIDI device name 2025-12-25 20:42:11 -05:00
Nick Matantsev b8dc50267e Clean up unused state fields 2025-12-25 20:42:11 -05:00
Nick Matantsev 1aaa04ab50 Send stored CC values to controller 2025-12-25 20:42:11 -05:00
Nick Matantsev 1860d31624 Tweak cc init 2025-12-25 20:42:11 -05:00
Nick Matantsev 47557cf06f Move device lookup inside input wrapper 2025-12-25 20:42:11 -05:00
Nick Matantsev 213edfb211 Roll up save/load state into MIDI wrapper 2025-12-25 20:42:11 -05:00
Nick Matantsev 84e59645fd Encapsulate MIDI input handling 2025-12-25 20:42:11 -05:00
Nick Matantsev 4df19f9ff8 Save MIDI-in CC state 2025-12-25 20:42:11 -05:00
Dsm0 ba7721f53c code formatting 2025-12-23 02:25:18 -08:00
Dsm0 fd1e3d248a block based eval 2025-12-23 01:47:06 -08:00
space-shell bad81e3850 Merge branch 'main' into space-shell/helix-keybindings 2025-12-22 13:01:22 +01:00
space-shell a409149dae Merge branch 'main' into space-shell/helix-keybindings 2025-12-15 13:48:44 +01:00
space-shell a6b3b9ef5e updates formatting 2025-12-15 13:22:13 +01:00
space-shell ec73c48e2e feat: Add Helix keybindings and Nix development environment
Add support for Helix editor keybindings in the REPL, providing users
with a modern, selection-first modal editing experience as an alternative
to Vim, Emacs, and VSCode keybindings.

Helix Keybindings Integration:

- Add codemirror-helix@^0.5.0 dependency to @strudel/codemirror
- Import and register helix keybindings in keybindings.mjs
- Add 'Helix' option to keybindings selector in Settings UI
- Create comprehensive user documentation at technical-manual/helix.mdx
- Update developer documentation with keybinding integration pattern

The Helix mode provides selection-first editing (select → action) which
differs from Vim's action-motion paradigm, offering an alternative
workflow for modal editing enthusiasts.

Documentation:

- Update CLAUDE.md with Helix support and keybinding addition process
- Create helix.mdx with user-facing documentation and command reference
- Update CHANGELOG.md with December 2025 entry

Testing:

Run `pnpm i` to install new dependencies, then `pnpm dev` to test.
Verify Helix mode works in Settings → Keybindings → Helix.
2025-12-08 09:44:02 +01:00
Lu[ke] Wilson 65dd79e374 add lots of synonyms 2025-11-29 17:44:33 +00:00
robmckinnon ad331c8c2e Show EDO intervals in _pitchwheel() display 2025-06-29 10:49:04 +02:00
robmckinnon 43293dc80f Import packages/edo into strudel 2025-06-29 10:49:04 +02:00
robmckinnon 73965a3a39 Add packages/edo for Equal Division of the Octave scales 2025-06-29 10:49:04 +02:00
85 changed files with 5137 additions and 1390 deletions
+44
View File
@@ -0,0 +1,44 @@
name: Build and Deploy hot PRs
on:
pull_request_target:
types: [labeled]
# Allow one concurrent deployment
concurrency:
group: "warm-pages"
cancel-in-progress: false
jobs:
build:
if: ${{ github.event.label.name == 'serve-hot' }}
runs-on: docker
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: pnpm/action-setup@v4
with:
version: 9.12.2
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install Dependencies
run: pnpm install
- name: Build
run: pnpm build
- name: Deploy
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
eval $(ssh-agent -s)
echo "$SSH_PRIVATE_KEY" | ssh-add -
apt update && apt install -y rsync
mkdir -p ~/.ssh
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
+1
View File
@@ -29,6 +29,7 @@ const editor = new StrudelMirror({
import('@strudel/core'), import('@strudel/core'),
import('@strudel/draw'), import('@strudel/draw'),
import('@strudel/mini'), import('@strudel/mini'),
import('@strudel/edo'),
import('@strudel/tonal'), import('@strudel/tonal'),
import('@strudel/webaudio'), import('@strudel/webaudio'),
); );
+1
View File
@@ -15,6 +15,7 @@
"@strudel/codemirror": "workspace:*", "@strudel/codemirror": "workspace:*",
"@strudel/core": "workspace:*", "@strudel/core": "workspace:*",
"@strudel/draw": "workspace:*", "@strudel/draw": "workspace:*",
"@strudel/edo": "workspace:*",
"@strudel/mini": "workspace:*", "@strudel/mini": "workspace:*",
"@strudel/soundfonts": "workspace:*", "@strudel/soundfonts": "workspace:*",
"@strudel/tonal": "workspace:*", "@strudel/tonal": "workspace:*",
+1
View File
@@ -4,6 +4,7 @@ export * from './packages/core/index.mjs';
export * from './packages/csound/index.mjs'; export * from './packages/csound/index.mjs';
export * from './packages/desktopbridge/index.mjs'; export * from './packages/desktopbridge/index.mjs';
export * from './packages/draw/index.mjs'; export * from './packages/draw/index.mjs';
export * from './packages/edo/index.mjs';
export * from './packages/embed/index.mjs'; export * from './packages/embed/index.mjs';
export * from './packages/hydra/index.mjs'; export * from './packages/hydra/index.mjs';
export * from './packages/midi/index.mjs'; export * from './packages/midi/index.mjs';
+1
View File
@@ -48,6 +48,7 @@
"homepage": "https://strudel.cc", "homepage": "https://strudel.cc",
"dependencies": { "dependencies": {
"@strudel/core": "workspace:*", "@strudel/core": "workspace:*",
"@strudel/edo": "workspace:*",
"@strudel/mini": "workspace:*", "@strudel/mini": "workspace:*",
"@strudel/tonal": "workspace:*", "@strudel/tonal": "workspace:*",
"@strudel/transpiler": "workspace:*", "@strudel/transpiler": "workspace:*",
+51
View File
@@ -0,0 +1,51 @@
// Block-based evaluation utilities
export function getBlockRegions(code) {
const chars = code.split('');
let i = 0,
blanks = [],
blockStart = 0,
regions = [];
while (i < chars.length) {
const isBlank = chars[i] === '\n';
if (isBlank) {
blanks.push(i);
} else if (chars[i].trim() !== '') {
if (blanks.length > 1) {
regions.push([blockStart, blanks[0]]);
blockStart = i;
}
blanks = [];
}
i++;
}
regions.push([blockStart, blanks.length ? blanks[0] : i]);
return regions;
}
export function getBlockAt(code, cursor) {
const regions = getBlockRegions(code);
for (const [start, end] of regions) {
if (cursor >= start && cursor <= end) {
return [start, end];
}
}
return null;
}
export const evalBlock = (strudelMirror) => {
const { state } = strudelMirror.editor;
const code = state.doc.toString();
const cursor = state.selection.main.head;
const range = getBlockAt(code, cursor);
if (range) {
const [a, b] = range;
const block = code.slice(a, b);
if (block) {
// Flash the block being evaluated
strudelMirror.flash(200, { from: a, to: b });
strudelMirror.repl.evaluateBlock(block, true, { range });
}
}
return true;
};
+87 -24
View File
@@ -13,18 +13,18 @@ import {
} from '@codemirror/view'; } from '@codemirror/view';
import { persistentAtom } from '@nanostores/persistent'; import { persistentAtom } from '@nanostores/persistent';
import { logger, registerControl, repl } from '@strudel/core'; import { logger, registerControl, repl } from '@strudel/core';
import { cleanupDraw, Drawer } from '@strudel/draw'; import { cleanupDraw, cleanupDrawContext, Drawer } from '@strudel/draw';
import { isAutoCompletionEnabled } from './autocomplete.mjs'; import { isAutoCompletionEnabled } from './autocomplete.mjs';
import { basicSetup } from './basicSetup.mjs'; import { basicSetup } from './basicSetup.mjs';
import { evalBlock } from './block_utilities.mjs';
import { flash, isFlashEnabled } from './flash.mjs'; import { flash, isFlashEnabled } from './flash.mjs';
import { highlightMiniLocations, isPatternHighlightingEnabled, updateMiniLocations } from './highlight.mjs'; import { highlightMiniLocations, isPatternHighlightingEnabled, updateMiniLocations } from './highlight.mjs';
import { keybindings } from './keybindings.mjs'; import { keybindings } from './keybindings.mjs';
import { sliderPlugin, updateSliderWidgets } from './slider.mjs'; import { jumpToCharacter } from './labelJump.mjs';
import { getSliderWidgets, sliderPlugin, updateSliderWidgets } from './slider.mjs';
import { activateTheme, initTheme, theme } from './themes.mjs'; import { activateTheme, initTheme, theme } from './themes.mjs';
import { isTooltipEnabled } from './tooltip.mjs'; import { isTooltipEnabled } from './tooltip.mjs';
import { updateWidgets, widgetPlugin } from './widget.mjs'; import { getActiveWidgets, updateWidgets, widgetPlugin } from './widget.mjs';
import { jumpToCharacter } from './labelJump.mjs';
export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands'; export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands';
@@ -64,6 +64,7 @@ export const defaultSettings = {
isLineWrappingEnabled: false, isLineWrappingEnabled: false,
isTabIndentationEnabled: false, isTabIndentationEnabled: false,
isMultiCursorEnabled: false, isMultiCursorEnabled: false,
isBlockBasedEvalEnabled: false,
theme: 'strudelTheme', theme: 'strudelTheme',
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: 18, fontSize: 18,
@@ -75,7 +76,7 @@ export const codemirrorSettings = persistentAtom('codemirror-settings', defaultS
}); });
// https://codemirror.net/docs/guide/ // https://codemirror.net/docs/guide/
export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, root, mondo }) { export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, root, mondo, strudelMirror }) {
const settings = codemirrorSettings.get(); const settings = codemirrorSettings.get();
const initialSettings = Object.keys(compartments).map((key) => const initialSettings = Object.keys(compartments).map((key) =>
compartments[key].of(extensions[key](parseBooleans(settings[key]))), compartments[key].of(extensions[key](parseBooleans(settings[key]))),
@@ -105,11 +106,26 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
keymap.of([ keymap.of([
{ {
key: 'Ctrl-Enter', key: 'Ctrl-Enter',
run: () => onEvaluate?.(), run: () => {
// issue with referencing settings, this works more reliably
if (strudelMirror?.isBlockBasedEvalEnabled) {
evalBlock(strudelMirror);
return true;
} else {
return onEvaluate?.();
}
},
}, },
{ {
key: 'Alt-Enter', key: 'Alt-Enter',
run: () => onEvaluate?.(), run: () => {
if (strudelMirror?.isBlockBasedEvalEnabled) {
evalBlock(strudelMirror);
return true;
} else {
return onEvaluate?.();
}
},
}, },
{ {
key: 'Ctrl-.', key: 'Ctrl-.',
@@ -171,6 +187,7 @@ export class StrudelMirror {
this.onDraw = onDraw || this.draw; this.onDraw = onDraw || this.draw;
this.id = id || s4(); this.id = id || s4();
this.solo = solo; this.solo = solo;
this.isBlockBasedEvalEnabled = false; // Will be updated via updateSettings()
this.drawer = new Drawer((haps, time, _, painters) => { this.drawer = new Drawer((haps, time, _, painters) => {
const currentFrame = haps.filter((hap) => hap.isActive(time)); const currentFrame = haps.filter((hap) => hap.isActive(time));
@@ -201,20 +218,28 @@ export class StrudelMirror {
cleanupDraw(true, id); cleanupDraw(true, id);
} }
}, },
beforeEval: async () => { beforeEval: async ({ blockBased } = {}) => {
cleanupDraw(true, id); // Only clean up all drawings for full evaluation
// Block-based eval should preserve animations (like .scope()) from other blocks
if (!blockBased) {
cleanupDraw(true, id);
}
await this.prebaked; await this.prebaked;
await replOptions?.beforeEval?.(); await replOptions?.beforeEval?.();
}, },
afterEval: (options) => { afterEval: (options) => {
// remember for when highlighting is toggled on // remember for when highlighting is toggled on
this.miniLocations = options.meta?.miniLocations; this.miniLocations = options.meta?.miniLocations || [];
this.widgets = options.meta?.widgets; this.widgets = options.meta?.widgets || [];
const sliders = this.widgets.filter((w) => w.type === 'slider'); const sliders = this.widgets.filter((w) => w.type === 'slider');
updateSliderWidgets(this.editor, sliders);
const widgets = this.widgets.filter((w) => w.type !== 'slider'); const widgets = this.widgets.filter((w) => w.type !== 'slider');
updateWidgets(this.editor, widgets); // range-aware update for block-based evaluation
updateMiniLocations(this.editor, this.miniLocations); const range = options.range && options.range.length >= 2 ? options.range : null;
updateSliderWidgets(this.editor, sliders, range);
updateWidgets(this.editor, widgets, range);
updateMiniLocations(this.editor, this.miniLocations, range);
replOptions?.afterEval?.(options); replOptions?.afterEval?.(options);
// if no painters are set (.onPaint was not called), then we only need // if no painters are set (.onPaint was not called), then we only need
// the present moment (for highlighting) // the present moment (for highlighting)
@@ -222,8 +247,14 @@ export class StrudelMirror {
this.drawer.setDrawTime(drawTime); this.drawer.setDrawTime(drawTime);
// invalidate drawer after we've set the appropriate drawTime // invalidate drawer after we've set the appropriate drawTime
this.drawer.invalidate(this.repl.scheduler); this.drawer.invalidate(this.repl.scheduler);
// Clean up draw context if a non-inline widget was removed
if (options.widgetRemoved) {
cleanupDrawContext(id);
}
}, },
}); });
this.cleanupDrawContext = () => cleanupDrawContext(id);
this.editor = initEditor({ this.editor = initEditor({
root, root,
initialCode, initialCode,
@@ -236,7 +267,9 @@ export class StrudelMirror {
onEvaluate: () => this.evaluate(), onEvaluate: () => this.evaluate(),
onStop: () => this.stop(), onStop: () => this.stop(),
mondo: replOptions.mondo, mondo: replOptions.mondo,
strudelMirror: this,
}); });
const cmEditor = this.root.querySelector('.cm-editor'); const cmEditor = this.root.querySelector('.cm-editor');
if (cmEditor) { if (cmEditor) {
this.root.style.display = 'block'; this.root.style.display = 'block';
@@ -260,6 +293,9 @@ export class StrudelMirror {
// Handle global evaluation requests (e.g., from Vim :w) // Handle global evaluation requests (e.g., from Vim :w)
this.onEvaluateRequest = (e) => { this.onEvaluateRequest = (e) => {
try { try {
if (e.detail.view !== this.editor) {
return; // ignore events from other editors
}
// Evaluate current editor on repl-evaluate // Evaluate current editor on repl-evaluate
logger('[repl] evaluate via event'); logger('[repl] evaluate via event');
this.evaluate(); this.evaluate();
@@ -274,6 +310,9 @@ export class StrudelMirror {
// Toggle comments requested from Vim (gc) // Toggle comments requested from Vim (gc)
this.onToggleComment = (e) => { this.onToggleComment = (e) => {
try { try {
if (e.detail.view !== this.editor) {
return; // ignore events from other editors
}
// Honor selections; toggleLineComment handles both selections and // Honor selections; toggleLineComment handles both selections and
// single line // single line
toggleLineComment(this.editor); toggleLineComment(this.editor);
@@ -306,13 +345,17 @@ export class StrudelMirror {
this.flash(); this.flash();
await this.repl.evaluate(this.code, autostart); await this.repl.evaluate(this.code, autostart);
} }
async stop() { async stop() {
this.repl.scheduler.stop(); this.repl.stop();
} }
// Listen for global stop requests (e.g., from Vim :q) // Listen for global stop requests (e.g., from Vim :q)
onStopRequest = (e) => { onStopRequest = (e) => {
try { try {
if (e.detail.view !== this.editor) {
return; // ignore events from other editors
}
this.stop(); this.stop();
e?.cancelable && e.preventDefault?.(); e?.cancelable && e.preventDefault?.();
} catch (err) { } catch (err) {
@@ -326,8 +369,8 @@ export class StrudelMirror {
this.evaluate(); this.evaluate();
} }
} }
flash(ms) { flash(ms, range) {
flash(this.editor, ms); flash(this.editor, ms, range);
} }
highlight(haps, time) { highlight(haps, time) {
highlightMiniLocations(this.editor, time, haps); highlightMiniLocations(this.editor, time, haps);
@@ -359,6 +402,10 @@ export class StrudelMirror {
setLineWrappingEnabled(enabled) { setLineWrappingEnabled(enabled) {
this.reconfigureExtension('isLineWrappingEnabled', enabled); this.reconfigureExtension('isLineWrappingEnabled', enabled);
} }
setBlockBasedEvalEnabled(enabled) {
this.reconfigureExtension('isBlockBasedEvalEnabled', enabled);
}
setBracketMatchingEnabled(enabled) { setBracketMatchingEnabled(enabled) {
this.reconfigureExtension('isBracketMatchingEnabled', enabled); this.reconfigureExtension('isBracketMatchingEnabled', enabled);
} }
@@ -380,6 +427,10 @@ export class StrudelMirror {
for (let key in extensions) { for (let key in extensions) {
this.reconfigureExtension(key, settings[key]); this.reconfigureExtension(key, settings[key]);
} }
// Update block-based eval setting on the instance
if (settings.isBlockBasedEvalEnabled !== undefined) {
this.isBlockBasedEvalEnabled = parseBooleans(settings.isBlockBasedEvalEnabled);
}
const updated = { ...codemirrorSettings.get(), ...settings }; const updated = { ...codemirrorSettings.get(), ...settings };
codemirrorSettings.set(updated); codemirrorSettings.set(updated);
} }
@@ -393,14 +444,26 @@ export class StrudelMirror {
this.setFontSize(value); this.setFontSize(value);
} }
} }
setCode(code) { replaceCode(code, from, to) {
const changes = { const changes = { from, to, insert: code };
from: 0,
to: this.editor.state.doc.length,
insert: code,
};
this.editor.dispatch({ changes }); this.editor.dispatch({ changes });
} }
insertCode(code, position) {
this.replaceCode(code, position, position);
}
setCode(code) {
this.replaceCode(code, 0, this.editor.state.doc.length);
}
// used for debugging but could serve other purposes
getActiveWidgets() {
return getActiveWidgets(this.editor);
}
getSliderWidgets() {
return getSliderWidgets(this.editor);
}
getMiniLocations() {
return this.miniLocations;
}
clear() { clear() {
this.onStartRepl && document.removeEventListener('start-repl', this.onStartRepl); this.onStartRepl && document.removeEventListener('start-repl', this.onStartRepl);
this.onEvaluateRequest && document.removeEventListener('repl-evaluate', this.onEvaluateRequest); this.onEvaluateRequest && document.removeEventListener('repl-evaluate', this.onEvaluateRequest);
+5 -3
View File
@@ -14,7 +14,8 @@ export const flashField = StateField.define({
const mark = Decoration.mark({ const mark = Decoration.mark({
attributes: { style: `background-color: rgba(255,255,255, .4); filter: invert(10%)` }, attributes: { style: `background-color: rgba(255,255,255, .4); filter: invert(10%)` },
}); });
flash = Decoration.set([mark.range(0, tr.newDoc.length)]); const range = e.value.range || { from: 0, to: tr.newDoc.length };
flash = Decoration.set([mark.range(range.from, range.to)]);
} else { } else {
flash = Decoration.set([]); flash = Decoration.set([]);
} }
@@ -29,8 +30,9 @@ export const flashField = StateField.define({
provide: (f) => EditorView.decorations.from(f), provide: (f) => EditorView.decorations.from(f),
}); });
export const flash = (view, ms = 200) => { export const flash = (view, ms = 200, range) => {
view.dispatch({ effects: setFlash.of(true) }); const flashData = range ? { range } : true;
view.dispatch({ effects: setFlash.of(flashData) });
setTimeout(() => { setTimeout(() => {
view.dispatch({ effects: setFlash.of(false) }); view.dispatch({ effects: setFlash.of(false) });
}, ms); }, ms);
+164 -63
View File
@@ -3,8 +3,9 @@ import { Decoration, EditorView } from '@codemirror/view';
export const setMiniLocations = StateEffect.define(); export const setMiniLocations = StateEffect.define();
export const showMiniLocations = StateEffect.define(); export const showMiniLocations = StateEffect.define();
export const updateMiniLocations = (view, locations) => { export const displayMiniLocations = StateEffect.define();
view.dispatch({ effects: setMiniLocations.of(locations) }); export const updateMiniLocations = (view, locations, range = null) => {
view.dispatch({ effects: setMiniLocations.of({ locations, range }) });
}; };
export const highlightMiniLocations = (view, atTime, haps) => { export const highlightMiniLocations = (view, atTime, haps) => {
view.dispatch({ effects: showMiniLocations.of({ atTime, haps }) }); view.dispatch({ effects: showMiniLocations.of({ atTime, haps }) });
@@ -21,23 +22,54 @@ const miniLocations = StateField.define({
for (let e of tr.effects) { for (let e of tr.effects) {
if (e.is(setMiniLocations)) { if (e.is(setMiniLocations)) {
// this is called on eval, with the mini locations obtained from the transpiler //block-based eval case
// codemirror will automatically remap the marks when the document is edited if (e.value.range) {
// create a mark for each mini location, adding the range to the spec to find it later const stateMiniLocations = getMiniLocationsFromDecorations(locations);
const marks = e.value
.filter(([from]) => from < tr.newDoc.length)
.map(([from, to]) => [from, Math.min(to, tr.newDoc.length)])
.map(
(range) =>
Decoration.mark({
id: range.join(':'),
// this green is only to verify that the decoration moves when the document is edited
// it will be removed later, so the mark is not visible by default
attributes: { style: `background-color: #00CA2880` },
}).range(...range), // -> Decoration
);
locations = Decoration.set(marks, true); // -> DecorationSet === RangeSet<Decoration> const normalized = e.value.locations
.filter(([from]) => from < tr.newDoc.length)
.map(([from, to]) => [from, Math.min(to, tr.newDoc.length)]);
const newIds = new Set(normalized.map((r) => r.join(':')));
const marks = normalized.map((range) => {
const id = range.join(':');
return Decoration.mark({
id,
// this green is only to verify that the decoration moves when the document is edited
// it will be removed later, so the mark is not visible by default
attributes: { style: `background-color: #00CA2880` },
}).range(...range); // -> Decoration
});
const previousMarks = stateMiniLocations
.filter(({ id }) => !newIds.has(id))
.map(({ from, to, id }) =>
Decoration.mark({
id,
attributes: { style: `background-color: #00CA2880` },
}).range(from, to),
);
locations = Decoration.set(previousMarks.concat(marks), true); // -> DecorationSet === RangeSet<Decoration>
} else {
// this is called on eval, with the mini locations obtained from the transpiler
// codemirror will automatically remap the marks when the document is edited
// create a mark for each mini location, adding the range to the spec to find it later
const marks = e.value.locations
.filter(([from]) => from < tr.newDoc.length)
.map(([from, to]) => [from, Math.min(to, tr.newDoc.length)])
.map(
(range) =>
Decoration.mark({
id: range.join(':'),
// this green is only to verify that the decoration moves when the document is edited
// it will be removed later, so the mark is not visible by default
attributes: { style: `background-color: #00CA2880` },
}).range(...range), // -> Decoration
);
locations = Decoration.set(marks, true); // -> DecorationSet === RangeSet<Decoration>
}
} }
} }
@@ -75,12 +107,83 @@ const visibleMiniLocations = StateField.define({
}, },
}); });
// // Derive the set of decorations from the miniLocations and visibleLocations const displayMiniLocationsState = StateField.define({
const miniLocationHighlights = EditorView.decorations.compute([miniLocations, visibleMiniLocations], (state) => { create() {
const iterator = state.field(miniLocations).iter(); return true; // default to showing miniLocations
const { haps } = state.field(visibleMiniLocations); },
const builder = new RangeSetBuilder(); update(display, tr) {
for (let e of tr.effects) {
if (e.is(displayMiniLocations)) {
display = e.value;
}
}
return display;
},
});
// // Derive the set of decorations from the miniLocations and visibleLocations
const miniLocationHighlights = EditorView.decorations.compute(
[miniLocations, visibleMiniLocations, displayMiniLocationsState],
(state) => {
// Check if miniLocations display is disabled
const shouldDisplay = state.field(displayMiniLocationsState);
if (!shouldDisplay) {
return Decoration.none; // Return empty decorations if display is disabled
}
const iterator = state.field(miniLocations).iter();
const { haps } = state.field(visibleMiniLocations);
const builder = new RangeSetBuilder();
while (iterator.value) {
const {
from,
to,
value: {
spec: { id },
},
} = iterator;
if (haps.has(id)) {
const hap = haps.get(id);
const color = hap.value?.color ?? 'var(--foreground)';
const style = hap.value?.markcss || `outline: solid 2px ${color}`;
// Get explicit channels for color values
/*
const swatch = document.createElement('div');
swatch.style.color = color;
document.body.appendChild(swatch);
let channels = getComputedStyle(swatch)
.color.match(/^rgba?\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})(?:,\s*(\d*(?:\.\d+)?))?\)$/)
.slice(1)
.map((c) => parseFloat(c || 1));
document.body.removeChild(swatch);
// Get percentage of event
const percent = 1 - (atTime - hap.whole.begin) / hap.whole.duration;
channels[3] *= percent;
*/
builder.add(
from,
to,
Decoration.mark({
// attributes: { style: `outline: solid 2px rgba(${channels.join(', ')})` },
attributes: { style },
}),
);
}
iterator.next();
}
return builder.finish();
},
);
const getMiniLocationsFromDecorations = (decorations) => {
const iterator = decorations.iter();
const miniLocationsArray = [];
while (iterator.value) { while (iterator.value) {
const { const {
from, from,
@@ -89,50 +192,48 @@ const miniLocationHighlights = EditorView.decorations.compute([miniLocations, vi
spec: { id }, spec: { id },
}, },
} = iterator; } = iterator;
miniLocationsArray.push({
if (haps.has(id)) { from,
const hap = haps.get(id); to,
const color = hap.value?.color ?? 'var(--foreground)'; id,
const style = hap.value?.markcss || `outline: solid 2px ${color}`; });
// Get explicit channels for color values
/*
const swatch = document.createElement('div');
swatch.style.color = color;
document.body.appendChild(swatch);
let channels = getComputedStyle(swatch)
.color.match(/^rgba?\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})(?:,\s*(\d*(?:\.\d+)?))?\)$/)
.slice(1)
.map((c) => parseFloat(c || 1));
document.body.removeChild(swatch);
// Get percentage of event
const percent = 1 - (atTime - hap.whole.begin) / hap.whole.duration;
channels[3] *= percent;
*/
builder.add(
from,
to,
Decoration.mark({
// attributes: { style: `outline: solid 2px rgba(${channels.join(', ')})` },
attributes: { style },
}),
);
}
iterator.next(); iterator.next();
} }
return miniLocationsArray;
};
return builder.finish(); export const getMiniLocations = (state) => {
}); const decorations = state.field(miniLocations);
return getMiniLocationsFromDecorations(decorations);
};
export const highlightExtension = [miniLocations, visibleMiniLocations, miniLocationHighlights]; export const getActiveMiniLocations = (state) => {
const miniLocations = getMiniLocations(state);
const { haps } = state.field(visibleMiniLocations);
const activeMiniLocations = miniLocations.filter((location) => haps.has(location.id));
return activeMiniLocations;
};
export const highlightExtension = [
miniLocations,
visibleMiniLocations,
displayMiniLocationsState,
miniLocationHighlights,
];
export const isPatternHighlightingEnabled = (on, config) => { export const isPatternHighlightingEnabled = (on, config) => {
on && // NOTE:
config && // Modified this function to always return the highlightExtension, and instead just toggle whether or not the miniLocations are displayed.
setTimeout(() => { // This is because block based evaluation only updates regions of miniLocations, and those updates need to be kept track of constantly.
updateMiniLocations(config.editor, config.miniLocations); // The setTimeout was also removed because it conflicted with the range-specific updates required by
}, 100); // block based evaluation.
return on ? Prec.highest(highlightExtension) : []; // Not sure if this is the best approach, but for block based eval I can't think of a better way to do it.
if (config) {
// Toggle the display state for miniLocations
config.editor.dispatch({ effects: displayMiniLocations.of(on) });
}
return Prec.highest(highlightExtension);
}; };
+1
View File
@@ -5,3 +5,4 @@ export * from './slider.mjs';
export * from './themes.mjs'; export * from './themes.mjs';
export * from './widget.mjs'; export * from './widget.mjs';
export { Vim } from './keybindings.mjs'; export { Vim } from './keybindings.mjs';
export * from './basicSetup.mjs';
+110 -58
View File
@@ -6,6 +6,7 @@ import { emacs } from '@replit/codemirror-emacs';
import { vim, Vim } from '@replit/codemirror-vim'; import { vim, Vim } from '@replit/codemirror-vim';
// import { vim } from './vim_test.mjs'; // import { vim } from './vim_test.mjs';
import { vscodeKeymap } from '@replit/codemirror-vscode-keymap'; import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
import { helix, commands } from 'codemirror-helix';
import { logger } from '@strudel/core'; import { logger } from '@strudel/core';
const vscodePlugin = ViewPlugin.fromClass( const vscodePlugin = ViewPlugin.fromClass(
@@ -20,6 +21,70 @@ const vscodePlugin = ViewPlugin.fromClass(
); );
const vscodeExtension = (options) => [vscodePlugin].concat(options ?? []); const vscodeExtension = (options) => [vscodePlugin].concat(options ?? []);
function replEval(view) {
try {
// Dispatch a dedicated evaluate event first
let handled = false;
try {
const ev = new CustomEvent('repl-evaluate', { detail: { source: 'vim', view }, cancelable: true });
handled = document.dispatchEvent(ev) === false; // false means preventDefault was called
} catch (e) {
console.error('Error dispatching repl-evaluate event', e);
}
if (handled) {
return;
}
// Try Ctrl+Enter first if not handled by custom event
const ctrlEnter = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
ctrlKey: true,
bubbles: true,
cancelable: true,
});
view?.dom?.dispatchEvent?.(ctrlEnter);
// If not handled (no handler called preventDefault), try Alt+Enter as
// fallback
if (!ctrlEnter.defaultPrevented) {
const altEnter = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
altKey: true,
bubbles: true,
cancelable: true,
});
view?.dom?.dispatchEvent?.(altEnter);
}
} catch (e) {
console.error('Error dispatching repl evaluation event', e);
}
}
function replStop(view) {
try {
// First try dispatching our custom stop event, then fallback to Alt+.
let handled = false;
try {
const ev = new CustomEvent('repl-stop', { detail: { source: 'vim', view }, cancelable: true });
handled = document.dispatchEvent(ev) === false;
} catch (e) {
console.error('Error dispatching repl-stop event', e);
}
if (!handled) {
const altDot = new KeyboardEvent('keydown', {
key: '.',
code: 'Period',
altKey: true,
bubbles: true,
cancelable: true,
});
view?.dom?.dispatchEvent?.(altDot);
}
} catch (e) {
console.error('Error dispatching repl stop event', e);
}
}
// Map Vim :w to trigger the same action as evaluation. We dispatch a custom // Map Vim :w to trigger the same action as evaluation. We dispatch a custom
// event 'repl-evaluate' that the editor listens for, and also simulate // event 'repl-evaluate' that the editor listens for, and also simulate
// Ctrl+Enter/Alt+Enter as a fallback. We log to the Strudel logger so it // Ctrl+Enter/Alt+Enter as a fallback. We log to the Strudel logger so it
@@ -31,7 +96,7 @@ try {
// internal actions and works with current selections/visual mode. // internal actions and works with current selections/visual mode.
try { try {
Vim.defineAction('strudelToggleComment', (cm) => { Vim.defineAction('strudelToggleComment', (cm) => {
const view = cm?.view || cm; const view = cm.cm6;
try { try {
const ev = new CustomEvent('repl-toggle-comment', { detail: { source: 'vim', view }, cancelable: true }); const ev = new CustomEvent('repl-toggle-comment', { detail: { source: 'vim', view }, cancelable: true });
document.dispatchEvent(ev); document.dispatchEvent(ev);
@@ -47,74 +112,23 @@ try {
// :q to pause/stop // :q to pause/stop
Vim.defineEx('quit', 'q', (cm) => { Vim.defineEx('quit', 'q', (cm) => {
try { const view = cm.cm6;
const view = cm?.view || cm; logger('[vim] :q — stopping repl');
// First try dispatching our custom stop event, then fallback to Alt+. replStop(view);
let handled = false;
try {
const ev = new CustomEvent('repl-stop', { detail: { source: 'vim', view }, cancelable: true });
handled = document.dispatchEvent(ev) === false;
} catch (e) {
console.error('Error dispatching repl-stop event', e);
}
if (!handled) {
const altDot = new KeyboardEvent('keydown', {
key: '.',
code: 'Period',
altKey: true,
bubbles: true,
cancelable: true,
});
view?.dom?.dispatchEvent?.(altDot);
}
} catch (e) {
console.error('Error dispatching :q stop event', e);
}
}); });
// :w to evaluate // :w to evaluate
Vim.defineEx('write', 'w', (cm) => { Vim.defineEx('write', 'w', (cm) => {
const view = cm?.view || cm; // CM6 Vim passes either an object with view or the view itself const view = cm.cm6;
try { try {
view?.focus?.(); view.focus?.();
// Let the app know this came from Vim :w // Let the app know this came from Vim :w
try { try {
logger('[vim] :w — evaluating code'); logger('[vim] :w — evaluating code');
} catch (e) { } catch (e) {
console.error('Error logging Vim :w evaluation', e); console.error('Error logging Vim :w evaluation', e);
} }
// Dispatch a dedicated evaluate event first replEval(view);
let handled = false;
try {
const ev = new CustomEvent('repl-evaluate', { detail: { source: 'vim', view }, cancelable: true });
handled = document.dispatchEvent(ev) === false; // false means preventDefault was called
} catch (e) {
console.error('Error dispatching repl-evaluate event', e);
}
if (handled) {
return;
}
// Try Ctrl+Enter first if not handled by custom event
const ctrlEnter = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
ctrlKey: true,
bubbles: true,
cancelable: true,
});
view?.dom?.dispatchEvent?.(ctrlEnter);
// If not handled (no handler called preventDefault), try Alt+Enter as
// fallback
if (!ctrlEnter.defaultPrevented) {
const altEnter = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
altKey: true,
bubbles: true,
cancelable: true,
});
view?.dom?.dispatchEvent?.(altEnter);
}
} catch (e) { } catch (e) {
console.error('Error dispatching :w evaluation event', e); console.error('Error dispatching :w evaluation event', e);
} }
@@ -124,11 +138,49 @@ try {
console.error('Vim ex command setup failed (defineEx missing or Vim unavailable)', e); console.error('Vim ex command setup failed (defineEx missing or Vim unavailable)', e);
} }
// Map Helix :w to trigger the same action as evaluation. We dispatch a custom
// event 'repl-evaluate' that the editor listens for, and also simulate
// Ctrl+Enter/Alt+Enter as a fallback. We log to the Strudel logger so it
// appears in the Console panel.
const helixCommands = commands.of([
{
// :w to evaluate
name: 'write',
aliases: ['w'],
help: 'Repl-eval',
handler(view, args) {
try {
view?.focus?.(); // Let the app know this came from Helix :w
logger('[helix] :w — evaluating code');
replEval(view);
} catch (e) {
console.error('Error dispatching helix :w evaluation event', e);
}
},
},
{
// :q to pause/stop
name: 'quit',
aliases: ['q'],
help: 'Repl-stop',
handler(view, args) {
try {
view?.focus?.(); // Let the app know this came from Helix :q
logger('[helix] :q — stopping repl');
replStop(view);
} catch (e) {
console.error('Error dispatching helix :q stop event', e);
}
},
},
]);
const keymaps = { const keymaps = {
vim, vim,
emacs, emacs,
codemirror: () => keymap.of(defaultKeymap), codemirror: () => keymap.of(defaultKeymap),
vscode: vscodeExtension, vscode: vscodeExtension,
helix: () => [helix(), helixCommands],
}; };
export { Vim } from '@replit/codemirror-vim'; export { Vim } from '@replit/codemirror-vim';
+8 -7
View File
@@ -32,18 +32,19 @@
}, },
"homepage": "https://codeberg.org/uzu/strudel#readme", "homepage": "https://codeberg.org/uzu/strudel#readme",
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.18.4", "@codemirror/autocomplete": "catalog:",
"@codemirror/commands": "^6.8.0", "@codemirror/commands": "catalog:",
"@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-javascript": "catalog:",
"@codemirror/language": "^6.10.8", "@codemirror/language": "catalog:",
"@codemirror/search": "^6.5.8", "@codemirror/search": "catalog:",
"@codemirror/state": "^6.5.1", "@codemirror/state": "catalog:",
"@codemirror/view": "^6.36.2", "@codemirror/view": "catalog:",
"@lezer/highlight": "^1.2.1", "@lezer/highlight": "^1.2.1",
"@nanostores/persistent": "^0.10.2", "@nanostores/persistent": "^0.10.2",
"@replit/codemirror-emacs": "^6.1.0", "@replit/codemirror-emacs": "^6.1.0",
"@replit/codemirror-vim": "^6.3.0", "@replit/codemirror-vim": "^6.3.0",
"@replit/codemirror-vscode-keymap": "^6.0.2", "@replit/codemirror-vscode-keymap": "^6.0.2",
"codemirror-helix": "^0.5.0",
"@strudel/core": "workspace:*", "@strudel/core": "workspace:*",
"@strudel/draw": "workspace:*", "@strudel/draw": "workspace:*",
"@strudel/tonal": "workspace:*", "@strudel/tonal": "workspace:*",
+106 -17
View File
@@ -2,11 +2,11 @@ import { ref, pure } from '@strudel/core';
import { WidgetType, ViewPlugin, Decoration } from '@codemirror/view'; import { WidgetType, ViewPlugin, Decoration } from '@codemirror/view';
import { StateEffect } from '@codemirror/state'; import { StateEffect } from '@codemirror/state';
// Global state storage for all widget types
export let sliderValues = {}; export let sliderValues = {};
const getSliderID = (from) => `slider_${from}`;
export class SliderWidget extends WidgetType { export class SliderWidget extends WidgetType {
constructor(value, min, max, from, to, step, view) { constructor(value, min, max, from, to, step, view, id) {
super(); super();
this.value = value; this.value = value;
this.min = min; this.min = min;
@@ -16,10 +16,21 @@ export class SliderWidget extends WidgetType {
this.to = to; this.to = to;
this.step = step; this.step = step;
this.view = view; this.view = view;
this.id = id || `${from}:${to}`; // Range-based ID for stability
} }
eq() { eq(other) {
return false; if (!(other instanceof SliderWidget)) {
return false;
}
return (
this.id === other.id &&
this.from === other.from &&
this.to === other.to &&
this.value === other.value &&
this.min === other.min &&
this.max === other.max
);
} }
toDOM() { toDOM() {
@@ -38,6 +49,7 @@ export class SliderWidget extends WidgetType {
slider.from = this.from; slider.from = this.from;
slider.originalFrom = this.originalFrom; slider.originalFrom = this.originalFrom;
slider.to = this.to; slider.to = this.to;
slider.id = this.id; // Store range-based ID in DOM element
slider.style = 'width:64px;margin-right:4px;transform:translateY(4px)'; slider.style = 'width:64px;margin-right:4px;transform:translateY(4px)';
this.slider = slider; this.slider = slider;
slider.addEventListener('input', (e) => { slider.addEventListener('input', (e) => {
@@ -49,7 +61,7 @@ export class SliderWidget extends WidgetType {
slider.originalValue = insert; slider.originalValue = insert;
slider.value = insert; slider.value = insert;
this.view.dispatch({ changes: change }); this.view.dispatch({ changes: change });
const id = getSliderID(slider.originalFrom); // matches id generated in transpiler const id = slider.id; // Use range-based ID
window.postMessage({ type: 'cm-slider', value: Number(next), id }); window.postMessage({ type: 'cm-slider', value: Number(next), id });
}); });
return wrap; return wrap;
@@ -62,19 +74,60 @@ export class SliderWidget extends WidgetType {
export const setSliderWidgets = StateEffect.define(); export const setSliderWidgets = StateEffect.define();
export const updateSliderWidgets = (view, widgets) => { export const setSliderWidgetsInRange = StateEffect.define();
view.dispatch({ effects: setSliderWidgets.of(widgets) });
export const updateSliderWidgets = (view, widgets, range = null) => {
if (range) {
// range argument passed for block-based evaluation
view.dispatch({ effects: setSliderWidgetsInRange.of({ widgets, range }) });
} else {
view.dispatch({ effects: setSliderWidgets.of(widgets) });
}
}; };
function getSliders(widgetConfigs, view) { function getSliders(widgetConfigs, view) {
return widgetConfigs return (
.filter((w) => w.type === 'slider') widgetConfigs
.map(({ from, to, value, min, max, step }) => { .filter((w) => w.type === 'slider')
return Decoration.widget({ // Deduplicate sliders that might appear multiple times (e.g., during paste operations)
widget: new SliderWidget(value, min, max, from, to, step, view), .filter((slider, index, self) => index === self.findIndex((s) => s.from === slider.from && s.to === slider.to))
side: 0, .sort((a, b) => a.from - b.from)
}).range(from /* , to */); .map(({ from, to, value, min, max, step, id }) => {
}); return Decoration.widget({
widget: new SliderWidget(value, min, max, from, to, step, view, id),
side: 0,
}).range(from /* , to */);
})
);
}
export function getSliderWidgets(view) {
if (!view || !view.state) {
return [];
}
const sliderPluginInstance = view.plugin(sliderPlugin);
if (!sliderPluginInstance || !sliderPluginInstance.decorations) {
return [];
}
const sliderWidgets = [];
sliderPluginInstance.decorations.between(0, view.state.doc.length, (from, to, decoration) => {
if (decoration.widget instanceof SliderWidget) {
sliderWidgets.push({
type: 'slider',
from: decoration.widget.from,
to: decoration.widget.to,
value: decoration.widget.value,
min: decoration.widget.min,
max: decoration.widget.max,
step: decoration.widget.step,
});
}
});
return sliderWidgets;
} }
export const sliderPlugin = ViewPlugin.fromClass( export const sliderPlugin = ViewPlugin.fromClass(
@@ -101,7 +154,42 @@ export const sliderPlugin = ViewPlugin.fromClass(
} }
} }
for (let e of tr.effects) { for (let e of tr.effects) {
if (e.is(setSliderWidgets)) { if (e.is(setSliderWidgetsInRange)) {
// Block-aware slider update logic
const { widgets, range } = e.value;
const [rangeStart, rangeEnd] = range;
// Get existing slider widgets that should be preserved
const existingSliders = [];
this.decorations.between(0, update.view.state.doc.length, (from, to, decoration) => {
if (decoration.widget instanceof SliderWidget) {
// Preserve sliders outside the evaluation range
// Use strict > for rangeEnd because when code is deleted, slider positions
// map to the deletion boundary (rangeEnd), and those should be removed, not preserved
if (from < rangeStart || from > rangeEnd) {
existingSliders.push({
from,
to,
value: decoration.widget.value,
min: decoration.widget.min,
max: decoration.widget.max,
step: decoration.widget.step,
id: decoration.widget.id || `${from}:${to}`,
type: 'slider',
});
}
}
});
// Merge preserved sliders with new widgets
const mergedWidgets = [...existingSliders, ...widgets]
.filter(
(slider, index, self) => index === self.findIndex((s) => s.type === 'slider' && s.id === slider.id),
)
.sort((a, b) => a.from - b.from);
this.decorations = Decoration.set(getSliders(mergedWidgets, update.view));
} else if (e.is(setSliderWidgets)) {
this.decorations = Decoration.set(getSliders(e.value, update.view)); this.decorations = Decoration.set(getSliders(e.value, update.view));
} }
} }
@@ -132,6 +220,7 @@ export let sliderWithID = (id, value, min, max) => {
sliderValues[id] = value; // sync state at eval time (code -> state) sliderValues[id] = value; // sync state at eval time (code -> state)
return ref(() => sliderValues[id]); // use state at query time return ref(() => sliderValues[id]); // use state at query time
}; };
// update state when sliders are moved // update state when sliders are moved
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.addEventListener('message', (e) => { window.addEventListener('message', (e) => {
@@ -140,7 +229,7 @@ if (typeof window !== 'undefined') {
// update state when slider is moved // update state when slider is moved
sliderValues[e.data.id] = e.data.value; sliderValues[e.data.id] = e.data.value;
} else { } else {
console.warn(`slider with id "${e.data.id}" is not registered. Only ${Object.keys(sliderValues)}`); console.error(`slider with id "${e.data.id}" is not registered. Only ${Object.keys(sliderValues)}`);
} }
} }
}); });
+194 -46
View File
@@ -1,55 +1,111 @@
import { StateEffect, StateField } from '@codemirror/state'; import { StateEffect, StateField } from '@codemirror/state';
import { Decoration, EditorView, WidgetType } from '@codemirror/view'; import { Decoration, EditorView, WidgetType, ViewPlugin } from '@codemirror/view';
import { getWidgetID, registerWidgetType } from '@strudel/transpiler'; import { getWidgetID, registerWidgetType } from '@strudel/transpiler';
import { Pattern } from '@strudel/core'; import { Pattern } from '@strudel/core';
export const addWidget = StateEffect.define({ export const setWidgets = StateEffect.define();
map: ({ from, to }, change) => {
return { from: change.mapPos(from), to: change.mapPos(to) };
},
});
export const updateWidgets = (view, widgets) => { export const setWidgetsInRange = StateEffect.define();
view.dispatch({ effects: addWidget.of(widgets) });
export const updateWidgets = (view, widgets, range = null) => {
if (range) {
// range argument passed for block-based evaluation
view.dispatch({ effects: setWidgetsInRange.of({ widgets, range }) });
} else {
view.dispatch({ effects: setWidgets.of(widgets) });
}
}; };
function getWidgets(widgetConfigs) { function getWidgets(widgetConfigs, view) {
return ( const filtered = widgetConfigs
widgetConfigs // Filter to widget configs only (exclude sliders)
// codemirror throws an error if we don't sort .filter((w) => w && w.type && w.type !== 'slider')
.sort((a, b) => a.to - b.to) // Deduplicate widgets by ID, matching slider behavior for stable widget identity
.map((widgetConfig) => { .filter((widget, index, self) => index === self.findIndex((w) => w.type === widget.type && w.id === widget.id));
// Filter out widgets whose range is encompassed by another widget
// const nonEncompassed = filterEncompassedWidgets(filtered);
return filtered
.sort((a, b) => (a.to || 0) - (b.to || 0))
.map((widgetConfig) => {
try {
return Decoration.widget({ return Decoration.widget({
widget: new BlockWidget(widgetConfig), widget: new BlockWidget(widgetConfig, view),
side: 0, side: 0,
block: true, }).range(widgetConfig.to || widgetConfig.from || 0);
}).range(widgetConfig.to); } catch (error) {
}) console.error('error creating widget', error);
); return null;
}
})
.filter(Boolean); // Remove any null results from failed creations
} }
const widgetField = StateField.define( export const widgetPlugin = ViewPlugin.fromClass(
/* <DecorationSet> */ { class {
create() { decorations; //: DecorationSet
return Decoration.none;
}, constructor(view /* : EditorView */) {
update(widgets, tr) { this.decorations = Decoration.set([]);
widgets = widgets.map(tr.changes); }
for (let e of tr.effects) {
if (e.is(addWidget)) { update(update /* : ViewUpdate */) {
try { update.transactions.forEach((tr) => {
widgets = widgets.update({ if (tr.docChanged) {
filter: () => false, this.decorations = this.decorations.map(tr.changes);
add: getWidgets(e.value), const iterator = this.decorations.iter();
}); // Apply changes to iterator.from and iterator.to if docChanged
} catch (error) { while (iterator.value) {
console.log('err', error); // when the widgets are moved, we need to tell the dom node the current position
// this is important because the widget functions have to work with the dom node
if (iterator.value?.widget instanceof BlockWidget) {
iterator.value.widget.from = iterator.from;
iterator.value.widget.to = iterator.to;
}
iterator.next();
} }
} }
} for (let e of tr.effects) {
return widgets; if (e.is(setWidgetsInRange)) {
}, // Block-aware widget update logic
provide: (f) => EditorView.decorations.from(f), const { widgets, range } = e.value;
const [rangeStart, rangeEnd] = range;
// Get existing widget widgets that should be preserved
const existingWidgets = [];
this.decorations.between(0, update.view.state.doc.length, (from, to, decoration) => {
if (decoration.widget instanceof BlockWidget) {
// Preserve widgets outside the evaluation range
// Use strict > for rangeEnd because when code is deleted, widget positions
// map to the deletion boundary (rangeEnd), and those should be removed, not preserved
if (from < rangeStart || from > rangeEnd) {
existingWidgets.push({
from: decoration.widget.from,
to: decoration.widget.to,
type: decoration.widget.type,
index: decoration.widget.index,
id: decoration.widget.id,
});
}
}
});
// Merge preserved widgets with new widgets, deduplicating by ID
const mergedWidgets = [...existingWidgets, ...widgets].filter(
(widget, index, self) => index === self.findIndex((w) => w.type === widget.type && w.id === widget.id),
);
this.decorations = Decoration.set(getWidgets(mergedWidgets, update.view));
} else if (e.is(setWidgets)) {
this.decorations = Decoration.set(getWidgets(e.value, update.view));
}
}
});
}
},
{
decorations: (v) => v.decorations,
}, },
); );
@@ -60,24 +116,116 @@ export function setWidget(id, el) {
} }
export class BlockWidget extends WidgetType { export class BlockWidget extends WidgetType {
constructor(widgetConfig) { constructor(widgetConfig, view) {
super(); super();
// Graceful handling of invalid configs like sliders
if (!widgetConfig || typeof widgetConfig !== 'object') {
widgetConfig = { type: 'unknown', from: 0, to: 0 };
}
this.from = widgetConfig.from || 0;
this.originalFrom = widgetConfig.from || 0;
this.to = widgetConfig.to || this.from;
this.originalTo = widgetConfig.to || this.from;
this.type = widgetConfig.type || 'unknown';
this.index = widgetConfig.index || 0;
this.view = view;
// Use range-based ID for stability, similar to sliders
this.id = widgetConfig.id || getWidgetID?.(widgetConfig);
this.widgetConfig = widgetConfig; this.widgetConfig = widgetConfig;
} }
eq() {
return true; eq(other) {
if (!(other instanceof BlockWidget)) {
return false;
}
return (
this.id === other.id &&
this.from === other.from &&
this.to === other.to &&
this.type === other.type &&
this.index === other.index
);
} }
toDOM() { toDOM() {
const id = getWidgetID(this.widgetConfig); let wrap = document.createElement('span');
const el = widgetElements[id]; wrap.setAttribute('aria-hidden', 'true');
return el; wrap.className = 'cm-widget-container';
let el = widgetElements[this.id];
if (el) {
// Ensure the element has the correct ID
el.id = this.id;
wrap.appendChild(el);
} else {
// Create a placeholder element if the widget element doesn't exist
// This prevents CodeMirror errors when widget is missing
const placeholder = document.createElement('span');
placeholder.setAttribute('aria-hidden', 'true');
placeholder.className = 'cm-widget-placeholder';
placeholder.style.cssText = 'display: none;'; // Hide placeholder
placeholder.id = this.id;
wrap.appendChild(placeholder);
}
return wrap;
} }
ignoreEvent(e) { ignoreEvent(e) {
return true; return true;
} }
} }
export const widgetPlugin = [widgetField]; export function getActiveWidgets(view) {
if (!view || !view.state) {
return [];
}
const widgetPluginInstance = view.plugin(widgetPlugin);
if (!widgetPluginInstance || !widgetPluginInstance.decorations) {
return [];
}
const widgets = [];
widgetPluginInstance.decorations.between(0, view.state.doc.length, (from, to, decoration) => {
if (decoration.widget instanceof BlockWidget) {
widgets.push({
type: decoration.widget.type,
from: decoration.widget.from,
to: decoration.widget.to,
index: decoration.widget.index,
id: decoration.widget.id,
});
}
});
return widgets;
}
export function getAllWidgetIds(view) {
if (!view || !view.state) {
return [];
}
const widgetPluginInstance = view.plugin(widgetPlugin);
if (!widgetPluginInstance || !widgetPluginInstance.decorations) {
return [];
}
const widgetIds = [];
widgetPluginInstance.decorations.between(0, view.state.doc.length, (from, to, decoration) => {
if (decoration.widget instanceof BlockWidget) {
widgetIds.push(decoration.widget.id);
}
});
return widgetIds;
}
// widget implementer API to create a new widget type // widget implementer API to create a new widget type
export function registerWidget(type, fn) { export function registerWidget(type, fn) {
+265 -239
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -5,6 +5,34 @@ This program is free software: you can redistribute it and/or modify it under th
*/ */
export const strudelScope = {}; export const strudelScope = {};
// Make strudelScope available globally so transpiled code can access it
globalThis.strudelScope = strudelScope;
// Track user-defined keys (from block-based eval) so we can clear them without removing strudel functions
export const userDefinedKeys = new Set();
globalThis.userDefinedKeys = userDefinedKeys;
/**
* Clears all user-defined variables and functions from the scope.
* This removes variables created during block-based evaluation.
* @name clearScope
* @example
* // After defining variables in blocks:
* // let myVar = 5
* // function myFunc() { return 10; }
* clearScope() // removes myVar and myFunc from scope
*/
export const clearScope = () => {
for (const key of userDefinedKeys) {
delete strudelScope[key];
delete globalThis[key];
}
userDefinedKeys.clear();
// Return silence if available (for use in pattern expressions), otherwise undefined
return globalThis.silence;
};
// Make clearScope available globally
globalThis.clearScope = clearScope;
export const evalScope = async (...args) => { export const evalScope = async (...args) => {
const results = await Promise.allSettled(args); const results = await Promise.allSettled(args);
+221 -148
View File
@@ -818,7 +818,6 @@ export class Pattern {
* @name layer * @name layer
* @tags combiners * @tags combiners
* @memberof Pattern * @memberof Pattern
* @synonyms apply
* @returns Pattern * @returns Pattern
* @example * @example
* "<0 2 4 6 ~ 4 ~ 2 0!3 ~!5>*8" * "<0 2 4 6 ~ 4 ~ 2 0!3 ~!5>*8"
@@ -1055,91 +1054,135 @@ function _composeOp(a, b, func) {
return func(a, b); return func(a, b);
} }
// Make composers // pattern composers
(function () { const COMPOSERS = {
// pattern composers /**
const composers = { * When called on a pattern `a`, with a input pattern `b` (`a.set(b)`),
set: [(a, b) => b], * combines `a` and `b` such that anything defined in `b`
keep: [(a) => a], * and anything defined in `a` that is *not* defined in `b`
keepif: [(a, b) => (b ? a : undefined)], * will be in the resulting pattern.
*
* The structure is maintained from `a`,
* because the default pattern alignment is `in`,
* see the section on `Pattern Alignment`
* in the technical manual in the docs
*
* This is the inverse of `keep`
*
* See examples below
* @name set
* @param {Pattern} pat
* @returns {Pattern}
* @memberof Pattern
* @tags internal, combiners
* @example
* // because input pattern has `s` set,
* // it overrides the "sine" declared earlier
* note("c a f e").s("sine").set(s("triangle"))
*/
set: [(a, b) => b],
/**
* When called on a pattern `a`, with a input pattern `b` (`a.keep(b)`),
* combines `a` and `b` such that anything defined in `a`,
* and anything defined in `b` that is *not* defined in `a`
* will be in the resulting pattern
*
* The structure is maintained from `a`,
* because the default pattern alignment is `in`,
* see the section on `Pattern Alignment`
* in the technical manual in the docs
*
* This is the inverse of `set`
*
* See examples below
* @name keep
* @param {Pattern} pat
* @memberof Pattern
* @returns {Pattern}
* @tags internal, combiners
* @example
* // notes, already defined, will stay "c a f e",
* // while "s", not defined, will be set to "piano"
* note("c a f e").keep(note("e f a c").s("piano"))
*/
keep: [(a) => a],
keepif: [(a, b) => (b ? a : undefined)],
// numerical functions // numerical functions
/** /**
* *
* Assumes a pattern of numbers. Adds the given number to each item in the pattern. * Assumes a pattern of numbers. Adds the given number to each item in the pattern.
* @name add * @name add
* @memberof Pattern * @memberof Pattern
* @tags math * @tags math
* @example * @example
* // Here, the triad 0, 2, 4 is shifted by different amounts * // Here, the triad 0, 2, 4 is shifted by different amounts
* n("0 2 4".add("<0 3 4 0>")).scale("C:major") * n("0 2 4".add("<0 3 4 0>")).scale("C:major")
* // Without add, the equivalent would be: * // Without add, the equivalent would be:
* // n("<[0 2 4] [3 5 7] [4 6 8] [0 2 4]>").scale("C:major") * // n("<[0 2 4] [3 5 7] [4 6 8] [0 2 4]>").scale("C:major")
* @example * @example
* // You can also use add with notes: * // You can also use add with notes:
* note("c3 e3 g3".add("<0 5 7 0>")) * note("c3 e3 g3".add("<0 5 7 0>"))
* // Behind the scenes, the notes are converted to midi numbers: * // Behind the scenes, the notes are converted to midi numbers:
* // note("48 52 55".add("<0 5 7 0>")) * // note("48 52 55".add("<0 5 7 0>"))
*/ */
add: [numeralArgs((a, b) => a + b)], // support string concatenation add: [numeralArgs((a, b) => a + b)], // support string concatenation
/** /**
* *
* Like add, but the given numbers are subtracted. * Like add, but the given numbers are subtracted.
* @name sub * @name sub
* @memberof Pattern * @memberof Pattern
* @tags math * @tags math
* @example * @example
* n("0 2 4".sub("<0 1 2 3>")).scale("C4:minor") * n("0 2 4".sub("<0 1 2 3>")).scale("C4:minor")
* // See add for more information. * // See add for more information.
*/ */
sub: [numeralArgs((a, b) => a - b)], sub: [numeralArgs((a, b) => a - b)],
/** /**
* *
* Multiplies each number by the given factor. * Multiplies each number by the given factor.
* @name mul * @name mul
* @memberof Pattern * @memberof Pattern
* @tags math * @tags math
* @example * @example
* "<1 1.5 [1.66, <2 2.33>]>*4".mul(150).freq() * "<1 1.5 [1.66, <2 2.33>]>*4".mul(150).freq()
*/ */
mul: [numeralArgs((a, b) => a * b)], mul: [numeralArgs((a, b) => a * b)],
/** /**
* *
* Divides each number by the given factor. * Divides each number by the given factor.
* @name div * @name div
* @memberof Pattern * @memberof Pattern
* @tags math * @tags math
*/ */
div: [numeralArgs((a, b) => a / b)], div: [numeralArgs((a, b) => a / b)],
mod: [numeralArgs(_mod)], mod: [numeralArgs(_mod)],
pow: [numeralArgs(Math.pow)], pow: [numeralArgs(Math.pow)],
log2: [numeralArgs(Math.log2)], band: [numeralArgs((a, b) => a & b)],
band: [numeralArgs((a, b) => a & b)], bor: [numeralArgs((a, b) => a | b)],
bor: [numeralArgs((a, b) => a | b)], bxor: [numeralArgs((a, b) => a ^ b)],
bxor: [numeralArgs((a, b) => a ^ b)], blshift: [numeralArgs((a, b) => a << b)],
blshift: [numeralArgs((a, b) => a << b)], brshift: [numeralArgs((a, b) => a >> b)],
brshift: [numeralArgs((a, b) => a >> b)],
// TODO - force numerical comparison if both look like numbers? // TODO - force numerical comparison if both look like numbers?
lt: [(a, b) => a < b], lt: [(a, b) => a < b],
gt: [(a, b) => a > b], gt: [(a, b) => a > b],
lte: [(a, b) => a <= b], lte: [(a, b) => a <= b],
gte: [(a, b) => a >= b], gte: [(a, b) => a >= b],
eq: [(a, b) => a == b], eq: [(a, b) => a == b],
eqt: [(a, b) => a === b], eqt: [(a, b) => a === b],
ne: [(a, b) => a != b], ne: [(a, b) => a != b],
net: [(a, b) => a !== b], net: [(a, b) => a !== b],
and: [(a, b) => a && b], and: [(a, b) => a && b],
or: [(a, b) => a || b], or: [(a, b) => a || b],
// bitwise ops // bitwise ops
func: [(a, b) => b(a)], func: [(a, b) => b(a)],
}; };
const hows = ['In', 'Out', 'Mix', 'Squeeze', 'SqueezeOut', 'Reset', 'Restart', 'Poly'];
const _setupAlignments = () => {
// generate methods to do what and how // generate methods to do what and how
for (const [what, [op, preprocess]] of Object.entries(composers)) { for (const [what, [op, preprocess]] of Object.entries(COMPOSERS)) {
// make plain version, e.g. pat._add(value) adds that plain value // make plain version, e.g. pat._add(value) adds that plain value
// to all the values in pat // to all the values in pat
Pattern.prototype['_' + what] = function (value) { Pattern.prototype['_' + what] = function (value) {
@@ -1148,16 +1191,18 @@ function _composeOp(a, b, func) {
// make patternified monster version // make patternified monster version
Object.defineProperty(Pattern.prototype, what, { Object.defineProperty(Pattern.prototype, what, {
// Set to configurable so we can update if the default alignment changes
configurable: true,
// a getter that returns a function, so 'pat' can be // a getter that returns a function, so 'pat' can be
// accessed by closures that are methods of that function.. // accessed by closures that are methods of that function..
get: function () { get: function () {
const pat = this; const pat = this;
// wrap the 'in' function as default behaviour // wrap the 'in' function as default behaviour
const wrapper = (...other) => pat[what]['in'](...other); const wrapper = (...other) => pat[what][DEFAULT_ALIGNMENT](...other);
// add methods to that function for each behaviour // add methods to that function for each behaviour
for (const how of hows) { for (const how of ALIGNMENTS) {
wrapper[how.toLowerCase()] = function (...other) { wrapper[how.toLowerCase()] = function (...other) {
var howpat = pat; var howpat = pat;
other = sequence(other); other = sequence(other);
@@ -1182,15 +1227,23 @@ function _composeOp(a, b, func) {
return wrapper; return wrapper;
}, },
}); });
// Default op to 'set', e.g. pat.squeeze(pat2) = pat.set.squeeze(pat2)
for (const how of hows) {
Pattern.prototype[how.toLowerCase()] = function (...args) {
return this.set[how.toLowerCase()](args);
};
}
} }
};
let DEFAULT_ALIGNMENT = 'in';
const ALIGNMENTS = ['In', 'Out', 'Mix', 'Squeeze', 'SqueezeOut', 'Reset', 'Restart', 'Poly'];
const ALIGNMENT_KEYS = ALIGNMENTS.map((how) => how.toLowerCase());
// Make composers
(function () {
_setupAlignments();
// Default op to 'set', e.g. pat.squeeze(pat2) = pat.set.squeeze(pat2)
for (const how of ALIGNMENTS) {
Pattern.prototype[how.toLowerCase()] = function (...args) {
return this.set[how.toLowerCase()](args);
};
}
// binary composers // binary composers
/** /**
* Applies the given structure to the pattern: * Applies the given structure to the pattern:
@@ -1249,6 +1302,28 @@ function _composeOp(a, b, func) {
}; };
})(); })();
/**
* Sets the default method of combining events from two patterns (aka [alignment](https://strudel.cc/technical-manual/alignment/)) in Strudel.
* The default method is 'in', meaning that patterns to the left will (typically) dictate the event timings when combined with patterns to the right.
* By changing alignment to 'out', the opposite will happen. With 'mix', they will combine their event timings.
*
* Note that we say the _default_ method, because alignments can also be set explicitly with calls like
* 'add.mix', 'set.squeeze', etc.
*
* @param {string} method Default join method to use. Options: 'in', 'out', 'mix', 'squeeze', 'squeezeout', 'reset', 'restart', 'poly'
* @tags combiners
* @example
* setDefaultJoin('mix') // also try 'in', 'out', 'squeeze', etc.
* s("saw").vel("1 0.5").note("F A C E").delay("0 0.2 0.3")
*/
export const setDefaultJoin = (alignment) => {
alignment = alignment?.toLowerCase();
if (DEFAULT_ALIGNMENT !== alignment && ALIGNMENT_KEYS.includes(alignment)) {
DEFAULT_ALIGNMENT = alignment;
_setupAlignments();
}
};
// aliases // aliases
export const polyrhythm = stack; export const polyrhythm = stack;
export const pr = stack; export const pr = stack;
@@ -1536,10 +1611,10 @@ export function arrange(...sections) {
* @tags combiners * @tags combiners
* @return {Pattern} * @return {Pattern}
* @example * @example
seqPLoop([0, 2, "bd(3,8)"], seqPLoop(
[1, 3, "cp(3,8)"] [0, 2, "bd(3,8)"],
) [1, 3, "cp(3,8)"]
.sound() ).sound()
*/ */
export function seqPLoop(...parts) { export function seqPLoop(...parts) {
let total = Fraction(0); let total = Fraction(0);
@@ -1582,7 +1657,7 @@ export function sequence(...pats) {
/** Like **cat**, but the items are crammed into one cycle. /** Like **cat**, but the items are crammed into one cycle.
* @tags combiners * @tags combiners
* @synonyms seq, fastcat * @synonyms fastcat
* @example * @example
* seq("e5", "b4", ["d5", "c5"]).note() * seq("e5", "b4", ["d5", "c5"]).note()
* // "e5 b4 [d5 c5]".note() * // "e5 b4 [d5 c5]".note()
@@ -1788,6 +1863,8 @@ export const floor = register('floor', function (pat) {
return pat.asNumber().fmap((v) => Math.floor(v)); return pat.asNumber().fmap((v) => Math.floor(v));
}); });
export const log2 = register('log2', (pat) => pat.asNumber().fmap((v) => Math.log2(v)));
/** /**
* Assumes a numerical pattern. Returns a new pattern with all values set to * Assumes a numerical pattern. Returns a new pattern with all values set to
* their mathematical ceiling. E.g. `3.2` replaced with `4`, and `-4.2` * their mathematical ceiling. E.g. `3.2` replaced with `4`, and `-4.2`
@@ -2109,13 +2186,12 @@ export const { firstOf, every } = register(['firstOf', 'every'], function (n, fu
}); });
/** /**
* Like layer, but with a single function: * Applies the given function to the pattern. Like layer, but with a single function:
* @tags temporal * @tags combiners
* @name apply * @name apply
* @example * @example
* "<c3 eb3 g3>".scale('C minor').apply(scaleTranspose("0,2,4")).note() * "<c3 eb3 g3>".scale('C minor').apply(scaleTranspose("0,2,4")).note()
*/ */
// TODO: remove or dedupe with layer?
export const apply = register('apply', function (func, pat) { export const apply = register('apply', function (func, pat) {
return func(pat); return func(pat);
}); });
@@ -2472,7 +2548,7 @@ export const { juxBy, juxby } = register(['juxBy', 'juxby'], function (by, func,
/** /**
* The jux function creates strange stereo effects, by applying a function to a pattern, but only in the right-hand channel. * The jux function creates strange stereo effects, by applying a function to a pattern, but only in the right-hand channel.
* @tags temporal * @tags temporal, superdough
* @example * @example
* s("bd lt [~ ht] mt cp ~ bd hh").jux(rev) * s("bd lt [~ ht] mt cp ~ bd hh").jux(rev)
* @example * @example
@@ -2486,7 +2562,7 @@ export const jux = register('jux', function (func, pat) {
/** /**
* Superimpose and offset multiple times, applying the given function each time. * Superimpose and offset multiple times, applying the given function each time.
* @tags temporal * @tags temporal, functional
* @name echoWith * @name echoWith
* @synonyms echowith, stutWith, stutwith * @synonyms echowith, stutWith, stutwith
* @param {number} times how many times to repeat * @param {number} times how many times to repeat
@@ -2660,7 +2736,7 @@ export const { repeatCycles } = register(
/** /**
* Divides a pattern into a given number of parts, then cycles through those parts in turn, applying the given function to each part in turn (one part per cycle). * Divides a pattern into a given number of parts, then cycles through those parts in turn, applying the given function to each part in turn (one part per cycle).
* @tags temporal * @tags temporal, functional
* @name chunk * @name chunk
* @synonyms slowChunk, slowchunk * @synonyms slowChunk, slowchunk
* @memberof Pattern * @memberof Pattern
@@ -2824,7 +2900,7 @@ Pattern.prototype.tag = function (tag) {
/** /**
* Filters haps using the given function * Filters haps using the given function
* @name filter * @name filter
* @tags temporal * @tags temporal, functional
* @param {Function} test function to test Hap * @param {Function} test function to test Hap
* @example * @example
* s("hh!7 oh").filter(hap => hap.value.s === 'hh') * s("hh!7 oh").filter(hap => hap.value.s === 'hh')
@@ -2834,7 +2910,7 @@ export const filter = register('filter', (test, pat) => pat.withHaps((haps) => h
/** /**
* Filters haps by their begin time * Filters haps by their begin time
* @name filterWhen * @name filterWhen
* @tags temporal * @tags temporal, functional
* @param {Function} test function to test Hap.whole.begin * @param {Function} test function to test Hap.whole.begin
* @example * @example
* oneCycle: s("bd*4").filterWhen((t) => t < 1) * oneCycle: s("bd*4").filterWhen((t) => t < 1)
@@ -2844,7 +2920,7 @@ export const filterWhen = register('filterWhen', (test, pat) => pat.filter((h) =
/** /**
* Use within to apply a function to only a part of a pattern. * Use within to apply a function to only a part of a pattern.
* @name within * @name within
* @tags temporal * @tags temporal, functional
* @param {number} start start within cycle (0 - 1) * @param {number} start start within cycle (0 - 1)
* @param {number} end end within cycle (0 - 1). Must be > start * @param {number} end end within cycle (0 - 1). Must be > start
* @param {Function} func function to be applied to the sub-pattern * @param {Function} func function to be applied to the sub-pattern
@@ -2919,7 +2995,7 @@ export function _match(span, hap_p) {
* *Experimental* * *Experimental*
* *
* Speeds a pattern up or down, to fit to the given number of steps per cycle. * Speeds a pattern up or down, to fit to the given number of steps per cycle.
* @tags temporal * @tags stepwise
* @example * @example
* sound("bd sd cp").pace(4) * sound("bd sd cp").pace(4)
* // The same as sound("{bd sd cp}%4") or sound("<bd sd cp>*4") * // The same as sound("{bd sd cp}%4") or sound("<bd sd cp>*4")
@@ -2961,7 +3037,7 @@ export function _polymeterListSteps(steps, ...args) {
* *Experimental* * *Experimental*
* *
* Aligns the steps of the patterns, creating polymeters. The patterns are repeated until they all fit the cycle. For example, in the below the first pattern is repeated twice, and the second is repeated three times, to fit the lowest common multiple of six steps. * Aligns the steps of the patterns, creating polymeters. The patterns are repeated until they all fit the cycle. For example, in the below the first pattern is repeated twice, and the second is repeated three times, to fit the lowest common multiple of six steps.
* @tags temporal * @tags stepwise
* @synonyms pm * @synonyms pm
* @example * @example
* // The same as note("{c eb g, c2 g2}%6") * // The same as note("{c eb g, c2 g2}%6")
@@ -2994,7 +3070,7 @@ export function polymeter(...args) {
* The steps can either be inferred from the pattern, or provided as a [length, pattern] pair. * The steps can either be inferred from the pattern, or provided as a [length, pattern] pair.
* Has the alias `timecat`. * Has the alias `timecat`.
* @name stepcat * @name stepcat
* @tags combiners * @tags stepwise
* @synonyms timeCat, timecat * @synonyms timeCat, timecat
* @return {Pattern} * @return {Pattern}
* @example * @example
@@ -3052,7 +3128,7 @@ export function stepcat(...timepats) {
* Concatenates patterns stepwise, according to an inferred 'steps per cycle'. * Concatenates patterns stepwise, according to an inferred 'steps per cycle'.
* Similar to `stepcat`, but if an argument is a list, the whole pattern will alternate between the elements in the list. * Similar to `stepcat`, but if an argument is a list, the whole pattern will alternate between the elements in the list.
* *
* @tags combiners * @tags stepwise
* @return {Pattern} * @return {Pattern}
* @example * @example
* stepalt(["bd cp", "mt"], "bd").sound() * stepalt(["bd cp", "mt"], "bd").sound()
@@ -3079,7 +3155,7 @@ export function stepalt(...groups) {
* *
* Takes the given number of steps from a pattern (dropping the rest). * Takes the given number of steps from a pattern (dropping the rest).
* A positive number will take steps from the start of a pattern, and a negative number from the end. * A positive number will take steps from the start of a pattern, and a negative number from the end.
* @tags temporal * @tags stepwise
* @return {Pattern} * @return {Pattern}
* @example * @example
* "bd cp ht mt".take("2").sound() * "bd cp ht mt".take("2").sound()
@@ -3124,7 +3200,7 @@ export const take = stepRegister('take', function (i, pat) {
* *
* Drops the given number of steps from a pattern. * Drops the given number of steps from a pattern.
* A positive number will drop steps from the start of a pattern, and a negative number from the end. * A positive number will drop steps from the start of a pattern, and a negative number from the end.
* @tags temporal * @tags stepwise
* @return {Pattern} * @return {Pattern}
* @example * @example
* "tha dhi thom nam".drop("1").sound().bank("mridangam") * "tha dhi thom nam".drop("1").sound().bank("mridangam")
@@ -3153,7 +3229,7 @@ export const drop = stepRegister('drop', function (i, pat) {
* `extend` is similar to `fast` in that it increases its density, but it also increases the step count * `extend` is similar to `fast` in that it increases its density, but it also increases the step count
* accordingly. So `stepcat("a b".extend(2), "c d")` would be the same as `"a b a b c d"`, whereas * accordingly. So `stepcat("a b".extend(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"`. * `stepcat("a b".fast(2), "c d")` would be the same as `"[a b] [a b] c d"`.
* @tags temporal * @tags stepwise
* @example * @example
* stepcat( * stepcat(
* sound("bd bd - cp").extend(2), * sound("bd bd - cp").extend(2),
@@ -3172,7 +3248,7 @@ export const extend = stepRegister('extend', function (factor, pat) {
* `stepcat("a b".fast(2), "c d")` would be the same as `"[a b] [a b] c d"`. * `stepcat("a b".fast(2), "c d")` would be the same as `"[a b] [a b] c d"`.
* *
* TODO: find out how this function differs from extend * TODO: find out how this function differs from extend
* @tags temporal * @tags stepwise
* @example * @example
* stepcat( * stepcat(
* sound("bd bd - cp").replicate(2), * sound("bd bd - cp").replicate(2),
@@ -3187,7 +3263,7 @@ export const replicate = stepRegister('replicate', function (factor, pat) {
* *Experimental* * *Experimental*
* *
* Expands the step size of the pattern by the given factor. * Expands the step size of the pattern by the given factor.
* @tags temporal * @tags stepwise
* @example * @example
* sound("tha dhi thom nam").bank("mridangam").expand("3 2 1 1 2 3").pace(8) * sound("tha dhi thom nam").bank("mridangam").expand("3 2 1 1 2 3").pace(8)
*/ */
@@ -3199,7 +3275,7 @@ export const expand = stepRegister('expand', function (factor, pat) {
* *Experimental* * *Experimental*
* *
* Contracts the step size of the pattern by the given factor. See also `expand`. * Contracts the step size of the pattern by the given factor. See also `expand`.
* @tags temporal * @tags stepwise
* @example * @example
* sound("tha dhi thom nam").bank("mridangam").contract("3 2 1 1 2 3").pace(8) * sound("tha dhi thom nam").bank("mridangam").contract("3 2 1 1 2 3").pace(8)
*/ */
@@ -3254,7 +3330,7 @@ export const shrinklist = (amount, pat) => pat.shrinklist(amount);
* Progressively shrinks the pattern by 'n' steps until there's nothing left, or if a second value is given (using mininotation list syntax with `:`), * Progressively shrinks the pattern by 'n' steps until there's nothing left, or if a second value is given (using mininotation list syntax with `:`),
* that number of times. * that number of times.
* A positive number will progressively drop steps from the start of a pattern, and a negative number from the end. * A positive number will progressively drop steps from the start of a pattern, and a negative number from the end.
* @tags temporal * @tags stepwise
* @return {Pattern} * @return {Pattern}
* @example * @example
* "tha dhi thom nam".shrink("1").sound() * "tha dhi thom nam".shrink("1").sound()
@@ -3294,7 +3370,7 @@ export const shrink = register(
* Progressively grows the pattern by 'n' steps until the full pattern is played, or if a second value is given (using mininotation list syntax with `:`), * Progressively grows the pattern by 'n' steps until the full pattern is played, or if a second value is given (using mininotation list syntax with `:`),
* that number of times. * that number of times.
* A positive number will progressively grow steps from the start of a pattern, and a negative number from the end. * A positive number will progressively grow steps from the start of a pattern, and a negative number from the end.
* @tags temporal * @tags stepwise
* @return {Pattern} * @return {Pattern}
* @example * @example
* "tha dhi thom nam".grow("1").sound() * "tha dhi thom nam".grow("1").sound()
@@ -3335,7 +3411,7 @@ export const grow = register(
* on successive repetitions. The patterns are added together stepwise, with all repetitions taking place over a single cycle. Using `pace` to set the * 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. * number of steps per cycle is therefore usually recommended.
* *
* @tags combiners * @tags stepwise
* @return {Pattern} * @return {Pattern}
* @example * @example
* "[c g]".tour("e f", "e f g", "g f e c").note() * "[c g]".tour("e f", "e f g", "g f e c").note()
@@ -3362,7 +3438,7 @@ Pattern.prototype.tour = function (...many) {
* '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. * Using `pace` to set the number of steps per cycle is therefore usually recommended.
* *
* @tags combiners * @tags stepwise
* @returns {Pattern} * @returns {Pattern}
* @example * @example
* zip("e f", "e f g", "g [f e] a f4 c").note() * zip("e f", "e f g", "g [f e] a f4 c").note()
@@ -3414,7 +3490,7 @@ Pattern.prototype.steps = Pattern.prototype.pace;
* Cuts each sample into the given number of parts, allowing you to explore a technique known as 'granular synthesis'. * Cuts each sample into the given number of parts, allowing you to explore a technique known as 'granular synthesis'.
* It turns a pattern of samples into a pattern of parts of samples. * It turns a pattern of samples into a pattern of parts of samples.
* @name chop * @name chop
* @tags temporal * @tags samples
* @memberof Pattern * @memberof Pattern
* @returns Pattern * @returns Pattern
* @example * @example
@@ -3445,7 +3521,7 @@ export const chop = register('chop', function (n, pat) {
/** /**
* Cuts each sample into the given number of parts, triggering progressive portions of each sample at each loop. * Cuts each sample into the given number of parts, triggering progressive portions of each sample at each loop.
* @name striate * @name striate
* @tags temporal * @tags samples
* @memberof Pattern * @memberof Pattern
* @returns Pattern * @returns Pattern
* @example * @example
@@ -3464,14 +3540,13 @@ export const striate = register('striate', function (n, pat) {
/** /**
* Makes the sample fit the given number of cycles by changing the speed. * Makes the sample fit the given number of cycles by changing the speed.
* @name loopAt * @name loopAt
* @tags temporal * @tags samples, pitch
* @memberof Pattern * @memberof Pattern
* @returns Pattern * @returns Pattern
* @example * @example
* samples({ rhodes: 'https://cdn.freesound.org/previews/132/132051_316502-lq.mp3' }) * samples({ rhodes: 'https://cdn.freesound.org/previews/132/132051_316502-lq.mp3' })
* s("rhodes").loopAt(2) * s("rhodes").loopAt(2)
*/ */
// TODO - global cps clock
const _loopAt = function (factor, pat, cps = 0.5) { const _loopAt = function (factor, pat, cps = 0.5) {
return pat return pat
.speed((1 / factor) * cps) .speed((1 / factor) * cps)
@@ -3479,11 +3554,16 @@ const _loopAt = function (factor, pat, cps = 0.5) {
.slow(factor); .slow(factor);
}; };
export const { loopAt, loopat } = register(['loopAt', 'loopat'], function (factor, pat) {
const steps = pat._steps ? pat._steps.div(factor) : undefined;
return new Pattern((state) => _loopAt(factor, pat, state.controls._cps).query(state), steps);
});
/** /**
* Chops samples into the given number of slices, triggering those slices with a given pattern of slice numbers. * Chops samples into the given number of slices, triggering those slices with a given pattern of slice numbers.
* Instead of a number, it also accepts a list of numbers from 0 to 1 to slice at specific points. * Instead of a number, it also accepts a list of numbers from 0 to 1 to slice at specific points.
* @name slice * @name slice
* @tags temporal * @tags samples
* @memberof Pattern * @memberof Pattern
* @returns Pattern * @returns Pattern
* @example * @example
@@ -3537,7 +3617,7 @@ Pattern.prototype.onTriggerTime = function (func) {
/** /**
* Works the same as slice, but changes the playback speed of each slice to match the duration of its step. * Works the same as slice, but changes the playback speed of each slice to match the duration of its step.
* @name splice * @name splice
* @tags temporal * @tags samples, pitch
* @example * @example
* samples('github:tidalcycles/dirt-samples') * samples('github:tidalcycles/dirt-samples')
* s("breaks165") * s("breaks165")
@@ -3566,16 +3646,11 @@ export const splice = register(
false, // turns off auto-patternification false, // turns off auto-patternification
); );
export const { loopAt, loopat } = register(['loopAt', 'loopat'], function (factor, pat) {
const steps = pat._steps ? pat._steps.div(factor) : undefined;
return new Pattern((state) => _loopAt(factor, pat, state.controls._cps).query(state), steps);
});
/** /**
* Makes the sample fit its event duration. Good for rhythmical loops like drum breaks. * Makes the sample fit its event duration. Good for rhythmical loops like drum breaks.
* Similar to `loopAt`. * Similar to `loopAt`.
* @name fit * @name fit
* @tags temporal * @tags samples, pitch
* @example * @example
* samples({ rhodes: 'https://cdn.freesound.org/previews/132/132051_316502-lq.mp3' }) * samples({ rhodes: 'https://cdn.freesound.org/previews/132/132051_316502-lq.mp3' })
* s("rhodes/2").fit() * s("rhodes/2").fit()
@@ -3597,18 +3672,16 @@ export const fit = register('fit', (pat) =>
/** /**
* Makes the sample fit the given number of cycles and cps value, by * Makes the sample fit the given number of cycles and cps value, by
* changing the speed. Please note that at some point cps will be * changing the speed. deprecated: use loopAt or fit instead, together with setCps / setCpm.
* given by a global clock and this function will be
* deprecated/removed.
* @name loopAtCps * @name loopAtCps
* @tags temporal * @tags samples, pitch
* @memberof Pattern * @memberof Pattern
* @deprecated
* @returns Pattern * @returns Pattern
* @example * @example
* samples({ rhodes: 'https://cdn.freesound.org/previews/132/132051_316502-lq.mp3' }) * samples({ rhodes: 'https://cdn.freesound.org/previews/132/132051_316502-lq.mp3' })
* s("rhodes").loopAtCps(4,1.5).cps(1.5) * s("rhodes").loopAtCps(4,1.5).cps(1.5)
*/ */
// TODO - global cps clock
export const { loopAtCps, loopatcps } = register(['loopAtCps', 'loopatcps'], function (factor, cps, pat) { export const { loopAtCps, loopatcps } = register(['loopAtCps', 'loopatcps'], function (factor, cps, pat) {
return _loopAt(factor, pat, cps); return _loopAt(factor, pat, cps);
}); });
@@ -3630,7 +3703,7 @@ let fadeGain = (p) => (p < 0.5 ? 1 : 1 - (p - 0.5) / 0.5);
* - 1 = (no left, full right) * - 1 = (no left, full right)
* *
* @name xfade * @name xfade
* @tags combiners * @tags amplitude
* @example * @example
* xfade(s("bd*2"), "<0 .25 .5 .75 1>", s("hh*8")) * xfade(s("bd*2"), "<0 .25 .5 .75 1>", s("hh*8"))
*/ */
@@ -3756,7 +3829,7 @@ const _distortWithAlg = function (name) {
* Soft-clipping distortion * Soft-clipping distortion
* *
* @name soft * @name soft
* @tags fx * @tags distortion, superdough
* @param {number | Pattern} distortion amount of distortion to apply * @param {number | Pattern} distortion amount of distortion to apply
* @param {number | Pattern} volume linear postgain of the distortion * @param {number | Pattern} volume linear postgain of the distortion
* *
@@ -3767,7 +3840,7 @@ export const soft = _distortWithAlg('soft');
* Hard-clipping distortion * Hard-clipping distortion
* *
* @name hard * @name hard
* @tags fx * @tags distortion, superdough
* @param {number | Pattern} distortion amount of distortion to apply * @param {number | Pattern} distortion amount of distortion to apply
* @param {number | Pattern} volume linear postgain of the distortion * @param {number | Pattern} volume linear postgain of the distortion
* *
@@ -3778,7 +3851,7 @@ export const hard = _distortWithAlg('hard');
* Cubic polynomial distortion * Cubic polynomial distortion
* *
* @name cubic * @name cubic
* @tags fx * @tags distortion, superdough
* @param {number | Pattern} distortion amount of distortion to apply * @param {number | Pattern} distortion amount of distortion to apply
* @param {number | Pattern} volume linear postgain of the distortion * @param {number | Pattern} volume linear postgain of the distortion
* *
@@ -3789,7 +3862,7 @@ export const cubic = _distortWithAlg('cubic');
* Diode-emulating distortion * Diode-emulating distortion
* *
* @name diode * @name diode
* @tags fx * @tags distortion, superdough
* @param {number | Pattern} distortion amount of distortion to apply * @param {number | Pattern} distortion amount of distortion to apply
* @param {number | Pattern} volume linear postgain of the distortion * @param {number | Pattern} volume linear postgain of the distortion
* *
@@ -3800,7 +3873,7 @@ export const diode = _distortWithAlg('diode');
* Asymmetrical diode distortion * Asymmetrical diode distortion
* *
* @name asym * @name asym
* @tags fx * @tags distortion, superdough
* @param {number | Pattern} distortion amount of distortion to apply * @param {number | Pattern} distortion amount of distortion to apply
* @param {number | Pattern} volume linear postgain of the distortion * @param {number | Pattern} volume linear postgain of the distortion
* *
@@ -3811,7 +3884,7 @@ export const asym = _distortWithAlg('asym');
* Wavefolding distortion * Wavefolding distortion
* *
* @name fold * @name fold
* @tags fx * @tags distortion, superdough
* @param {number | Pattern} distortion amount of distortion to apply * @param {number | Pattern} distortion amount of distortion to apply
* @param {number | Pattern} volume linear postgain of the distortion * @param {number | Pattern} volume linear postgain of the distortion
* *
@@ -3822,7 +3895,7 @@ export const fold = _distortWithAlg('fold');
* Wavefolding distortion composed with sinusoid * Wavefolding distortion composed with sinusoid
* *
* @name sinefold * @name sinefold
* @tags fx * @tags distortion, superdough
* @param {number | Pattern} distortion amount of distortion to apply * @param {number | Pattern} distortion amount of distortion to apply
* @param {number | Pattern} volume linear postgain of the distortion * @param {number | Pattern} volume linear postgain of the distortion
* *
@@ -3833,7 +3906,7 @@ export const sinefold = _distortWithAlg('sinefold');
* Distortion via Chebyshev polynomials * Distortion via Chebyshev polynomials
* *
* @name chebyshev * @name chebyshev
* @tags fx * @tags distortion, superdough
* @param {number | Pattern} distortion amount of distortion to apply * @param {number | Pattern} distortion amount of distortion to apply
* @param {number | Pattern} volume linear postgain of the distortion * @param {number | Pattern} volume linear postgain of the distortion
* *
@@ -3867,7 +3940,7 @@ const _ensureListPattern = (list) => {
* Can also be used to create a new synth via `s('user').partials(...)` * Can also be used to create a new synth via `s('user').partials(...)`
* *
* @name partials * @name partials
* @tags fx, superdough * @tags superdough
* @param {number[] | Pattern} magnitudes List of [0, 1] magnitudes for partials. 0th entry is the fundamental harmonic (i.e. DC offset is skipped) * @param {number[] | Pattern} magnitudes List of [0, 1] magnitudes for partials. 0th entry is the fundamental harmonic (i.e. DC offset is skipped)
* @example * @example
* s("user").seg(16).n(irand(8)).scale("A:major") * s("user").seg(16).n(irand(8)).scale("A:major")
@@ -3889,7 +3962,7 @@ export const partials = (list) => {
* Rotates the harmonics of one of the core synths ('sine', 'tri', 'saw', 'user', ..) by a list of phases * Rotates the harmonics of one of the core synths ('sine', 'tri', 'saw', 'user', ..) by a list of phases
* *
* @name phases * @name phases
* @tags fx, superdough * @tags superdough
* @param {number[] | Pattern} phases List of [0, 1) phases for partials. 0th entry is the fundamental phase (i.e. DC offset is skipped) * @param {number[] | Pattern} phases List of [0, 1) phases for partials. 0th entry is the fundamental phase (i.e. DC offset is skipped)
* @example * @example
* // Phase cancellation * // Phase cancellation
@@ -3911,7 +3984,7 @@ export const phases = (list) => {
* calls and/or in a single .FX(fx1, fx2, ..) call. The fx1, .. are _patterns_ which * calls and/or in a single .FX(fx1, fx2, ..) call. The fx1, .. are _patterns_ which
* establish the controls of the given effect. See examples. * establish the controls of the given effect. See examples.
* @name FX * @name FX
* @tags fx, superdough * @tags superdough
* @memberof Pattern * @memberof Pattern
* @returns Pattern * @returns Pattern
* @example * @example
@@ -3962,7 +4035,7 @@ const _asArrayPattern = (pats) => {
* by wrapping them inside a function in K (see example). * by wrapping them inside a function in K (see example).
* *
* @name K * @name K
* @tags generators, fx, superdough * @tags generators, superdough
* @param {KabelsalatExpression | Function} expr Kabelsalat graph definition * @param {KabelsalatExpression | Function} expr Kabelsalat graph definition
* @memberof Pattern * @memberof Pattern
* @returns Pattern * @returns Pattern
+28 -23
View File
@@ -71,29 +71,32 @@ export const pickmod = register('pickmod', function (lookup, pat) {
/** * pickF lets you use a pattern of numbers to pick which function to apply to another pattern. /** * pickF lets you use a pattern of numbers to pick which function to apply to another pattern.
* @tags combiners, functional * @tags combiners, functional
* @param {Pattern} pat * @param {Pattern} pat
* @param {Pattern} lookup a pattern of indices * @param {Pattern} lookup a pattern of indices or names
* @param {function[]} funcs the array of functions from which to pull * @param {function[] | object} lookup the array or lookup object of functions from which to pull
* @returns {Pattern} * @returns {Pattern}
* @example * @example
* s("bd [rim hh]").pickF("<0 1 2>", [rev,jux(rev),fast(2)]) * s("bd [rim hh]").pickF("<0 1 2>", [rev,jux(rev),fast(2)])
* @example * @example
* note("<c2 d2>(3,8)").s("square") * note("<c2 d2>(3,8)").s("square")
* .pickF("<0 2> 1", [jux(rev),fast(2),x=>x.lpf(800)]) * .pickF("<0 2> 1", [jux(rev), fast(2), x=>x.lpf(800)])
* @example
* note("<c2 d2>(3,8)").s("square")
* .pickF("<jr l> f", { jr:jux(rev), f:fast(2), l:x=>x.lpf(800) })
*/ */
export const pickF = register('pickF', function (lookup, funcs, pat) { export const pickF = register('pickF', function (pickPattern, lookup, pat) {
return pat.apply(pick(lookup, funcs)); return pat.apply(pick(lookup, pickPattern));
}); });
/** * The same as `pickF`, but if you pick a number greater than the size of the functions list, /** * The same as `pickF`, but if you pick a number greater than the size of the functions list,
* it wraps around, rather than sticking at the maximum value. * it wraps around, rather than sticking at the maximum value.
* @tags combiners * @tags combiners
* @param {Pattern} pat * @param {Pattern} pat
* @param {Pattern} lookup a pattern of indices * @param {Pattern} lookup a pattern of indices or names
* @param {function[]} funcs the array of functions from which to pull * @param {function[] | object} lookup the array or lookup object of functions from which to pull
* @returns {Pattern} * @returns {Pattern}
*/ */
export const pickmodF = register('pickmodF', function (lookup, funcs, pat) { export const pickmodF = register('pickmodF', function (pickPattern, lookup, pat) {
return pat.apply(pickmod(lookup, funcs)); return pat.apply(pickmod(lookup, pickPattern));
}); });
/** * Similar to `pick`, but it applies an outerJoin instead of an innerJoin. /** * Similar to `pick`, but it applies an outerJoin instead of an innerJoin.
@@ -167,20 +170,22 @@ export const pickmodReset = register('pickmodReset', function (lookup, pat) {
}); });
/** Picks patterns (or plain values) either from a list (by index) or a lookup table (by name). /** Picks patterns (or plain values) either from a list (by index) or a lookup table (by name).
* Similar to `pick`, but cycles are squeezed into the target ('inhabited') pattern. * Similar to `pick`, but cycles are squeezed into the target ('inhabited') pattern.
* @name inhabit * @name inhabit
* @tags combiners * @tags combiners
* @synonyms pickSqueeze * @synonyms pickSqueeze
* @param {Pattern} pat * @param {Pattern} pat
* @param {*} xs * @param {*} xs
* @returns {Pattern} * @returns {Pattern}
* @example * @example
* "<a b [a,b]>".inhabit({a: s("bd(3,8)"), * let a = s("bd(3,8)")
b: s("cp sd") * let b = s("cp sd")
}) * "<a b [a,b]>".inhabit({ a, b })
* @example * @example
* s("a@2 [a b] a".inhabit({a: "bd(3,8)", b: "sd sd"})).slow(4) * s("a@2 [a b] a"
*/ * .inhabit({a: "bd(3,8)", b: "sd sd"}))
* .slow(4)
*/
export const { inhabit, pickSqueeze } = register(['inhabit', 'pickSqueeze'], function (lookup, pat) { export const { inhabit, pickSqueeze } = register(['inhabit', 'pickSqueeze'], function (lookup, pat) {
return _pick(lookup, pat, false).squeezeJoin(); return _pick(lookup, pat, false).squeezeJoin();
}); });
+302 -39
View File
@@ -38,6 +38,7 @@ export function repl({
pattern: undefined, pattern: undefined,
miniLocations: [], miniLocations: [],
widgets: [], widgets: [],
sliders: [],
pending: false, pending: false,
started: false, started: false,
}; };
@@ -79,11 +80,132 @@ export function repl({
let allTransform; let allTransform;
let eachTransform; let eachTransform;
// Block-based evaluation state
let codeBlocks = {};
let lastActiveVisualizerLabel = null;
// Track which patterns belong to which blocks: { blockRange: [patternKeys] }
let blockPatterns = new Map();
// Helper function to collect properties from all code blocks (handles both labeled and anonymous blocks)
function collectFromBlocks(property) {
return Object.entries(codeBlocks).flatMap(([key, block]) => {
if (key === '$') {
// Anonymous blocks are stored as an array of block objects
return Array.isArray(block) ? block.flatMap((b) => b[property] || []) : [];
}
// Labeled blocks are stored as single block objects
return block[property] || [];
});
}
// Helper function to process a single labeled block
function processLabeledBlock(labels, i, code, options, meta) {
const label = labels[i];
const nextLabel = labels[i + 1] || { index: code.length, end: code.length };
const labelCode = code.slice(label.index, nextLabel.index);
const labelRange = [label.index + options.range[0], label.end + options.range[0]];
// Calculate the full block range (from label start to next label start)
const blockStart = label.index + options.range[0];
const blockEnd = nextLabel.index + options.range[0];
const blockWidgets = (meta?.widgets || []).filter((widget) => {
const widgetPos = widget.from ?? widget.index ?? 0;
return widgetPos >= blockStart && widgetPos < blockEnd;
});
const blockSliders = (meta?.sliders || []).filter((slider) => {
const sliderPos = slider.from ?? slider.index ?? 0;
return sliderPos >= blockStart && sliderPos < blockEnd;
});
const blockMiniLocations = (meta?.miniLocations || []).filter((loc) => {
// const locStart = loc.start ?? loc.from ?? 0;
// mini locations can be either [start, end] arrays or objects with start/from
const locStart = Array.isArray(loc) ? loc[0] : (loc.start ?? loc.from ?? 0);
return locStart >= blockStart && locStart < blockEnd;
});
handleSingleLabelBlock(
label,
labelCode,
{ ...options, range: labelRange },
{ widgets: blockWidgets, sliders: blockSliders, miniLocations: blockMiniLocations },
);
}
// helper
function cleanupConflictingRanges(codeBlocks, currentKey, newRange) {
for (const [existingKey, existingBlock] of Object.entries(codeBlocks)) {
if (existingKey === currentKey) continue;
if (!existingBlock.range) continue;
const [existingStart, existingEnd] = existingBlock.range;
const [newStart, newEnd] = newRange;
// If ranges overlap (not just touch), remove the stale block
if (!(newEnd <= existingStart || newStart >= existingEnd)) {
delete codeBlocks[existingKey];
}
}
}
// helper
function handleSingleLabelBlock(label, code, options, meta) {
// Detect if this block contains a non-inline widget
// The activeVisualizer is now provided by the transpiler for all labels
const activeVisualizer = label.activeVisualizer || null;
if (activeVisualizer !== null) {
lastActiveVisualizerLabel = label.name;
}
// Store the entire code block under the label name
codeBlocks[label.name] = {
code: code,
range: options.range,
labels: [label.name],
miniLocations: meta?.miniLocations || [],
widgets: meta?.widgets || [],
sliders: meta?.sliders || [],
activeVisualizer: activeVisualizer, // Store the widget type if present, null otherwise
};
// Clean up any blocks with conflicting ranges (including declaration blocks)
cleanupConflictingRanges(codeBlocks, label.name, options.range);
}
// helper
// These blocks return silence but may contain mini notation strings that need highlighting
function handleDeclarationBlock(code, options, meta) {
const range = options.range || [];
if (range.length < 2) return;
const blockKey = `_decl:${range[0]}:${range[1]}`;
codeBlocks[blockKey] = {
code: code,
range: range,
labels: [],
miniLocations: meta?.miniLocations || [],
widgets: meta?.widgets || [],
sliders: meta?.sliders || [],
activeVisualizer: null,
};
// Clean up any overlapping declaration blocks
cleanupConflictingRanges(codeBlocks, blockKey, range);
}
const hush = function () { const hush = function () {
pPatterns = {}; pPatterns = {};
anonymousIndex = 0; anonymousIndex = 0;
allTransform = undefined; allTransform = undefined;
eachTransform = undefined; eachTransform = undefined;
codeBlocks = {};
blockPatterns.clear();
lastActiveVisualizerLabel = null; // Reset 'all' visualizer tracking
return silence; return silence;
}; };
@@ -103,7 +225,60 @@ export function repl({
}; };
setTime(() => scheduler.now()); // TODO: refactor? setTime(() => scheduler.now()); // TODO: refactor?
const stop = () => scheduler.stop(); // Helper function to apply pattern transformations (solo, each, all)
// this should be abstracted more
function applyPatternTransforms(pattern) {
const allPatterns = Object.values(pPatterns);
if (allPatterns.length) {
let patterns = [];
let soloActive = false;
for (const [key, value] of Object.entries(pPatterns)) {
// handle soloed patterns ex: S$: s("bd!4")
const isSolod = key.length > 1 && key.startsWith('S');
if (isSolod && soloActive === false) {
// first time we see a soloed pattern, clear existing patterns
patterns = [];
soloActive = true;
}
if (!soloActive || (soloActive && isSolod)) {
const valWithState = value.withState((state) => state.setControls({ id: key }));
patterns.push(valWithState);
}
}
if (eachTransform) {
// Explicit lambda so only element (not index and array) are passed
patterns = patterns.map((x) => eachTransform(x));
}
pattern = stack(...patterns);
} else if (eachTransform) {
pattern = eachTransform(pattern);
}
if (allTransforms.length) {
for (const transform of allTransforms) {
pattern = transform(pattern);
}
}
if (!isPattern(pattern)) {
pattern = silence;
}
return pattern;
}
const stop = () => {
codeBlocks = {};
blockPatterns.clear();
pPatterns = {};
lastActiveVisualizerLabel = null; // Reset 'all' visualizer tracking
updateState({
miniLocations: [],
widgets: [],
sliders: [],
});
scheduler.stop();
};
const start = () => scheduler.start(); const start = () => scheduler.start();
const pause = () => scheduler.pause(); const pause = () => scheduler.pause();
const toggle = () => scheduler.toggle(); const toggle = () => scheduler.toggle();
@@ -217,7 +392,7 @@ export function repl({
}); });
}; };
const evaluate = async (code, autostart = true, shouldHush = true) => { const evaluate = async (code, autostart = true) => {
if (!code) { if (!code) {
throw new Error('no code to evaluate'); throw new Error('no code to evaluate');
} }
@@ -225,59 +400,34 @@ export function repl({
updateState({ code, pending: true }); updateState({ code, pending: true });
await injectPatternMethods(); await injectPatternMethods();
setTime(() => scheduler.now()); // TODO: refactor? setTime(() => scheduler.now()); // TODO: refactor?
await beforeEval?.({ code }); await beforeEval?.({ code, blockBased: false });
allTransforms = []; // reset all transforms allTransforms = []; // reset all transforms
shouldHush && hush();
codeBlocks = {};
hush();
if (mondo) { if (mondo) {
code = `mondolang\`${code}\``; code = `mondolang\`${code}\``;
} }
let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptions);
if (Object.keys(pPatterns).length) {
let patterns = [];
let soloActive = false;
for (const [key, value] of Object.entries(pPatterns)) {
// handle soloed patterns ex: S$: s("bd!4")
const isSolod = key.length > 1 && key.startsWith('S');
if (isSolod && soloActive === false) {
// first time we see a soloed pattern, clear existing patterns
patterns = [];
soloActive = true;
}
if (!soloActive || (soloActive && isSolod)) {
const valWithState = value.withState((state) => state.setControls({ id: key }));
patterns.push(valWithState);
}
}
if (eachTransform) {
// Explicit lambda so only element (not index and array) are passed
patterns = patterns.map((x) => eachTransform(x));
}
pattern = stack(...patterns);
} else if (eachTransform) {
pattern = eachTransform(pattern);
}
if (allTransforms.length) {
for (const transform of allTransforms) {
pattern = transform(pattern);
}
}
if (!isPattern(pattern)) { let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptions);
pattern = silence;
} pattern = applyPatternTransforms(pattern);
logger(`[eval] code updated`); logger(`[eval] code updated`);
pattern = await setPattern(pattern, autostart); pattern = await setPattern(pattern, autostart);
updateState({ updateState({
miniLocations: meta?.miniLocations || [], miniLocations: meta?.miniLocations || [],
widgets: meta?.widgets || [], widgets: meta?.widgets || [],
sliders: meta?.sliders || [],
activeCode: code, activeCode: code,
pattern, pattern,
evalError: undefined, evalError: undefined,
schedulerError: undefined, schedulerError: undefined,
pending: false, pending: false,
}); });
afterEval?.({ code, pattern, meta });
afterEval?.({ code, pattern, meta, range: undefined, widgetRemoved: false });
return pattern; return pattern;
} catch (err) { } catch (err) {
logger(`[eval] error: ${err.message}`, 'error'); logger(`[eval] error: ${err.message}`, 'error');
@@ -286,8 +436,121 @@ export function repl({
onEvalError?.(err); onEvalError?.(err);
} }
}; };
const evaluateBlock = async (code, autostart = true, options = {}) => {
if (!code) {
throw new Error('no code to evaluate');
}
try {
updateState({ code, pending: true });
await injectPatternMethods();
setTime(() => scheduler.now()); // TODO: refactor?
await beforeEval?.({ code, blockBased: true });
allTransforms = []; // reset all transforms
const transpilerOptionsWithBlock = {
...transpilerOptions,
blockBased: true,
range: options.range || [],
};
if (mondo) {
code = `mondolang\`${code}\``;
}
let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptionsWithBlock);
// Track activeVisualizer cleanup: check if any block's visualizer was removed
let widgetRemoved = false;
const labels = meta.labels || [];
// Check for anonymous labels (labels starting with '$')
const hasAnonymousLabel = labels.some((label) => label.name.startsWith('$'));
// Store code blocks in dictionary using labels as keys
if (hasAnonymousLabel) {
// variable/function declarations that don't return patterns are allowed,
// but anonymous pattern blocks pose an issue for block-based evaluation
// if an anonymous pattern is evaluated multiple times it will just stack and get louder and louder
// it's very common for users to write code prefixed with '$'
// but to modify and override existing patterns, the patterns must be labeled,
// otherwise we'll have no idea of which pattern is being overridden
// (we probably need to update the docs on this)
// we could easily enable it, but it would confuse a lot of people
throw new Error(
'anonymous labels disabled for block based evaluation (see https://strudel.cc/blog/#label-notation)',
);
} else if (labels.length > 0) {
for (let i = 0; i < labels.length; i++) {
// processing transpiler output instead of code is simply to avoid
// extra regex in detecting whether or not an inline widget has been commented out
processLabeledBlock(labels, i, meta.output, options, meta);
}
} else {
// Declaration block (variable/function that returns silence)
// Store it so its miniLocations are preserved for highlighting patterns stored in variables
handleDeclarationBlock(code, options, meta);
}
meta.miniLocations = collectFromBlocks('miniLocations');
meta.widgets = collectFromBlocks('widgets');
meta.sliders = collectFromBlocks('sliders');
// Track activeVisualizer cleanup: check if any block's visualizer was removed
const blocksToUpdate = labels.map((label) => label.name);
// this is the hackiest bit
for (const [key, block] of Object.entries(codeBlocks)) {
if (blocksToUpdate.includes(key)) {
// This block was just updated
if (block.activeVisualizer !== null) {
// Block now has a visualizer, update tracking
lastActiveVisualizerLabel = key;
} else if (lastActiveVisualizerLabel === key) {
// This block lost its visualizer, trigger cleanup
widgetRemoved = true;
lastActiveVisualizerLabel = null;
}
}
}
pPatterns = Object.fromEntries(
Object.entries(pPatterns).filter(([key]) => {
return Object.keys(codeBlocks).includes(key);
}),
);
pattern = applyPatternTransforms(pattern);
logger(`[eval] code updated`);
pattern = await setPattern(pattern, autostart);
updateState({
miniLocations: meta?.miniLocations || [],
widgets: meta?.widgets || [],
sliders: meta?.sliders || [],
activeCode: code,
pattern,
evalError: undefined,
schedulerError: undefined,
pending: false,
});
afterEval?.({ code, pattern, meta, range: options.range, widgetRemoved });
return pattern;
} catch (err) {
logger(`[eval] error: ${err.message}`, 'error');
console.error(err);
updateState({ evalError: err, pending: false });
onEvalError?.(err);
}
};
const setCode = (code) => updateState({ code }); const setCode = (code) => updateState({ code });
return { scheduler, evaluate, start, stop, pause, setCps, setPattern, setCode, toggle, state }; return { scheduler, evaluate, evaluateBlock, start, stop, pause, setCps, setPattern, setCode, toggle, state };
} }
export const getTrigger = export const getTrigger =
+5 -3
View File
@@ -316,7 +316,7 @@ export const run = (n) => saw.range(0, n).round().segment(n);
* // "hh".s().struct("1 0 1") * // "hh".s().struct("1 0 1")
*/ */
export const binary = (n) => { export const binary = (n) => {
const nBits = reify(n).log2(0).floor().add(1); const nBits = reify(n).log2().floor().add(1);
return binaryN(n, nBits); return binaryN(n, nBits);
}; };
@@ -348,7 +348,7 @@ export const binaryN = (n, nBits = 16) => {
* .partials(binaryL(irand(4096).add(1))) * .partials(binaryL(irand(4096).add(1)))
*/ */
export const binaryL = (n) => { export const binaryL = (n) => {
const nBits = reify(n).log2(0).floor().add(1); const nBits = reify(n).log2().floor().add(1);
return binaryNL(n, nBits); return binaryNL(n, nBits);
}; };
@@ -389,7 +389,9 @@ export const randL = (n) => {
export const randrun = (n) => { export const randrun = (n) => {
return signal((t, controls) => { return signal((t, controls) => {
// Without adding 0.5, the first cycle is always 0,1,2,3,... // Without adding 0.5, the first cycle is always 0,1,2,3,...
const rands = getRandsAtTime(t.floor().add(0.5), n, controls.randSeed); let rands = getRandsAtTime(t.floor().add(0.5), n, controls.randSeed);
// Support n = 1
if (!Array.isArray(rands)) rands = [rands];
const nums = rands const nums = rands
.map((n, i) => [n, i]) .map((n, i) => [n, i])
.sort((a, b) => (a[0] > b[0]) - (a[0] < b[0])) .sort((a, b) => (a[0] > b[0]) - (a[0] < b[0]))
+7 -1
View File
@@ -9,7 +9,7 @@ import Fraction from 'fraction.js';
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { saw, saw2, isaw, isaw2, per, perx, cyclesPer } from '../signal.mjs'; import { saw, saw2, isaw, isaw2, per, perx, cyclesPer } from '../signal.mjs';
import { fastcat, sequence, State, TimeSpan, Hap } from '../index.mjs'; import { fastcat, sequence, State, TimeSpan, Hap, note } from '../index.mjs';
const st = (begin, end) => new State(ts(begin, end)); const st = (begin, end) => new State(ts(begin, end));
const ts = (begin, end) => new TimeSpan(Fraction(begin), Fraction(end)); const ts = (begin, end) => new TimeSpan(Fraction(begin), Fraction(end));
@@ -59,3 +59,9 @@ describe('perx', () => {
); );
}); });
}); });
describe('shuffle', () => {
it('returns original pattern if input is 1', () => {
expect(note('c d e f').sound('piano').shuffle(1).firstCycle()).toEqual(note('c d e f').sound('piano').firstCycle());
});
});
+7 -2
View File
@@ -339,8 +339,13 @@ export function uniqsortr(a) {
export function unicodeToBase64(text) { export function unicodeToBase64(text) {
const utf8Bytes = new TextEncoder().encode(text); const utf8Bytes = new TextEncoder().encode(text);
const base64String = btoa(String.fromCharCode(...utf8Bytes)); let binaryString = '';
return base64String; const chunkSize = 0x8000;
for (let i = 0; i < utf8Bytes.length; i += chunkSize) {
const chunk = utf8Bytes.subarray(i, i + chunkSize);
binaryString += String.fromCharCode.apply(null, chunk);
}
return btoa(binaryString);
} }
export function base64ToUnicode(base64String) { export function base64ToUnicode(base64String) {
+10
View File
@@ -78,6 +78,16 @@ export const cleanupDraw = (clearScreen = true, id) => {
stopAllAnimations(id); stopAllAnimations(id);
}; };
export const cleanupDrawContext = (replID) => {
const ctx = getDrawContext();
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
// clear the big canvas context, ignore inline widgets
Object.keys(animationFrames).forEach(
(id) => (!replID || id.startsWith(replID)) && !id.startsWith('_') && stopAnimationFrame(id),
);
};
Pattern.prototype.onPaint = function (painter) { Pattern.prototype.onPaint = function (painter) {
return this.withState((state) => { return this.withState((state) => {
if (!state.controls.painters) { if (!state.controls.painters) {
+32 -1
View File
@@ -54,11 +54,42 @@ export function pitchwheel({
} }
if (edo) { if (edo) {
edo = haps.length >= 1 && haps[0].value && haps[0].value.edo ? haps[0].value.edo : edo;
root = haps.length >= 1 && haps[0].value && haps[0].value.root ? haps[0].value.root : root;
const degreeIndexes =
haps.length >= 1 && haps[0].value && haps[0].value.degreeIndexes ? haps[0].value.degreeIndexes : null;
const intLabels = haps.length >= 1 && haps[0].value && haps[0].value.intLabels ? haps[0].value.intLabels : null;
ctx.font = '20px sans-serif';
const label = `${edo} EDO`;
// Draw EDO label:
ctx.fillText(label, centerX + radius - ctx.measureText(label).width + 15, centerY + radius);
Array.from({ length: edo }, (_, i) => { Array.from({ length: edo }, (_, i) => {
const angle = freq2angle(root * Math.pow(2, i / edo), root); const angle = freq2angle(root * Math.pow(2, i / edo), root);
const [x, y] = circlePos(centerX, centerY, radius, angle); const [x, y] = circlePos(centerX, centerY, radius, angle);
ctx.beginPath(); ctx.beginPath();
ctx.arc(x, y, hapRadius, 0, 2 * Math.PI); // Draw interval label for degree i when it exists:
if (degreeIndexes === null || degreeIndexes.includes(i)) {
ctx.globalAlpha = 1;
ctx.arc(x, y, hapRadius, 0, 2 * Math.PI);
if (intLabels !== null) {
const degree = degreeIndexes.indexOf(i);
if (intLabels[degree]) {
if (angle < 0.32 && angle > 0.125) {
ctx.fillText(intLabels[degree], x - 34, y);
} else {
if (angle < 0.1 && angle > -1.125) {
ctx.fillText(intLabels[degree], x - 7, y - 12);
} else {
ctx.fillText(intLabels[degree], x + 9, y);
}
}
}
}
} else {
ctx.globalAlpha = 0.15;
ctx.arc(x, y, hapRadius, 0, 2 * Math.PI);
}
ctx.fill(); ctx.fill();
}); });
ctx.stroke(); ctx.stroke();
+41
View File
@@ -0,0 +1,41 @@
# @strudel/edo
This package adds EDO scale functions to strudel Patterns.
## Install
```sh
npm i @strudel/edo --save
```
## Example
```js
import { n } from '@strudel/core';
import '@strudel/edo';
// E.g. edoScale for Gorgo-6 scale, 16 EDO, LLsLLLs
// base note C3, large step size 3, small step size 1:
// C3:LLsLLL:3:1
const [baseNote, sequence, largeStep, smallStep] = ['C3', 'LLsLLL', 3, 1]
const pattern = n("0 2 4 6 4 2").edoScale([baseNote, sequence, largeStep, smallStep]);
const events = pattern.firstCycle().map((e) => e.show());
console.log(events);
```
yields:
```
[
"[ 0/1 → 1/1 |
{
\"degree\":1,
\"degreeIndexes\":[0,3,6,7,10,13],
\"intLabels\":[null,\"S2\",\"d4\",\"N4\",\"s6\",\"s7\",\"P8\"],
\"root\":\"130.8128\",
\"freq\":130.813,
\"edo\":16
}
]"
]
```
+114
View File
@@ -0,0 +1,114 @@
/*
edo.mjs - Equal division of the octave (EDO) scale functions for strudel
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/edo.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, pure, noteToMidi, isNote, tokenizeNote } from '@strudel/core';
import { EdoScale } from './edoscale.mjs';
import { Intervals } from './intervals.mjs';
import { Pitches } from './pitches.mjs';
const pitchesCache = new Map();
/**
* Turns numbers into notes in the given EDO scale (zero indexed).
*
* An EDO scale definition looks like this:
*
* e.g. C:LLsLLLs:2:1 <- this is the C major scale, 12 EDO
*
* e.g. C:LLsLLL:3:1 <- this is the Gorgo 6 note scale, 16 EDO
*
* An EDO scale, e.g. C:LLsLLLs:2:1, consists of a root note (e.g. C)
* followed by semicolon (':')
* and then a [Large/small step notation sequence](https://en.xen.wiki/w/MOS_scale)
* (e.g. LLsLLLs)
* followed by semicolon, then the large step size (e.g. 2)
* followed by semicolon, then the small step size (e.g. 1).
*
* The number of divisions of the octave is calculated as the sum
* of the steps in the EDO scale definition.
*
* e.g. C:LLsLLLs:2:1 is 2+2+1+2+2+2+1 = 12 EDO, 7 note scale
*
* e.g. C:LLsLLL:3:1 is 3+3+1+3+3+3 = 16 EDO, 6 note scale
*
* The root note defaults to octave 3, if no octave number is given.
*
* @name edoScale
* @param {string} scale Definition of EDO scale.
* @returns Pattern
* @example
* n("0 2 4 6 4 2").edoScale("C:LLsLLLs:2:1")
* @example
* n("[0,7] 4 [2,7] 4")
* .edoScale("G2:<LLsLLL LLLLsL>:3:1")
* .s("piano")._pitchwheel()
* @example
* n(rand.range(0,5).segment(6))
* .edoScale("<G2 C3>:LLsLL:3:1")
* .s("piano")._pitchwheel()
*/
export const edoScale = register(
'edoScale',
function (scaleDefinition, pat) {
// console.log(scaleDefinition);
// if (Array.isArray(scale)) {
const key = scaleDefinition.flat().join(':');
// }
// console.log(scaleDefinition);
let pitches;
if (pitchesCache.has(key)) {
pitches = pitchesCache.get(key);
} else {
// console.log({ key });
const [base_note, sequence, large, small] = scaleDefinition;
const root_octave = tokenizeNote(base_note)[2] || 3;
// console.log({ root_octave });
const scale = new EdoScale(large, small, sequence);
const intervals = new Intervals(scale);
// console.log({ intervals });
pitches = new Pitches(scale, intervals, 440, noteToMidi(base_note), root_octave);
pitchesCache.set(key, pitches);
// console.log({ base_note: noteToMidi(base_note) });
// console.log({ sequence });
// console.log({ edivisions: scale.edivisions });
// console.log({ intervals });
// console.log({ pat });
// console.log({ pitches: pitches });
}
return pat
.fmap((value) => {
const isObject = typeof value === 'object';
const n = isObject ? value.n : value;
if (isObject) {
delete value.n; // remove n so it won't cause trouble
}
if (isNote(n)) {
// legacy..
return pure(n);
}
const deg = (typeof n === 'string' ? parseInt(n, 10) : Number.isInteger(n) ? n : Math.round(n)) + 1;
const [oct, degree] = pitches.octdeg(deg);
const freq = pitches.octdegfreq(oct, degree);
const note = pitches.octdegmidi(oct, degree);
const edo = pitches.scale.edivisions;
const root = pitches.base_freq;
const degreeIndexes = pitches.scale.divisions;
const intLabels = pitches.intervals.intLabels;
// const color = 'red';
value = pure(isObject ? { ...value, degree, degreeIndexes, intLabels, root, freq, edo } : note);
// value = pure(isObject ? { ...value, key } : note);
// value = pure(isObject ? { ...value, edo } : note);
// console.log({ value });
return value;
})
.outerJoin()
.withHap((hap) => hap.setContext({ ...hap.context, scaleDefinition }));
},
true,
true, // preserve tactus
);
+213
View File
@@ -0,0 +1,213 @@
/*
edoscale.mjs - EdoScale defines equal division of the octave (EDO) scale in Ls notation
- Port of pitfalls/lib/Scale.lua - see <https://github.com/robmckinnon/pitfalls/blob/main/lib/Scale.lua>
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/edoscale.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const M = 2;
const L = 1;
const S = 0;
const LABELS = ['s', 'L', 'M'];
export class EdoScale {
constructor(large, small, sequence, medium) {
this.stepbackup = [L, L, S, L, L, L, S, L, L, L, S, L, L, L, S, L];
this.large = large;
this.medium = medium || large;
this.small = small;
this.divisions = [];
this.edivisions = null;
this.sequence = null;
this.tonic = 1;
this.mode = 1;
this.max_steps = 12;
this.min_steps = 3;
this.setSequence(sequence);
}
hasMedium() {
return this.step.some((_, i) => this.step[this.offset(i)] === M);
}
stepSize(i) {
return LABELS[this.step[this.offset(i)]];
}
sequence() {
return this.step.map((_, i) => this.stepSize(i)).join('');
}
stepValue(i) {
const step = this.step[this.offset(i)];
return step === L ? this.large : step === M ? this.medium : this.small;
}
offset(i) {
if (this.mode === 1) {
return i;
} else {
const offset = (this.mode - 1 + i) % this.length;
return offset === 0 ? this.length : offset;
}
}
static setMaxSteps(max) {
this.max_steps = max;
}
static setMinSteps(min) {
this.min_steps = min;
}
setSequence(sequence) {
if (this.sequence !== sequence) {
this.sequence = sequence;
this.length = sequence.length;
this.step = [];
for (let i = 0; i < sequence.length; i++) {
const char = sequence[i];
this.step[i] = char === 'L' ? L : char === 'M' ? M : S;
}
this.updateEdo();
} else {
return false;
}
}
setLarge(l) {
if (this.large !== l) {
this.large = l;
this.updateEdo();
} else {
return false;
}
}
setMedium(m) {
if (this.medium !== m) {
this.medium = m;
this.updateEdo();
} else {
return false;
}
}
setSmall(s) {
if (this.small !== s) {
this.small = s;
this.updateEdo();
} else {
return false;
}
}
setMode(mode) {
this.mode = mode;
}
setTonic(tonic) {
this.tonic = tonic;
}
changeMode(d) {
const orig = this.mode;
this.mode = Math.max(1, Math.min(this.mode + d, this.length));
return orig !== this.mode;
}
changeTonic(d) {
const orig = this.tonic;
this.tonic = Math.max(1, Math.min(this.tonic + d, this.edivisions));
return orig !== this.tonic;
}
updateEdo() {
const orig = this.edivisions;
this.edivisions = this.step.reduce((sum, _, i) => {
this.divisions[i] = sum;
return sum + this.stepValue(i);
}, 0);
// console.log(this.divisions);
const changed = orig !== this.edivisions;
if (changed) {
this.tonic = Math.max(1, Math.min(this.tonic, this.edivisions));
}
return changed;
}
changeStep(d, i) {
const index = this.offset(i);
const orig = this.step[index];
this.step[index] = Math.max(S, Math.min(this.step[index] + d, M));
this.stepbackup[index] = this.step[index];
const changed = orig !== this.step[index];
if (changed) {
this.updateEdo();
}
return changed;
}
changeLarge(d) {
const orig = this.large;
this.setLarge(Math.max(this.small + 1, Math.min(this.large + d, this.large + 1)));
const changed = this.large !== orig;
if (changed) {
if (this.large <= this.medium) {
this.setMedium(Math.max(this.small + 1, Math.min(this.large - 1, this.large)));
}
this.updateEdo();
}
return changed;
}
changeMedium(d) {
const orig = this.medium;
this.setMedium(Math.max(this.small + 1, Math.min(this.medium + d, this.large - 1)));
const changed = this.medium !== orig;
if (changed) {
this.updateEdo();
}
return changed;
}
changeSmall(d) {
const orig = this.small;
const value = this.small + d;
if (this.hasMedium()) {
this.setSmall(Math.max(1, Math.min(value, this.medium - 1)));
} else {
this.setSmall(Math.max(1, Math.min(value, this.large - 1)));
}
const changed = this.small !== orig;
if (changed) {
if (this.small >= this.medium) {
this.setMedium(Math.max(this.small + 1, Math.min(this.large)));
}
this.updateEdo();
}
return changed;
}
changeLength(d) {
const orig = this.length;
if (d === 1) {
this.length = Math.min(this.length + 1, this.max_steps);
} else if (d === -1) {
this.length = Math.max(this.length - 1, this.min_steps);
}
const changed = this.length !== orig;
if (changed) {
this.mode = 1;
if (d === 1) {
this.step[this.length] = this.stepbackup[this.length] || L;
} else if (d === -1 && this.length >= this.min_steps) {
this.step.pop();
}
this.updateEdo();
}
return changed;
}
}
+5
View File
@@ -0,0 +1,5 @@
import './edo.mjs';
export * from './edo.mjs';
export const packageName = '@strudel/edo';
+103
View File
@@ -0,0 +1,103 @@
/*
intervals.mjs - defines Intervals for equal division of the octave (EDO) scale
- Port of pitfalls/lib/Intervals.lua - see <https://github.com/robmckinnon/pitfalls/blob/main/lib/Intervals.lua>
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/intervals.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 ratiointervals from './ratios.mjs';
function ratio(division, edivisions) {
return division === 0 ? 1 : Math.pow(2, division / edivisions);
}
export class Intervals {
constructor(scale) {
this.scale = scale;
this.intLabels = [];
this.intNoms = [];
this.intRatios = [];
this.uniqLabels = [];
this.intErrors = [];
this.ratios = [];
const BLANK = '';
let division = 0;
const labToErr = {};
const labToInd = {};
this.ratios[0] = 1;
for (let i = 0; i < scale.length; i++) {
division += scale.stepValue(i);
this.ratios[i + 1] = ratio(division, scale.edivisions);
if (i < scale.length) {
const nearest = ratiointervals.nearestInterval(this.ratios[i + 1]);
const closeness = nearest[0];
const ratio = nearest[1];
const intLabel = ratiointervals.key(ratio);
this.intLabels[i + 1] = intLabel;
this.intErrors[i + 1] = closeness;
this.intNoms[i + 1] = ratio ? ratiointervals.nom(ratio) : 0;
this.intRatios[i + 1] = ratio ? `${ratiointervals.nom(ratio)}/${ratiointervals.denom(ratio)}` : '';
this.uniqLabels[i + 1] = BLANK;
if (intLabel && intLabel !== 'P1' && intLabel !== 'P8') {
if (!labToErr[intLabel]) {
this.uniqLabels[i + 1] = intLabel;
labToInd[intLabel] = i + 1;
labToErr[intLabel] = closeness;
} else if (closeness < labToErr[intLabel]) {
this.uniqLabels[labToInd[intLabel]] = BLANK;
this.uniqLabels[i + 1] = intLabel;
labToInd[intLabel] = i + 1;
labToErr[intLabel] = closeness;
}
}
}
}
}
ratio(i) {
return this.ratios[i];
}
intervalLabel(i) {
return this.intLabels[i];
}
intervalNominator(i) {
return this.intNoms[i];
}
intervalRatio(i) {
return this.intRatios[i];
}
uniqIntervalLabel(i) {
return this.uniqLabels[i];
}
intervalError(i) {
return this.intErrors[i];
}
nearestDegreeTo(r, threshold) {
let min = 1;
let degree = null;
for (const [i, v] of Object.entries(this.ratios)) {
const diff = Math.abs((r - v) / r);
if (diff < min) {
min = diff;
degree = parseInt(i, 10);
}
}
if (threshold == null) {
return degree;
} else {
return min < threshold ? degree : 1;
}
}
}
+42
View File
@@ -0,0 +1,42 @@
{
"name": "@strudel/edo",
"version": "0.1.0",
"description": "Equal division of the octave (EDO) scale functions for strudel",
"main": "index.mjs",
"publishConfig": {
"main": "dist/index.mjs"
},
"scripts": {
"build": "vite build",
"test": "vitest run",
"prepublishOnly": "npm run build"
},
"type": "module",
"repository": {
"type": "git",
"url": "git+https://codeberg.org/uzu/strudel.git"
},
"keywords": [
"tidalcycles",
"strudel",
"pattern",
"livecoding",
"algorave"
],
"author": "Rob McKinnon <rob@movingflow>",
"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:*",
"@tonaljs/tonal": "^4.10.0",
"chord-voicings": "^0.0.1",
"webmidi": "^3.1.12"
},
"devDependencies": {
"vite": "^6.0.11",
"vitest": "^3.0.4"
}
}
+98
View File
@@ -0,0 +1,98 @@
/*
pitches.mjs - defines Pitches for equal division of the octave (EDO) scale
- Port of pitfalls/lib/Pitches.lua - see <https://github.com/robmckinnon/pitfalls/blob/main/lib/Pitches.lua>
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/pitches.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/>.
*/
function ratio(division, edivisions) {
return division === 0 ? 1 : Math.pow(2, division / edivisions);
}
// This function gets frequency based on index, edo, octave, and base frequency
function get_freq(base_freq, edo, index, oct, base_octave) {
let f = base_freq * ratio(index - 1, edo);
if (oct < base_octave) {
f /= Math.pow(2, base_octave - oct);
} else if (oct > base_octave) {
f *= Math.pow(2, oct - base_octave);
}
return f;
}
// Convert MIDI number to Hz using a given tuning multiplier
function midi_to_hz(n, tuning) {
return tuning * Math.pow(2, (n - 69) / 12.0);
}
const denom = Math.log(2);
function hz_to_midi(freq, tuning) {
return 12.0 * (Math.log(freq / tuning) / denom) + 69;
}
export class Pitches {
constructor(scale, intervals, tuning, midi_start, root_octave) {
this.scale = scale;
this.intervals = intervals;
this.base_freq = midi_to_hz(midi_start, tuning);
this.root_octave = root_octave;
this.freqs = {};
this.midis = {};
this.degrees = {};
this.octdegfreqs = {};
this.octdegmidis = {};
let index = 0;
let f = null;
for (let oct = 0; oct <= 8; oct++) {
this.octdegfreqs[oct] = {};
this.octdegmidis[oct] = {};
f = get_freq(this.base_freq, scale.edivisions, scale.tonic, oct, this.root_octave);
for (let deg = 0; deg < scale.length; deg++) {
index = index + 1;
this.freqs[index] = parseFloat((f * intervals.ratio(deg)).toFixed(3));
this.midis[index] = parseFloat(hz_to_midi(f * intervals.ratio(deg), tuning).toFixed(4));
this.degrees[index] = deg;
this.octdegfreqs[oct][deg + 1] = this.freqs[index];
this.octdegmidis[oct][deg + 1] = this.midis[index];
}
}
this.base_freq = parseFloat(this.base_freq).toFixed(4);
}
base_freq() {
return this.base_freq;
}
degree(index) {
return this.degrees[index];
}
freq(index) {
return this.freqs[index];
}
octdeg(deg) {
const higherOcatve = deg > this.scale.length;
const octave = this.root_octave + (higherOcatve ? Math.floor((deg - 1) / this.scale.length) : 0);
const degree = higherOcatve ? (deg % this.scale.length === 0 ? this.scale.length : deg % this.scale.length) : deg;
// console.log([octave, degree]);
return [octave, degree];
}
octdegfreq(oct, deg) {
if (this.octdegfreqs[oct]) {
return this.octdegfreqs[oct][deg];
} else {
return null;
}
}
octdegmidi(oct, deg) {
if (this.octdegmidis[oct]) {
return this.octdegmidis[oct][deg];
} else {
return null;
}
}
}
+95
View File
@@ -0,0 +1,95 @@
/*
ratios.mjs - lists whole number ratios for pitch intervals
- Port of pitfalls/lib/ratios.lua - see <https://github.com/robmckinnon/pitfalls/blob/main/lib/ratios.lua>
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/ratios.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const ratiointervals = {};
// FJS Calculators - https://misotanni.github.io/fjs/en/calc.html
// List of pitch intervals - https://en.wikipedia.org/wiki/List_of_pitch_intervals
// Gallery of just intervals - https://en.xen.wiki/w/Gallery_of_just_intervals
// Two letter codes changed to make different interval labels more unique.
ratiointervals.list = new Map([
[1, ['P1', 'P1', 'P1', 1, 1]], // unison P1
[16 / 15, ['m2', 'm2', 'm2_5', 16, 15]], // minor second m2
[15 / 14, ['A1', 'A1', 'A1^5_7', 15, 14]], // augmented unison
[13 / 12, ['t2', 'm2', 'm2^13', 13, 12]], // tridecimal neutral second
[12 / 11, ['N2', 'M2', 'M2_11', 12, 11]], // undecimal neutral second
[11 / 10, ['n2', 'm2', 'm2^11_5', 11, 10]], // undecimal submajor second
[10 / 9, ['T2', 'M2', 'M2^5', 10, 9]], // classic (whole) tone
[9 / 8, ['M2', 'M2', 'M2', 9, 8]], // major second M2
[8 / 7, ['S2', 'M2', 'M2_7', 8, 7]], // septimal major second
[7 / 6, ['s3', 'm3', 'm3^7', 7, 6]], // septimal minor third
[19 / 16, ['o3', 'm3', 'm3^19', 19, 16]], // otonal minor third
[6 / 5, ['m3', 'm3', 'm3_5', 6, 5]], // minor third m3
[17 / 14, ['t3', 'm3', 'm3^17_7', 17, 14]], // septendecimal supraminor third
[11 / 9, ['n3', 'm3', 'm3^11', 11, 9]], // undecimal neutral third
[5 / 4, ['M3', 'M3', 'M3^5', 5, 4]], // major third M3
[9 / 7, ['S3', 'M3', 'M3_7', 9, 7]], // septimal major third SM3
[13 / 10, ['d4', 'd4', 'd4^13_5', 13, 10]], // Barbados third
[4 / 3, ['P4', 'P4', 'P4', 4, 3]], // perfect fourth P4
[19 / 14, ['N4', 'P4', 'P4^19_7', 19, 14]], // undevicesimal wide fourth
[11 / 8, ['n4', 'P4', 'P4^11', 11, 8]], // super-fourth
[25 / 18, ['a4', 'A4', 'A4^5,5', 25, 18]], // classic augmented fourth
[7 / 5, ['sT', 'd5', 'd5^7_5', 7, 5]], // lesser septimal tritone
[45 / 32, ['A4', 'A4', 'A4^5', 45, 32]], // just augmented fourth
[17 / 12, ['d5', 'd5', 'd5^17', 17, 12]], // larger septendecimal tritone
[10 / 7, ['ST', 'A4', 'A4^5_7', 10, 7]], // greater septimal tritone
[13 / 9, ['t5', 'd5', 'd5^13', 13, 9]], // tridecimal diminished fifth
[3 / 2, ['P5', 'P5', 'P5', 3, 2]], // perfect fifth P5
[14 / 9, ['s6', 'M6', 'm6^7', 14, 9]], // subminor sixth or septimal sixth
[25 / 16, ['a5', 'A5', 'A5^5,5', 25, 16]], // classic augmented fifth
[11 / 7, ['A5', 'P5', 'P5^11_7', 11, 7]], // undecimal minor sixth
[8 / 5, ['m6', 'm6', 'm6_5', 8, 5]], // minor sixth m6
[13 / 8, ['N6', 'm6', 'm6^13', 13, 8]], // tridecimal neutral sixth
[18 / 11, ['n6', 'M6', 'M6_11', 18, 11]], // undecimal neutral sixth
[5 / 3, ['M6', 'M6', 'M6^5', 5, 3]], // just major sixth M6
[128 / 75, ['d7', 'd7', 'd7_5,5', 128, 75]], // diminished seventh
[17 / 10, ['T6', 'd7', 'd7^17_5', 17, 10]], // septendecimal diminished seventh
[12 / 7, ['S6', 'M6', 'M6_7', 12, 7]], // septimal major sixth
[7 / 4, ['s7', 'm7', 'm7^7', 7, 4]], // septimal minor seventh
[16 / 9, ['m7', 'm7', 'm7', 16, 9]], // lesser minor seventh
[9 / 5, ['g7', 'm7', 'm7_5', 9, 5]], // greater just minor seventh
[11 / 6, ['n7', 'm7', 'm7^11', 11, 6]], // undecimal neutral seventh
[13 / 7, ['N7', 'm7', 'm7^13_7', 13, 7]], // tridecimal neutral seventh
[15 / 8, ['M7', 'M7', 'M7^5', 15, 8]], // major seventh
[17 / 9, ['T7', 'd8', 'd8^17', 17, 9]], // large septendecimal major seventh
[19 / 10, ['d8', 'd8', 'd8^19_5', 19, 10]], // large undevicesimal major seventh
[2, ['P8', 'P8', 'P8', 2, 1]], // octave P8
]);
ratiointervals.key = function (ratio) {
return ratio == null ? '' : ratiointervals.list.get(ratio)?.[0] || '';
};
ratiointervals.label = function (ratio) {
return ratio == null ? '' : ratiointervals.list.get(ratio)?.[1] || '';
};
ratiointervals.fjs = function (ratio) {
return ratio == null ? '' : ratiointervals.list.get(ratio)?.[2] || '';
};
ratiointervals.nom = function (ratio) {
return ratio == null ? null : ratiointervals.list.get(ratio)?.[3] || null;
};
ratiointervals.denom = function (ratio) {
return ratio == null ? null : ratiointervals.list.get(ratio)?.[4] || null;
};
ratiointervals.nearestInterval = function (v) {
let min = 1;
let match = null;
for (const [ratio, _labels] of ratiointervals.list) {
const diff = Math.abs((ratio - v) / ratio);
if (diff < min) {
min = diff;
match = ratio;
}
}
return min < 0.01 ? [min, match] : [null, null];
};
export default ratiointervals;
+34
View File
@@ -0,0 +1,34 @@
/*
edo.test.mjs - tests of edo.mjs
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/packages/tonal/test/tonal.test.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import '../edo.mjs';
import { n } from '@strudel/core';
import { describe, it, expect } from 'vitest';
describe('edoScale', () => {
it('Should run tonal functions ', () => {
// base note A3 = 220Hz
let baseNote = 'A3';
let root = 220;
let freq = 220;
let pattern = n('0').edoScale([baseNote, 'LLsLLLs', 2, 1]);
let cycle = pattern.firstCycleValues[0];
expect(cycle.freq).toEqual(freq);
expect(cycle.edo).toEqual(12);
expect(cycle.degree).toEqual(1);
expect(parseFloat(cycle.root).toFixed(0)).toEqual(root.toFixed(0));
// base note A4 = 440Hz
baseNote = 'A4';
root = 440;
freq = 880;
pattern = n('7').edoScale([baseNote, 'LLsLLLs', 2, 1]);
cycle = pattern.firstCycleValues[0];
expect(cycle.freq).toEqual(freq);
expect(cycle.edo).toEqual(12);
expect(cycle.degree).toEqual(1);
expect(parseFloat(cycle.root).toFixed(0)).toEqual(root.toFixed(0));
});
});
+21
View File
@@ -0,0 +1,21 @@
/*
edoscale.test.mjs - tests of EdoScale from edoscale.mjs
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/test/edoscale.test.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import '../edoscale.mjs'; // need to import this to add prototypes
import { EdoScale } from '../edoscale.mjs';
import { describe, it, expect } from 'vitest';
describe('EdoScale', () => {
it('updates edivisions', () => {
let scale = new EdoScale(2, 1, ['L', 'L', 's', 'L', 'L', 'L', 's']);
expect(scale.stepSize(0)).toEqual('L');
expect(scale.stepValue(0)).toEqual(2);
expect(scale.stepSize(2)).toEqual('s');
expect(scale.stepValue(2)).toEqual(1);
expect(scale.edivisions).toEqual(12);
});
});
+18
View File
@@ -0,0 +1,18 @@
/*
ratios.test.mjs - tests of ratios.mjs
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/test/ratios.test.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import ratiointervals from '../ratios.mjs';
import { describe, it, expect } from 'vitest';
describe('ratiointervals', () => {
it('updates edivisions', () => {
let ratio = 9 / 7;
expect(ratiointervals.key(ratio)).toEqual('S3');
expect(ratiointervals.label(ratio)).toEqual('M3');
expect(ratiointervals.fjs(ratio)).toEqual('M3_7');
expect(ratiointervals.nom(ratio)).toEqual(9);
expect(ratiointervals.denom(ratio)).toEqual(7);
});
});
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from 'vite';
import { dependencies } from './package.json';
import { resolve } from 'path';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [],
build: {
lib: {
entry: resolve(__dirname, 'index.mjs'),
formats: ['es'],
fileName: (ext) => ({ es: 'index.mjs' })[ext],
},
rollupOptions: {
external: [...Object.keys(dependencies)],
},
target: 'esnext',
},
});
+193
View File
@@ -0,0 +1,193 @@
/*
input.mjs - MIDI input wrapper
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/midi/midi.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 { WebMidi } from 'webmidi';
import { logger, ref } from '@strudel/core';
import { getDevice } from './util.mjs';
/**
* MIDI input device wrapper that manages connection and reconnection, tracks
* persisted CC states, etc. These instances are long-lived and are maintained as singletons
* (keyed globally by input string/number).
*/
export class MidiInput {
/**
*
* @param {string | number} input MIDI device name or index defaulting to 0
*/
constructor(input) {
this.input = input;
this.stateKey = typeof input === 'string' ? input : undefined; // Saved state is not tracked for numeric index inputs
this._refs = {};
this._refsByChan = {};
this._loadAllStates();
this.initialDevice = this._startDeviceListener();
}
/**
* Implementation for the cc() factory function tied to this specific input.
* @param {number} cc MIDI CC number
* @param {number | undefined} chan MIDI channel (1-16) or undefined for all channels
*/
createCC(cc, chan) {
const lookupMap = chan === undefined ? this._refs : this._refsByChan[chan];
if (!(cc in lookupMap)) {
const initialState = this._loadState(chan);
lookupMap[cc] = initialState[cc] || 0;
}
return ref(() => lookupMap[cc]);
}
_startDeviceListener() {
const initialDevice = getDevice(this.input, WebMidi.inputs);
// Background connection loop
(async () => {
const midiListener = this._onMidiMessage.bind(this);
let device = initialDevice;
while (true) {
if (!device) {
device = await this._waitForDevice();
}
// Wait a bit for device to be ready to receive last state
await new Promise((resolve) => setTimeout(resolve, 2000));
try {
// Still continue if sending did not work
this._sendAllStates(device);
} catch (err) {
console.error('midiin: failed to send last state on connect:', device.name, err);
}
// Listen for incoming MIDI messages and for disconnection
device.addListener('midimessage', midiListener);
await this._waitForDeviceDisconnect(device);
device.removeListener('midimessage', midiListener);
device = null; // Clear var to trigger wait for connection
}
})();
return initialDevice;
}
// Returns a promise that resolves when the specified device is connected
_waitForDevice() {
return new Promise((resolve) => {
const connListener = () => {
const device = getDevice(this.input, WebMidi.inputs);
if (device) {
logger(`[midi] device reconnected: ${device.name}`);
WebMidi.removeListener('connected', connListener);
resolve(device);
}
};
WebMidi.addListener('connected', connListener);
});
}
// Returns a promise that resolves when the specified device is disconnected
_waitForDeviceDisconnect(device) {
return new Promise((resolve) => {
const disconnListener = (e) => {
if (e.port.name === device.name) {
logger(`[midi] device disconnected: ${device.name}`);
WebMidi.removeListener('disconnected', disconnListener);
resolve();
}
};
WebMidi.addListener('disconnected', disconnListener);
});
}
_onMidiMessage(e) {
const ccNum = e.dataBytes[0];
const v = e.dataBytes[1];
const chan = e.message.channel;
const scaled = v / 127;
this._refs[ccNum] = scaled;
this._refsByChan[chan] ??= {};
this._refsByChan[chan][ccNum] = scaled;
this._saveState(undefined, ccNum, scaled);
this._saveState(chan, ccNum, scaled);
}
_loadAllStates() {
Object.assign(this._refs, this._loadState(undefined));
for (let chan = 1; chan <= 16; chan++) {
this._refsByChan[chan] ??= {};
Object.assign(this._refsByChan[chan], this._loadState(chan));
}
}
_loadState(chan) {
if (!this.stateKey) {
return {};
}
const initialDataRaw = localStorage.getItem(
`strudel-midin-${this.stateKey}-chan${chan !== undefined ? chan : 'all'}`,
);
if (!initialDataRaw) {
return {};
}
try {
return JSON.parse(initialDataRaw);
} catch (err) {
console.warn(
`Failed to parse MIDI state from localStorage for input "${this.stateKey}" and channel "${chan}"`,
initialDataRaw,
err,
);
return {};
}
}
_saveState(chan, cc, value) {
if (!this.stateKey) {
return;
}
const state = this._loadState(chan);
state[cc] = value;
localStorage.setItem(
`strudel-midin-${this.stateKey}-chan${chan !== undefined ? chan : 'all'}`,
JSON.stringify(state),
);
}
// Send CC values back to device to restore encoders and motorized sliders
_sendAllStates(device) {
const output = WebMidi.outputs.find((o) => o.name === device.name);
if (!output) {
return;
}
for (const [chan, refs] of Object.entries(this._refsByChan)) {
const channel = Number(chan);
for (const [cc, value] of Object.entries(refs)) {
const ccn = Number(cc);
const scaled = Math.round(value * 127);
output.sendControlChange(ccn, scaled, channel);
}
}
}
}
+37 -67
View File
@@ -23,6 +23,8 @@ import { noteToMidi, getControlName } from '@strudel/core';
import { Note } from 'webmidi'; import { Note } from 'webmidi';
import { getAudioContext } from '@strudel/webaudio'; import { getAudioContext } from '@strudel/webaudio';
import { scheduleAtTime } from '../superdough/helpers.mjs'; import { scheduleAtTime } from '../superdough/helpers.mjs';
import { getMidiDeviceNamesString, getDevice } from './util.mjs';
import { MidiInput } from './input.mjs';
// if you use WebMidi from outside of this package, make sure to import that instance: // if you use WebMidi from outside of this package, make sure to import that instance:
export const { WebMidi } = _WebMidi; export const { WebMidi } = _WebMidi;
@@ -31,10 +33,6 @@ function supportsMidi() {
return typeof navigator.requestMIDIAccess === 'function'; return typeof navigator.requestMIDIAccess === 'function';
} }
function getMidiDeviceNamesString(devices) {
return devices.map((o) => `'${o.name}'`).join(' | ');
}
export function enableWebMidi(options = {}) { export function enableWebMidi(options = {}) {
const { onReady, onConnected, onDisconnected, onEnabled } = options; const { onReady, onConnected, onDisconnected, onEnabled } = options;
if (WebMidi.enabled) { if (WebMidi.enabled) {
@@ -72,29 +70,6 @@ export function enableWebMidi(options = {}) {
}); });
} }
function getDevice(indexOrName, devices) {
if (!devices.length) {
throw new Error(`🔌 No MIDI devices found. Connect a device or enable IAC Driver.`);
}
if (typeof indexOrName === 'number') {
return devices[indexOrName];
}
const byName = (name) => devices.find((output) => output.name.includes(name));
if (typeof indexOrName === 'string') {
return byName(indexOrName);
}
// attempt to default to first IAC device if none is specified
const IACOutput = byName('IAC');
const device = IACOutput ?? devices[0];
if (!device) {
throw new Error(
`🔌 MIDI device '${device ? device : ''}' not found. Use one of ${getMidiDeviceNamesString(devices)}`,
);
}
return IACOutput ?? devices[0];
}
// send start/stop messages to outputs when repl starts/stops // send start/stop messages to outputs when repl starts/stops
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.addEventListener('message', (e) => { window.addEventListener('message', (e) => {
@@ -138,7 +113,7 @@ function githubPath(base, subpath = '') {
/** /**
* configures the default midimap, which is used when no "midimap" port is set * configures the default midimap, which is used when no "midimap" port is set
* @tags external_io * @tags external_io, midi
* @example * @example
* defaultmidimap({ lpf: 74 }) * defaultmidimap({ lpf: 74 })
* $: note("c a f e").midi(); * $: note("c a f e").midi();
@@ -152,7 +127,7 @@ let loadCache = {};
/** /**
* Adds midimaps to the registry. Inside each midimap, control names (e.g. lpf) are mapped to cc numbers. * Adds midimaps to the registry. Inside each midimap, control names (e.g. lpf) are mapped to cc numbers.
* @tags external_io * @tags external_io, midi
* @example * @example
* midimaps({ mymap: { lpf: 74 } }) * midimaps({ mymap: { lpf: 74 } })
* $: note("c a f e") * $: note("c a f e")
@@ -495,9 +470,9 @@ Pattern.prototype.midi = function (midiport, options = {}) {
}; };
/** /**
* Initialize a midi device * Initialize a midi input device
*/ */
async function _initialize(input) { async function _initializeInput(input) {
if (isPattern(input)) { if (isPattern(input)) {
throw new Error( throw new Error(
`[midi] Midi input cannot be a pattern. Make sure to pass device name with single quotes. Example: midin('${ `[midi] Midi input cannot be a pattern. Make sure to pass device name with single quotes. Example: midin('${
@@ -505,31 +480,38 @@ async function _initialize(input) {
}')`, }')`,
); );
} }
const initial = await enableWebMidi(); // only returns on first init const initial = await enableWebMidi(); // only returns on first init
const device = getDevice(input, WebMidi.inputs);
if (!device) { const instance = midiInputs[input] || new MidiInput(input);
throw new Error( midiInputs[input] = instance;
`[midi] Midi device "${input}" not found.. connected devices: ${getMidiDeviceNamesString(WebMidi.inputs)}`,
);
}
if (initial) { if (initial) {
const device = instance.initialDevice;
const otherInputs = WebMidi.inputs.filter((o) => o.name !== device.name); const otherInputs = WebMidi.inputs.filter((o) => o.name !== device.name);
logger( logger(
`[midi] Midi enabled! Using "${device.name}". ${ device
otherInputs?.length ? `Also available: ${getMidiDeviceNamesString(otherInputs)}` : '' ? `[midi] Midi enabled! Using "${device.name}". ${
}`, otherInputs?.length ? `Also available: ${getMidiDeviceNamesString(otherInputs)}` : ''
}`
: `[midi] Midi enabled! Waiting for device "${input}"... Currently connected devices: ${getMidiDeviceNamesString(WebMidi.inputs)}`,
); );
} }
return device;
return instance;
} }
// MIDI input wrappers, by specified input string/index
const midiInputs = {};
/** /**
* MIDI input: Opens a MIDI input port to receive MIDI control change messages. * MIDI input: Opens a MIDI input port to receive MIDI control change messages.
* *
* The output is a function that accepts a midi cc value to query as well as (optionally) a midi channel * The output is a function that accepts a midi cc value to query as well as (optionally) a midi channel
* *
* @name midin * @name midin
* @tags external_io * @tags external_io, midi
* @param {string | number} input MIDI device name or index defaulting to 0 * @param {string | number} input MIDI device name or index defaulting to 0
* @returns {function(number, number=): Pattern} A function from (cc, channel?) to a pattern. * @returns {function(number, number=): Pattern} A function from (cc, channel?) to a pattern.
* When queried, the pattern will produces the most recently received midi value (normalized to 0 to 1) * When queried, the pattern will produces the most recently received midi value (normalized to 0 to 1)
@@ -543,31 +525,10 @@ async function _initialize(input) {
* note("c a f e").s("saw") * note("c a f e").s("saw")
* .when(cc(0).gt(0), x => x.postgain(0)) * .when(cc(0).gt(0), x => x.postgain(0))
*/ */
let listeners = {};
const refs = {};
const refsByChan = {};
export async function midin(input) { export async function midin(input) {
const device = await _initialize(input); const instance = await _initializeInput(input);
refs[input] ??= {};
refsByChan[input] ??= {};
const cc = (cc, chan) => {
if (chan !== undefined) {
return ref(() => refsByChan[input][cc]?.[chan] || 0);
}
return ref(() => refs[input][cc] || 0);
};
listeners[input] && device.removeListener('midimessage', listeners[input]); return instance.createCC.bind(instance);
listeners[input] = (e) => {
const [ccNum, v] = e.dataBytes;
const chan = e.message.channel;
const scaled = v / 127;
refsByChan[input][ccNum] ??= {};
refsByChan[input][ccNum][chan] = scaled;
refs[input][ccNum] = scaled;
};
device.addListener('midimessage', listeners[input]);
return cc;
} }
/** /**
@@ -577,7 +538,7 @@ export async function midin(input) {
* note durations * note durations
* *
* @name midikeys * @name midikeys
* @tags external_io * @tags external_io, midi
* @param {string | number} input MIDI device name or index defaulting to 0 * @param {string | number} input MIDI device name or index defaulting to 0
* @returns {function((number | Pattern)=): Pattern} A function that produces a pattern. * @returns {function((number | Pattern)=): Pattern} A function that produces a pattern.
* When queried, the pattern will produces the most recently played midi notes and velocities, * When queried, the pattern will produces the most recently played midi notes and velocities,
@@ -622,7 +583,16 @@ function _triggerKeyboard(input, cps, now, latencyCycles) {
return true; return true;
} }
export async function midikeys(input) { export async function midikeys(input) {
const device = await _initialize(input); const instance = await _initializeInput(input);
// TODO: support unpluggable device usage
const device = instance.initialDevice;
if (!device) {
throw new Error(
`[midi] Midi device "${input}" not found.. connected devices: ${getMidiDeviceNamesString(WebMidi.inputs)}`,
);
}
if (!kHaps[input]) { if (!kHaps[input]) {
kHaps[input] = []; kHaps[input] = [];
} }
+44
View File
@@ -0,0 +1,44 @@
/*
util.mjs - MIDI utility functions
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/midi/midi.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 { Input, Output } from 'webmidi';
/**
* Get a string listing device names for error messages.
* @param {Input[] | Output[]} devices
* @returns {string}
*/
export function getMidiDeviceNamesString(devices) {
return devices.map((o) => `'${o.name}'`).join(' | ');
}
/**
* Look up a device by index or name. Otherwise return a default device, or fail if none are connected.
*
* @param {string | number} indexOrName
* @param {Input[] | Output[]} devices
* @returns {Input | Output | undefined}
*/
export function getDevice(indexOrName, devices) {
if (typeof indexOrName === 'number') {
return devices[indexOrName];
}
const byName = (name) => devices.find((output) => output.name.includes(name));
if (typeof indexOrName === 'string') {
return byName(indexOrName);
}
// attempt to default to first IAC device if none is specified
const IACOutput = byName('IAC');
const device = IACOutput ?? devices[0];
if (!device) {
if (!devices.length) {
throw new Error(`🔌 No MIDI devices found. Connect a device or enable IAC Driver.`);
}
throw new Error(`🔌 Default MIDI device not found. Use one of ${getMidiDeviceNamesString(devices)}`);
}
return device;
}
+1
View File
@@ -36,6 +36,7 @@
"@strudel/codemirror": "workspace:*", "@strudel/codemirror": "workspace:*",
"@strudel/core": "workspace:*", "@strudel/core": "workspace:*",
"@strudel/draw": "workspace:*", "@strudel/draw": "workspace:*",
"@strudel/edo": "workspace:*",
"@strudel/hydra": "workspace:*", "@strudel/hydra": "workspace:*",
"@strudel/midi": "workspace:*", "@strudel/midi": "workspace:*",
"@strudel/mini": "workspace:*", "@strudel/mini": "workspace:*",
+1
View File
@@ -8,6 +8,7 @@ export async function prebake() {
core, core,
import('@strudel/draw'), import('@strudel/draw'),
import('@strudel/mini'), import('@strudel/mini'),
import('@strudel/edo'),
import('@strudel/tonal'), import('@strudel/tonal'),
import('@strudel/webaudio'), import('@strudel/webaudio'),
import('@strudel/codemirror'), import('@strudel/codemirror'),
+2 -1
View File
@@ -98,6 +98,7 @@ export const connectLFO = (id, params, nodeTracker) => {
fxi = 'main', fxi = 'main',
depth = 1, depth = 1,
depthabs, depthabs,
retrig = 0,
...filteredParams ...filteredParams
} = params; } = params;
const { targetParams, paramName } = getTargetParamsForControl(control, nodeTracker[fxi], subControl); const { targetParams, paramName } = getTargetParamsForControl(control, nodeTracker[fxi], subControl);
@@ -109,7 +110,7 @@ export const connectLFO = (id, params, nodeTracker) => {
const modParams = { const modParams = {
...filteredParams, ...filteredParams,
frequency: sync !== undefined ? sync * cps : rate, frequency: sync !== undefined ? sync * cps : rate,
time: cycle / cps, time: retrig > 0.5 ? 0 : cycle / cps,
depth: depthValue, depth: depthValue,
min, min,
max, max,
+24 -1
View File
@@ -1,5 +1,5 @@
import { getBaseURL, getCommonSampleInfo } from './util.mjs'; import { getBaseURL, getCommonSampleInfo } from './util.mjs';
import { registerSound, registerWaveTable } from './index.mjs'; import { registerSound, registerWaveTable, soundMap } from './index.mjs';
import { getAudioContext } from './audioContext.mjs'; import { getAudioContext } from './audioContext.mjs';
import { import {
getADSRValues, getADSRValues,
@@ -30,6 +30,29 @@ function humanFileSize(bytes, si) {
return bytes.toFixed(1) + ' ' + units[u]; return bytes.toFixed(1) + ' ' + units[u];
} }
/**
* Returns the duration, in seconds, of the given sample.
* Has optional param `n` (for instance, the `2` in `s("casio:2")`)
*
* Note: `must` be called with await, otherwise you'll get a pending Promise object.
*
* @name getDuration,getDur
* @tag samples
* @param {string} sampleName
* @param {number} (optional) n
*
* @example
* // Set a patterns cycle length to exactly the length of the sample
* samples('github:tidalcycles/dirt-samples')
* let k = await getDuration('sax')
* s("sax").cps(1/k)
*/
export const getDuration = (s, n = 0) => {
return getSampleBufferSource({ s, n }, soundMap.get(s)[s].data.samples).then((x) => x.bufferDuration);
};
export const getDur = getDuration;
export function getSampleInfo(hapValue, bank) { export function getSampleInfo(hapValue, bank) {
const { speed = 1.0 } = hapValue; const { speed = 1.0 } = hapValue;
const { transpose, url, index, midi, label } = getCommonSampleInfo(hapValue, bank); const { transpose, url, index, midi, label } = getCommonSampleInfo(hapValue, bank);
+3 -3
View File
@@ -25,7 +25,7 @@ import {
import { map } from 'nanostores'; import { map } from 'nanostores';
import { logger } from './logger.mjs'; import { logger } from './logger.mjs';
import { connectLFO, connectEnvelope, connectBusModulator } from './modulators.mjs'; import { connectLFO, connectEnvelope, connectBusModulator } from './modulators.mjs';
import { loadBuffer } from './sampler.mjs'; import { getSampleBufferSource, loadBuffer } from './sampler.mjs';
import { getAudioContext } from './audioContext.mjs'; import { getAudioContext } from './audioContext.mjs';
import { SuperdoughAudioController } from './superdoughoutput.mjs'; import { SuperdoughAudioController } from './superdoughoutput.mjs';
import { resetSeenKeys } from './wavetable.mjs'; import { resetSeenKeys } from './wavetable.mjs';
@@ -40,7 +40,7 @@ export let maxPolyphony = DEFAULT_MAX_POLYPHONY;
* start to die out in first-in-first-out order once the max polyphony has been hit * start to die out in first-in-first-out order once the max polyphony has been hit
* *
* @name setMaxPolyphony * @name setMaxPolyphony
* @tags fx, superdough * @tags superdough
* @param {number} Max polyphony. Defaults to 128 * @param {number} Max polyphony. Defaults to 128
* @example * @example
* setMaxPolyphony(4) * setMaxPolyphony(4)
@@ -74,7 +74,7 @@ export function applyGainCurve(val) {
* quadratic, exponential, etc. rather than linear * quadratic, exponential, etc. rather than linear
* *
* @name setGainCurve * @name setGainCurve
* @tags fx, superdough * @tags amplitude, superdough
* @param {Function} function to apply to all gain values * @param {Function} function to apply to all gain values
* @example * @example
* setGainCurve((x) => x * x) // quadratic gain * setGainCurve((x) => x * x) // quadratic gain
+4 -2
View File
@@ -485,8 +485,10 @@ export function waveformN(partials, phases, type) {
if (phase !== 0) { if (phase !== 0) {
const c = Math.cos(PI2 * phase); const c = Math.cos(PI2 * phase);
const s = Math.sin(PI2 * phase); const s = Math.sin(PI2 * phase);
R = c * R - s * I; const R0 = R;
I = s * R + c * I; const I0 = I;
R = c * R0 - s * I0;
I = s * R0 + c * I0;
} }
real[n + 1] = R; real[n + 1] = R;
imag[n + 1] = I; imag[n + 1] = I;
+1 -1
View File
@@ -186,7 +186,7 @@ export function registerWaveTable(key, tables, params) {
* Loads a collection of wavetables to use with `s` * Loads a collection of wavetables to use with `s`
* *
* @name tables * @name tables
* @tags fx * @tags wavetable
*/ */
export const tables = async (url, frameLen, json, options = {}) => { export const tables = async (url, frameLen, json, options = {}) => {
if (json !== undefined) return _processTables(json, url, frameLen); if (json !== undefined) return _processTables(json, url, frameLen);
+1 -1
View File
@@ -214,7 +214,7 @@ class CoarseProcessor extends AudioWorkletProcessor {
coarse = Math.max(1, coarse); coarse = Math.max(1, coarse);
for (let n = 0; n < blockSize; n++) { for (let n = 0; n < blockSize; n++) {
for (let i = 0; i < input.length; i++) { for (let i = 0; i < input.length; i++) {
output[i][n] = n % coarse === 0 ? input[i][n] : output[i][n - 1]; output[i][n] = n % coarse < 1 ? input[i][n] : output[i][n - 1];
} }
} }
return true; return true;
+3 -3
View File
@@ -100,7 +100,7 @@ function scaleOffset(scale, offset, note) {
* - 5P = perfect fifth * - 5P = perfect fifth
* - 5d = diminished fifth * - 5d = diminished fifth
* *
* @tags music_theory * @tags tonal
* @param {string | number} amount Either number of semitones or interval string. * @param {string | number} amount Either number of semitones or interval string.
* @returns Pattern * @returns Pattern
* @memberof Pattern * @memberof Pattern
@@ -155,7 +155,7 @@ export const { transpose, trans } = register(['transpose', 'trans'], function tr
* *
* @memberof Pattern * @memberof Pattern
* @name scaleTranspose * @name scaleTranspose
* @tags music_theory * @tags tonal
* @param {offset} offset number of steps inside the scale * @param {offset} offset number of steps inside the scale
* @returns Pattern * @returns Pattern
* @synonyms scaleTrans, strans * @synonyms scaleTrans, strans
@@ -246,7 +246,7 @@ function _getNearestScaleNote(scaleName, note, preferHigher = true) {
* The root note defaults to octave 3, if no octave number is given. * The root note defaults to octave 3, if no octave number is given.
* *
* @name scale * @name scale
* @tags music_theory * @tags tonal
* @param {string} scale Name of scale * @param {string} scale Name of scale
* @returns Pattern * @returns Pattern
* @example * @example
+4 -4
View File
@@ -90,7 +90,7 @@ export const setVoicingRange = (name, range) => addVoicings(name, voicingRegistr
* Adds a new custom voicing dictionary. * Adds a new custom voicing dictionary.
* *
* @name addVoicings * @name addVoicings
* @tags music_theory * @tags tonal
* @memberof Pattern * @memberof Pattern
* @param {string} name identifier for the voicing dictionary * @param {string} name identifier for the voicing dictionary
* @param {Object} dictionary maps chord symbol to possible voicings * @param {Object} dictionary maps chord symbol to possible voicings
@@ -134,7 +134,7 @@ const getVoicing = (chord, dictionaryName, lastVoicing) => {
* Uses [chord-voicings package](https://github.com/felixroos/chord-voicings#chord-voicings). * Uses [chord-voicings package](https://github.com/felixroos/chord-voicings#chord-voicings).
* *
* @name voicings * @name voicings
* @tags music_theory * @tags tonal
* @memberof Pattern * @memberof Pattern
* @param {string} dictionary which voicing dictionary to use. * @param {string} dictionary which voicing dictionary to use.
* @returns Pattern * @returns Pattern
@@ -159,7 +159,7 @@ export const voicings = register('voicings', function (dictionary, pat) {
* Maps the chords of the incoming pattern to root notes in the given octave. * Maps the chords of the incoming pattern to root notes in the given octave.
* *
* @name rootNotes * @name rootNotes
* @tags music_theory * @tags tonal
* @memberof Pattern * @memberof Pattern
* @param {octave} octave octave to use * @param {octave} octave octave to use
* @returns Pattern * @returns Pattern
@@ -192,7 +192,7 @@ export const rootNotes = register('rootNotes', function (octave, pat) {
* If you pass a pattern of strings to voicing, they will be interpreted as chords. * If you pass a pattern of strings to voicing, they will be interpreted as chords.
* *
* @name voicing * @name voicing
* @tags music_theory * @tags tonal
* @returns Pattern * @returns Pattern
* @example * @example
* n("0 1 2 3").chord("<C Am F G>").voicing() * n("0 1 2 3").chord("<C Am F G>").voicing()
+24 -3
View File
@@ -1,10 +1,31 @@
# @strudel/transpiler # @strudel/transpiler
This package contains a JS code transpiler with the following features: This package contains a JS code transpiler with a plugin system that can be used to transform the code.
A plugin can be registered via a `registerTranspilerPlugin` call :
```js
registerTranspilerPlugin({
walk: (context) => ({
enter: function(node, parent, prop, index) {},
leave: function(node, parent, prop, index) {}
})
})
```
where the enter/leave functions (both optional) follow the `estree-walker` walk API.
4 plugins are currently hosted inside `@strudel/transpiler`
- mini: add locations of mini notation strings (double quoted or backticked) for highlighting
- widgets: add handling of sliders & draw widgets
- sample: make it possible to call a sample without await
- kabelsalat: transform the code to handle the `K(..)` kabelsalat notation
it also
- add locations of mini notation strings (double quoted or backticked) for highlighting
- converts pseudo note variables to note strings
- adds return statement to the last expression - adds return statement to the last expression
- handles label capturing for block-based eval
## Install ## Install
+13 -1
View File
@@ -1,5 +1,17 @@
/*
index.mjs - <short description TODO>
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdough.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 { evaluate as _evaluate } from '@strudel/core'; import { evaluate as _evaluate } from '@strudel/core';
import { transpiler } from './transpiler.mjs'; import { transpiler } from './transpiler.mjs';
export * from './transpiler.mjs'; export * from './transpiler.mjs';
export const evaluate = (code) => _evaluate(code, transpiler); import './plugin-kabelsalat.mjs';
import './plugin-mini.mjs';
import './plugin-sample.mjs';
import './plugin-widgets.mjs';
export { registerWidgetType, getWidgetID } from './plugin-widgets.mjs';
export const evaluate = (code, transpilerOptions) => _evaluate(code, transpiler, transpilerOptions);
+235
View File
@@ -0,0 +1,235 @@
/*
plugin-kabelsalat.mjs - <short description TODO>
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdough.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 { walk } from 'estree-walker';
import escodegen from 'escodegen';
import { getLanguages, registerTranspilerPlugin } from './transpiler.mjs';
export function genExprSource(expr) {
return escodegen.generate(expr, { format: { semicolons: false } });
}
function isStrudelPatternWrap(node) {
if (node.type !== 'CallExpression') {
return false;
}
const callee = node.callee;
if (callee.type === 'Identifier') {
return callee.name === 'S';
}
if (callee.type === 'MemberExpression' && !callee.computed) {
return callee.property?.name === 'S';
}
return false;
}
// Used to identify transpiled `m(...)` calls for proper conversion
// to, say, kabelsalat placeholders
function isMiniCall(node) {
if (node.type !== 'CallExpression') {
return false;
}
const callee = node.callee;
if (callee.type !== 'Identifier') {
return false;
}
if (callee.name !== getMinilangName()) {
return false;
}
const firstArg = node.arguments?.[0];
return firstArg?.type === 'Literal' && typeof firstArg.value === 'string';
}
function getMinilangName() {
const minilang = getLanguages().get('minilang');
return minilang?.name || 'm';
}
function replaceNode(node, replacement, parents, currentRoot) {
const info = parents.get(node);
if (!info || !info.parent) {
return replacement;
}
const { parent, prop, index } = info;
if (Array.isArray(parent[prop])) {
parent[prop][index] = replacement;
} else {
parent[prop] = replacement;
}
parents.set(replacement, { parent, prop, index });
return currentRoot;
}
// If `start` is available, we use it. If it's already been transpiled
// to `m(...)`, use the provided offset
function getPatternNodeOrder(node) {
if (typeof node.start === 'number') {
return node.start;
}
if (isMiniCall(node)) {
const offsetArg = node.arguments?.[1];
if (offsetArg?.type === 'Literal' && typeof offsetArg.value === 'number') {
return offsetArg.value;
}
}
return 0;
}
function placeholderAst(index) {
return {
type: 'MemberExpression',
object: { type: 'Identifier', name: 'pat' },
property: { type: 'Literal', value: index },
computed: true,
optional: false,
};
}
function getStrudelPatternExpr(node) {
if (isStrudelPatternWrap(node)) {
const arg = node.arguments?.[0];
if (!arg) {
throw new Error('S(...) requires an argument');
}
return arg;
}
if (isMiniCall(node)) {
return node;
}
return null;
}
function cloneNode(node) {
return JSON.parse(JSON.stringify(node));
}
export function extractPatternPlaceholders(expr) {
const templateExpr = cloneNode(expr);
const parents = new Map();
const targets = [];
walk(templateExpr, {
enter(node, parent, prop, index) {
parents.set(node, { parent, prop, index });
const patternExpr = getStrudelPatternExpr(node);
if (patternExpr) {
targets.push({ node, patternExpr });
this.skip();
}
},
});
if (!targets.length) {
return { template: genExprSource(templateExpr), patternExprs: [] };
}
targets.sort((a, b) => getPatternNodeOrder(a.node) - getPatternNodeOrder(b.node));
const patternExprs = targets.map(({ patternExpr }) => cloneNode(patternExpr));
let currentExpr = templateExpr;
targets.forEach(({ node }, index) => {
currentExpr = replaceNode(node, placeholderAst(index), parents, currentExpr);
});
const template = genExprSource(currentExpr);
return { template, patternExprs };
}
const transpilerPlugin = {
walk: (context) => ({
leave: function (node, parent, prop, index) {
if (!isKabelCall(node)) return;
let [expr, ...rest] = node.arguments;
if (!expr) throw new Error('K(...) requires an expression');
if (shouldCallKabelExpression(expr)) {
expr = {
type: 'CallExpression',
callee: expr,
arguments: [],
optional: false,
};
}
const language = 'kabelsalat';
const { template, patternExprs } = extractPatternPlaceholders(expr);
if (patternExprs.length) {
const workletArgs = [
/*{ type: 'Literal', value: language },*/
{ type: 'Literal', value: template },
...patternExprs,
...rest,
];
let callee = node.callee;
if (callee.type === 'ChainExpression') callee = callee.expression;
if (callee.type === 'MemberExpression') {
return this.replace({
type: 'CallExpression',
callee: workletMemberAst(callee.object),
arguments: workletArgs,
optional: false,
});
}
return this.replace({
type: 'CallExpression',
callee: { type: 'Identifier', name: 'worklet' },
arguments: workletArgs,
optional: false,
});
}
const kabelSrc = genExprSource(expr);
const workletArgs = [/*{ type: 'Literal', value: language },*/ { type: 'Literal', value: kabelSrc }, ...rest];
let callee = node.callee;
if (callee.type === 'ChainExpression') callee = callee.expression;
if (callee.type === 'MemberExpression') {
return this.replace({
type: 'CallExpression',
callee: workletMemberAst(callee.object),
arguments: workletArgs,
optional: false,
});
}
return this.replace({
type: 'CallExpression',
callee: { type: 'Identifier', name: 'worklet' },
arguments: workletArgs,
optional: false,
});
},
}),
};
registerTranspilerPlugin(transpilerPlugin);
function isKabelCall(node) {
if (node.type !== 'CallExpression') return false;
let callee = node.callee;
if (callee.type === 'ChainExpression') callee = callee.expression;
if (callee.type === 'MemberExpression') return !callee.computed && callee.property?.name === 'K';
return callee.type === 'Identifier' && callee.name === 'K';
}
function shouldCallKabelExpression(expr) {
if (expr.type !== 'ArrowFunctionExpression' && expr.type !== 'FunctionExpression') {
return false;
}
if (expr.params.length) {
return false;
}
return expr.body?.type === 'BlockStatement';
}
function workletMemberAst(objectExpr) {
return {
type: 'MemberExpression',
object: objectExpr,
property: { type: 'Identifier', name: 'worklet' },
computed: false,
optional: false,
};
}
+200
View File
@@ -0,0 +1,200 @@
/*
plugin-mini.mjs - <short description TODO>
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdough.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 { getLeafLocations } from '@strudel/mini';
import { getLanguages, registerTranspilerPlugin } from './transpiler.mjs';
const languageLiteral = {
walk: (context) => ({
enter: function (node, parent, prop, index) {
if (!isLanguageLiteral(node)) return;
context.miniLocations ??= [];
const { options, miniLocations } = context;
const { emitMiniLocations } = options;
const { name } = node.tag;
const language = getLanguages().get(name);
const code = node.quasi.quasis[0].value.raw;
const offset = node.quasi.start + 1;
if (emitMiniLocations) {
const locs = language.getLocations(code, offset);
miniLocations.push(...locs);
}
this.skip();
return this.replace(languageWithLocation(name, code, offset));
},
}),
};
const tidal = {
walk: (context) => ({
enter: function (node, parent, prop, index) {
if (!isTemplateLiteral(node, 'tidal')) return;
context.miniLocations ??= [];
const { options, miniLocations } = context;
const { emitMiniLocations } = options;
const raw = node.quasi.quasis[0].value.raw;
const offset = node.quasi.start + 1;
if (emitMiniLocations) {
const stringLocs = collectHaskellMiniLocations(raw, offset);
miniLocations.push(...stringLocs);
}
this.skip();
return this.replace(tidalWithLocation(raw, offset));
},
}),
};
const backtick = {
walk: (context) => ({
enter: function (node, parent, prop, index) {
if (!isBackTickString(node, parent)) return;
context.miniLocations ??= [];
const { options, input, miniDisableRanges, miniLocations } = context;
const { emitMiniLocations } = options;
if (isMiniDisabled(node.start, miniDisableRanges)) {
return;
}
const { quasis } = node;
const { raw } = quasis[0].value;
this.skip();
emitMiniLocations && collectMiniLocations(raw, node, miniLocations, input);
return this.replace(miniWithLocation(raw, node));
},
}),
};
const doublequotes = {
walk: (context) => ({
enter: function (node, parent, prop, index) {
if (!isStringWithDoubleQuotes(node)) return;
context.miniLocations ??= [];
const { options, input, miniDisableRanges, miniLocations } = context;
const { emitMiniLocations } = options;
if (isMiniDisabled(node.start, miniDisableRanges)) {
return;
}
const { value } = node;
this.skip();
emitMiniLocations && collectMiniLocations(value, node, miniLocations, input);
return this.replace(miniWithLocation(value, node));
},
}),
};
function isLanguageLiteral(node) {
return node.type === 'TaggedTemplateExpression' && getLanguages().has(node.tag.name);
}
function languageWithLocation(name, value, offset) {
return {
type: 'CallExpression',
callee: {
type: 'Identifier',
name: name,
},
arguments: [
{ type: 'Literal', value },
{ type: 'Literal', value: offset },
],
optional: false,
};
}
function collectHaskellMiniLocations(haskellCode, offset) {
return haskellCode
.split('')
.reduce((acc, char, i) => {
if (char !== '"') {
return acc;
}
if (!acc.length || acc[acc.length - 1].length > 1) {
acc.push([i + 1]);
} else {
acc[acc.length - 1].push(i);
}
return acc;
}, [])
.map(([start, end]) => {
const miniString = haskellCode.slice(start, end);
return getLeafLocations(`"${miniString}"`, offset + start - 1);
})
.flat();
}
function isTemplateLiteral(node, value) {
return node.type === 'TaggedTemplateExpression' && node.tag.name === value;
}
function tidalWithLocation(value, offset) {
return {
type: 'CallExpression',
callee: {
type: 'Identifier',
name: 'tidal',
},
arguments: [
{ type: 'Literal', value },
{ type: 'Literal', value: offset },
],
optional: false,
};
}
function isBackTickString(node, parent) {
return node.type === 'TemplateLiteral' && parent.type !== 'TaggedTemplateExpression';
}
function isMiniDisabled(offset, miniDisableRanges) {
for (const [start, end] of miniDisableRanges) {
if (offset >= start && offset < end) {
return true;
}
}
return false;
}
function miniWithLocation(value, node) {
const { start: fromOffset } = node;
const minilang = getLanguages().get('minilang');
let name = 'm';
if (minilang && minilang.name) {
name = minilang.name; // name is expected to be exported from the package of the minilang
}
return {
type: 'CallExpression',
callee: {
type: 'Identifier',
name,
},
arguments: [
{ type: 'Literal', value },
{ type: 'Literal', value: fromOffset },
],
optional: false,
};
}
const collectMiniLocations = (value, node, miniLocations, input) => {
const minilang = getLanguages().get('minilang');
if (minilang) {
const code = `[${value}]`;
const locs = minilang.getLocations(code, node.start);
miniLocations.push(...locs);
} else {
const leafLocs = getLeafLocations(`"${value}"`, node.start, input);
miniLocations.push(...leafLocs);
}
};
function isStringWithDoubleQuotes(node, locations, code) {
if (node.type !== 'Literal') {
return false;
}
return node.raw[0] === '"';
}
registerTranspilerPlugin([languageLiteral, tidal, backtick, doublequotes]);
+29
View File
@@ -0,0 +1,29 @@
/*
plugin-sample.mjs - <short description TODO>
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdough.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 { registerTranspilerPlugin } from './transpiler.mjs';
export function withAwait(node) {
return {
type: 'AwaitExpression',
argument: node,
};
}
const bareSample = {
walk: (context) => ({
enter: function (node, parent, prop, index) {
if (!isBareSamplesCall(node, parent)) return;
this.replace(withAwait(node));
},
}),
};
function isBareSamplesCall(node, parent) {
return node.type === 'CallExpression' && node.callee.name === 'samples' && parent.type !== 'AwaitExpression';
}
registerTranspilerPlugin(bareSample);
+128
View File
@@ -0,0 +1,128 @@
/*
plugin-widgets.mjs - <short description TODO>
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdough.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 { registerTranspilerPlugin } from './transpiler.mjs';
let widgetMethods = [];
export function registerWidgetType(type) {
widgetMethods.push(type);
}
const widgetTranspilerPlugin = {
walk: (context) => ({
enter: function (node, parent, prop, index) {
if (!isWidgetMethod(node)) return;
context.widgets ??= [];
const { widgets, options } = context;
const { emitWidgets } = options;
const type = node.callee.property.name;
const idx = widgets.filter((w) => w.type === type).length;
const widgetConfig = {
from: node.start,
to: node.end,
index: idx,
type,
id: options.id,
};
emitWidgets && widgets.push(widgetConfig);
return this.replace(widgetWithLocation(node, widgetConfig));
},
}),
};
const sliderTranspilerPlugin = {
walk: (context) => ({
enter: function (node, parent, prop, index) {
if (!isSliderFunction(node)) return;
context.widgets ??= [];
context.sliders ??= [];
const { options, widgets, sliders, nodeOffset } = context;
const { emitWidgets } = options;
const from = node.arguments[0].start + nodeOffset;
const to = node.arguments[0].end + nodeOffset;
const id = `${from}:${to}`; // Range-based ID for stability
const sliderConfig = {
from,
to,
id,
value: node.arguments[0].raw, // don't use value!
min: node.arguments[1]?.value ?? 0,
max: node.arguments[2]?.value ?? 1,
step: node.arguments[3]?.value,
type: 'slider',
};
emitWidgets && widgets.push(sliderConfig);
sliders.push(sliderConfig);
return this.replace(sliderWithLocation(node, nodeOffset));
},
}),
};
export const widgetTranspilerPlugins = [sliderTranspilerPlugin, widgetTranspilerPlugin];
// these functions are connected to @strudel/codemirror -> slider.mjs
// maybe someday there will be pluggable transpiler functions, then move this there
function isSliderFunction(node) {
return node.type === 'CallExpression' && node.callee.name === 'slider';
}
function isWidgetMethod(node) {
return node.type === 'CallExpression' && widgetMethods.includes(node.callee.property?.name);
}
function sliderWithLocation(node, nodeOffset = 0) {
// Apply nodeOffset for block-based evaluation to generate correct range
const from = node.arguments[0].start + nodeOffset;
const to = node.arguments[0].end + nodeOffset;
// Use range-based ID for stability during block evaluation
const id = `${from}:${to}`;
// add loc as identifier to first argument
// the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?)
node.arguments.unshift({
type: 'Literal',
value: id,
raw: id,
});
node.callee.name = 'sliderWithID';
return node;
}
export function getWidgetID(widgetConfig) {
// the widget id is used as id for the dom element + as key for eventual resources
// for example, for each scope widget, a new analyser + buffer (large) is created
// Update: use range-based ID generation for better stability during block evaluation
// When we have both from and to, use them together for stability
// Otherwise fall back to position-based ID for backward compatibility
let uniqueIdentifier;
if (widgetConfig.from !== undefined && widgetConfig.to !== undefined) {
// Use range for more stable identification
uniqueIdentifier = `${widgetConfig.from}-${widgetConfig.to}`;
} else {
// Fallback to single position (for backward compatibility)
uniqueIdentifier = widgetConfig.to || widgetConfig.from || 0;
}
const baseId = `${widgetConfig.id || ''}_widget_${widgetConfig.type}`;
return `${baseId}_${widgetConfig.index}_${uniqueIdentifier}`;
}
function widgetWithLocation(node, widgetConfig) {
const id = getWidgetID(widgetConfig);
// Store the unique ID back into the config so it's available for widget management
// This is critical for block-based evaluation to match existing widgets with new ones
widgetConfig.id = id;
// add loc as identifier to first argument
// the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?)
node.arguments.unshift({
type: 'Literal',
value: id,
raw: id,
});
return node;
}
registerTranspilerPlugin(widgetTranspilerPlugins);
+1 -1
View File
@@ -5,7 +5,7 @@ This program is free software: you can redistribute it and/or modify it under th
*/ */
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { transpiler } from '../transpiler.mjs'; import { transpiler } from '../index.mjs';
const simple = { wrapAsync: false, addReturn: false, simpleLocs: true }; const simple = { wrapAsync: false, addReturn: false, simpleLocs: true };
+223 -459
View File
@@ -1,13 +1,12 @@
import { getLeafLocations } from '@strudel/mini'; /*
transpiler.mjs - <short description TODO>
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdough.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 { parse } from 'acorn'; import { parse } from 'acorn';
import escodegen from 'escodegen'; import escodegen from 'escodegen';
import { walk } from 'estree-walker'; import { walk } from 'estree-walker';
let widgetMethods = [];
export function registerWidgetType(type) {
widgetMethods.push(type);
}
let languages = new Map(); let languages = new Map();
// config = { getLocations: (code: string, offset?: number) => number[][] } // config = { getLocations: (code: string, offset?: number) => number[][] }
// see mondough.mjs for example use // see mondough.mjs for example use
@@ -17,9 +16,31 @@ let languages = new Map();
export function registerLanguage(type, config) { export function registerLanguage(type, config) {
languages.set(type, config); languages.set(type, config);
} }
export function getLanguages() {
return languages;
}
const plugins = [];
export function registerTranspilerPlugin(plugin) {
plugins.push(plugin);
}
export function getPlugins() {
return plugins.flat(Infinity);
}
export function transpiler(input, options = {}) { export function transpiler(input, options = {}) {
const { wrapAsync = false, addReturn = true, emitMiniLocations = true, emitWidgets = true } = options; options = {
wrapAsync: false,
addReturn: true,
emitMiniLocations: true,
emitWidgets: true,
blockBased: false,
range: [],
...options,
};
const { wrapAsync, addReturn, emitMiniLocations, emitWidgets, blockBased, range } = options;
const comments = []; const comments = [];
let ast = parse(input, { let ast = parse(input, {
@@ -29,169 +50,103 @@ export function transpiler(input, options = {}) {
onComment: comments, onComment: comments,
}); });
const miniDisableRanges = findMiniDisableRanges(comments, input.length); let miniDisableRanges = findMiniDisableRanges(comments, input.length);
let miniLocations = [];
const collectMiniLocations = (value, node) => { // Position offset for block-based evaluation
const minilang = languages.get('minilang'); let nodeOffset = range && range.length > 0 ? range[0] : 0;
if (minilang) {
const code = `[${value}]`; // Track declarations to add to strudelScope for block-based eval
const locs = minilang.getLocations(code, node.start); let scopeDeclarations = [];
miniLocations = miniLocations.concat(locs);
} else { let labels = [];
const leafLocs = getLeafLocations(`"${value}"`, node.start, input);
miniLocations = miniLocations.concat(leafLocs); const context = { options, input, nodeOffset, miniDisableRanges, labels };
} const plugins = getPlugins().map((plugin) => plugin.walk?.(context));
};
let widgets = [];
walk(ast, { walk(ast, {
enter(node, parent /* , prop, index */) { enter(node, parent, prop, index) {
if (isLanguageLiteral(node)) { // Apply position offset for block-based evaluation
const { name } = node.tag; if (blockBased && node.start !== undefined) {
const language = languages.get(name); node.start = node.start + nodeOffset;
const code = node.quasi.quasis[0].value.raw; node.end = node.end + nodeOffset;
const offset = node.quasi.start + 1; }
if (emitMiniLocations) { // Collect variable and function declarations for strudelScope (block-based eval)
const locs = language.getLocations(code, offset); if (blockBased && parent?.type === 'Program') {
miniLocations = miniLocations.concat(locs); if (node.type === 'VariableDeclaration') {
for (const declarator of node.declarations) {
if (declarator.id?.name) {
scopeDeclarations.push(declarator.id.name);
}
}
} else if (node.type === 'FunctionDeclaration' && node.id?.name) {
scopeDeclarations.push(node.id.name);
} }
this.skip();
return this.replace(languageWithLocation(name, code, offset));
} }
if (isTemplateLiteral(node, 'tidal')) {
const raw = node.quasi.quasis[0].value.raw; for (const plugin of plugins) {
const offset = node.quasi.start + 1; if (!plugin?.enter?.call(this, node, parent, prop, index)) continue;
if (emitMiniLocations) { return;
const stringLocs = collectHaskellMiniLocations(raw, offset);
miniLocations = miniLocations.concat(stringLocs);
}
this.skip();
return this.replace(tidalWithLocation(raw, offset));
}
if (isBackTickString(node, parent)) {
if (isMiniDisabled(node.start, miniDisableRanges)) {
return;
}
const { quasis } = node;
const { raw } = quasis[0].value;
this.skip();
emitMiniLocations && collectMiniLocations(raw, node);
return this.replace(miniWithLocation(raw, node));
}
if (isStringWithDoubleQuotes(node)) {
if (isMiniDisabled(node.start, miniDisableRanges)) {
return;
}
const { value } = node;
this.skip();
emitMiniLocations && collectMiniLocations(value, node);
return this.replace(miniWithLocation(value, node));
}
if (isSliderFunction(node)) {
emitWidgets &&
widgets.push({
from: node.arguments[0].start,
to: node.arguments[0].end,
value: node.arguments[0].raw, // don't use value!
min: node.arguments[1]?.value ?? 0,
max: node.arguments[2]?.value ?? 1,
step: node.arguments[3]?.value,
type: 'slider',
});
return this.replace(sliderWithLocation(node));
}
if (isWidgetMethod(node)) {
const type = node.callee.property.name;
const index = widgets.filter((w) => w.type === type).length;
const widgetConfig = {
to: node.end,
index,
type,
id: options.id,
};
emitWidgets && widgets.push(widgetConfig);
return this.replace(widgetWithLocation(node, widgetConfig));
}
if (isBareSamplesCall(node, parent)) {
return this.replace(withAwait(node));
} }
if (isLabelStatement(node)) { if (isLabelStatement(node)) {
// Collect label info for block-based evaluation
// Store positions WITHOUT offset so repl can slice the transpiler output correctly
if (blockBased) {
labels.push({
name: node.label.name,
index: node.start - nodeOffset,
end: node.label.end - nodeOffset,
fullMatch: input.slice(node.start - nodeOffset, node.label.end - nodeOffset),
activeVisualizer: findVisualizerInSubtree(node.body),
});
}
return this.replace(labelToP(node)); return this.replace(labelToP(node));
} }
// Detect all() calls as special labels for block management
// Store positions WITHOUT offset so repl can slice the transpiler output correctly
if (blockBased && isAllCall(node)) {
labels.push({
name: 'all',
index: node.start - nodeOffset,
end: node.end - nodeOffset,
fullMatch: input.slice(node.start - nodeOffset, node.end - nodeOffset),
activeVisualizer: node.arguments[0] ? findVisualizerInSubtree(node.arguments[0]) : null,
});
}
}, },
leave(node, parent, prop, index) { leave(node, parent, prop, index) {
if (!isKabelCall(node)) return; for (const plugin of plugins) {
if (!plugin?.leave?.call(this, node, parent, prop, index)) continue;
let [expr, ...rest] = node.arguments; return;
if (!expr) throw new Error('K(...) requires an expression');
if (shouldCallKabelExpression(expr)) {
expr = {
type: 'CallExpression',
callee: expr,
arguments: [],
optional: false,
};
} }
const { template, patternExprs } = extractPatternPlaceholders(expr);
if (patternExprs.length) {
const workletArgs = [{ type: 'Literal', value: template }, ...patternExprs, ...rest];
let callee = node.callee;
if (callee.type === 'ChainExpression') callee = callee.expression;
if (callee.type === 'MemberExpression') {
return this.replace({
type: 'CallExpression',
callee: workletMemberAst(callee.object),
arguments: workletArgs,
optional: false,
});
}
return this.replace({
type: 'CallExpression',
callee: { type: 'Identifier', name: 'worklet' },
arguments: workletArgs,
optional: false,
});
}
const kabelSrc = genExprSource(expr);
const workletArgs = [{ type: 'Literal', value: kabelSrc }, ...rest];
let callee = node.callee;
if (callee.type === 'ChainExpression') callee = callee.expression;
if (callee.type === 'MemberExpression') {
return this.replace({
type: 'CallExpression',
callee: workletMemberAst(callee.object),
arguments: workletArgs,
optional: false,
});
}
return this.replace({
type: 'CallExpression',
callee: { type: 'Identifier', name: 'worklet' },
arguments: workletArgs,
optional: false,
});
}, },
}); });
let { body } = ast; let { body } = ast;
const silenceExpression = {
type: 'ExpressionStatement',
expression: {
type: 'Identifier',
name: 'silence',
},
};
if (!body.length) { if (!body.length) {
console.warn('empty body -> fallback to silence'); console.warn('empty body -> fallback to silence');
body.push({ body.push(silenceExpression);
type: 'ExpressionStatement',
expression: {
type: 'Identifier',
name: 'silence',
},
});
} else if (!body?.[body.length - 1]?.expression) { } else if (!body?.[body.length - 1]?.expression) {
throw new Error('unexpected ast format without body expression'); // Last statement is not an expression (e.g., VariableDeclaration, FunctionDeclaration)
body.push(silenceExpression);
}
// For block-based eval, add scope assignments before the return statement
// This allows variables/functions defined in one block to be used in other blocks
if (blockBased && scopeDeclarations.length > 0) {
const scopeAssignments = scopeDeclarations.flatMap((name) => createScopeAssignment(name));
// Insert scope assignments before the last statement (which will become the return)
body.splice(body.length - 1, 0, ...scopeAssignments);
} }
// add return to last statement // add return to last statement
@@ -209,256 +164,15 @@ export function transpiler(input, options = {}) {
if (!emitMiniLocations) { if (!emitMiniLocations) {
return { output }; return { output };
} }
return { output, miniLocations, widgets };
let pluginContext;
({ options, input, miniDisableRanges, nodeOffset, ...pluginContext } = context);
return { output, ...pluginContext };
} }
function isKabelCall(node) { function isAllCall(node) {
if (node.type !== 'CallExpression') return false; return node.type === 'CallExpression' && node.callee.name === 'all';
let callee = node.callee;
if (callee.type === 'ChainExpression') callee = callee.expression;
if (callee.type === 'MemberExpression') return !callee.computed && callee.property?.name === 'K';
return callee.type === 'Identifier' && callee.name === 'K';
}
function shouldCallKabelExpression(expr) {
if (expr.type !== 'ArrowFunctionExpression' && expr.type !== 'FunctionExpression') {
return false;
}
if (expr.params.length) {
return false;
}
return expr.body?.type === 'BlockStatement';
}
function genExprSource(expr) {
return escodegen.generate(expr, { format: { semicolons: false } });
}
function extractPatternPlaceholders(expr) {
const templateExpr = cloneNode(expr);
const parents = new Map();
const targets = [];
walk(templateExpr, {
enter(node, parent, prop, index) {
parents.set(node, { parent, prop, index });
const patternExpr = getStrudelPatternExpr(node);
if (patternExpr) {
targets.push({ node, patternExpr });
this.skip();
}
},
});
if (!targets.length) {
return { template: genExprSource(templateExpr), patternExprs: [] };
}
targets.sort((a, b) => getPatternNodeOrder(a.node) - getPatternNodeOrder(b.node));
const patternExprs = targets.map(({ patternExpr }) => cloneNode(patternExpr));
let currentExpr = templateExpr;
targets.forEach(({ node }, index) => {
currentExpr = replaceNode(node, placeholderAst(index), parents, currentExpr);
});
const template = genExprSource(currentExpr);
return { template, patternExprs };
}
function getStrudelPatternExpr(node) {
if (isStrudelPatternWrap(node)) {
const arg = node.arguments?.[0];
if (!arg) {
throw new Error('S(...) requires an argument');
}
return arg;
}
if (isMiniCall(node)) {
return node;
}
return null;
}
function isStrudelPatternWrap(node) {
if (node.type !== 'CallExpression') {
return false;
}
const callee = node.callee;
if (callee.type === 'Identifier') {
return callee.name === 'S';
}
if (callee.type === 'MemberExpression' && !callee.computed) {
return callee.property?.name === 'S';
}
return false;
}
function getMinilangName() {
const minilang = languages.get('minilang');
return minilang?.name || 'm';
}
// Used to identify transpiled `m(...)` calls for proper conversion
// to, say, kabelsalat placeholders
function isMiniCall(node) {
if (node.type !== 'CallExpression') {
return false;
}
const callee = node.callee;
if (callee.type !== 'Identifier') {
return false;
}
if (callee.name !== getMinilangName()) {
return false;
}
const firstArg = node.arguments?.[0];
return firstArg?.type === 'Literal' && typeof firstArg.value === 'string';
}
// If `start` is available, we use it. If it's already been transpiled
// to `m(...)`, use the provided offset
function getPatternNodeOrder(node) {
if (typeof node.start === 'number') {
return node.start;
}
if (isMiniCall(node)) {
const offsetArg = node.arguments?.[1];
if (offsetArg?.type === 'Literal' && typeof offsetArg.value === 'number') {
return offsetArg.value;
}
}
return 0;
}
function placeholderAst(index) {
return {
type: 'MemberExpression',
object: { type: 'Identifier', name: 'pat' },
property: { type: 'Literal', value: index },
computed: true,
optional: false,
};
}
function replaceNode(node, replacement, parents, currentRoot) {
const info = parents.get(node);
if (!info || !info.parent) {
return replacement;
}
const { parent, prop, index } = info;
if (Array.isArray(parent[prop])) {
parent[prop][index] = replacement;
} else {
parent[prop] = replacement;
}
parents.set(replacement, { parent, prop, index });
return currentRoot;
}
function cloneNode(node) {
return JSON.parse(JSON.stringify(node));
}
function workletMemberAst(objectExpr) {
return {
type: 'MemberExpression',
object: objectExpr,
property: { type: 'Identifier', name: 'worklet' },
computed: false,
optional: false,
};
}
function isStringWithDoubleQuotes(node, locations, code) {
if (node.type !== 'Literal') {
return false;
}
return node.raw[0] === '"';
}
function isBackTickString(node, parent) {
return node.type === 'TemplateLiteral' && parent.type !== 'TaggedTemplateExpression';
}
function miniWithLocation(value, node) {
const { start: fromOffset } = node;
const minilang = languages.get('minilang');
let name = 'm';
if (minilang && minilang.name) {
name = minilang.name; // name is expected to be exported from the package of the minilang
}
return {
type: 'CallExpression',
callee: {
type: 'Identifier',
name,
},
arguments: [
{ type: 'Literal', value },
{ type: 'Literal', value: fromOffset },
],
optional: false,
};
}
// these functions are connected to @strudel/codemirror -> slider.mjs
// maybe someday there will be pluggable transpiler functions, then move this there
function isSliderFunction(node) {
return node.type === 'CallExpression' && node.callee.name === 'slider';
}
function isWidgetMethod(node) {
return node.type === 'CallExpression' && widgetMethods.includes(node.callee.property?.name);
}
function sliderWithLocation(node) {
const id = 'slider_' + node.arguments[0].start; // use loc of first arg for id
// add loc as identifier to first argument
// the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?)
node.arguments.unshift({
type: 'Literal',
value: id,
raw: id,
});
node.callee.name = 'sliderWithID';
return node;
}
export function getWidgetID(widgetConfig) {
// the widget id is used as id for the dom element + as key for eventual resources
// for example, for each scope widget, a new analyser + buffer (large) is created
// that means, if we use the index index of line position as id, less garbage is generated
// return `widget_${widgetConfig.to}`; // more gargabe
//return `widget_${widgetConfig.index}_${widgetConfig.to}`; // also more garbage
return `${widgetConfig.id || ''}_widget_${widgetConfig.type}_${widgetConfig.index}`; // less garbage
}
function widgetWithLocation(node, widgetConfig) {
const id = getWidgetID(widgetConfig);
// add loc as identifier to first argument
// the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?)
node.arguments.unshift({
type: 'Literal',
value: id,
raw: id,
});
return node;
}
function isBareSamplesCall(node, parent) {
return node.type === 'CallExpression' && node.callee.name === 'samples' && parent.type !== 'AwaitExpression';
}
function withAwait(node) {
return {
type: 'AwaitExpression',
argument: node,
};
} }
function isLabelStatement(node) { function isLabelStatement(node) {
@@ -491,67 +205,126 @@ function labelToP(node) {
}; };
} }
function isLanguageLiteral(node) { // List of non-inline widgets that need cleanup
return node.type === 'TaggedTemplateExpression' && languages.has(node.tag.name); // These are Pattern.prototype methods that create persistent visualizations
// (should be repalced by a function call producing an actual list of registered widgets)
const nonInlineWidgets = ['punchcard', 'spiral', 'scope', 'pitchwheel', 'spectrum', 'pianoroll', 'wordfall'];
function isVisualizerCall(node) {
if (
node.type === 'CallExpression' &&
node.callee.type === 'MemberExpression' &&
nonInlineWidgets.includes(node.callee.property?.name)
) {
return node.callee.property.name;
}
return null;
} }
// tidal highlighting function findVisualizerInSubtree(node) {
// this feels kind of stupid, when we also know the location inside the string op (tidal.mjs) if (!node || typeof node !== 'object') return null;
// but maybe it's the only way
function isTemplateLiteral(node, value) { // Check if this node is a visualizer call
return node.type === 'TaggedTemplateExpression' && node.tag.name === value; const viz = isVisualizerCall(node);
} if (viz) return viz;
function collectHaskellMiniLocations(haskellCode, offset) { // Recursively search children
return haskellCode for (const key of Object.keys(node)) {
.split('') if (key === 'parent') continue; // Skip parent references to avoid cycles
.reduce((acc, char, i) => { const child = node[key];
if (char !== '"') { if (Array.isArray(child)) {
return acc; for (const item of child) {
const found = findVisualizerInSubtree(item);
if (found) return found;
} }
if (!acc.length || acc[acc.length - 1].length > 1) { } else if (child && typeof child === 'object' && child.type) {
acc.push([i + 1]); const found = findVisualizerInSubtree(child);
} else { if (found) return found;
acc[acc.length - 1].push(i); }
} }
return acc; return null;
}, [])
.map(([start, end]) => {
const miniString = haskellCode.slice(start, end);
return getLeafLocations(`"${miniString}"`, offset + start - 1);
})
.flat();
} }
function tidalWithLocation(value, offset) { // Creates AST nodes for: userDefinedKeys.add('name'); strudelScope.name = name; globalThis.name = name;
return { // Used in block-based evaluation to persist variables/functions across blocks
type: 'CallExpression', // We add to both strudelScope (for internal lookups) and globalThis (for direct access)
callee: { // We also track the key in userDefinedKeys so clearScope() can remove it later
type: 'Identifier', function createScopeAssignment(name) {
name: 'tidal', return [
// userDefinedKeys.add('name');
{
type: 'ExpressionStatement',
expression: {
type: 'CallExpression',
callee: {
type: 'MemberExpression',
object: {
type: 'Identifier',
name: 'userDefinedKeys',
},
property: {
type: 'Identifier',
name: 'add',
},
computed: false,
},
arguments: [
{
type: 'Literal',
value: name,
},
],
},
}, },
arguments: [ // strudelScope.name = name;
{ type: 'Literal', value }, {
{ type: 'Literal', value: offset }, type: 'ExpressionStatement',
], expression: {
optional: false, type: 'AssignmentExpression',
}; operator: '=',
} left: {
type: 'MemberExpression',
function languageWithLocation(name, value, offset) { object: {
return { type: 'Identifier',
type: 'CallExpression', name: 'strudelScope',
callee: { },
type: 'Identifier', property: {
name: name, type: 'Identifier',
name: name,
},
computed: false,
},
right: {
type: 'Identifier',
name: name,
},
},
}, },
arguments: [ // globalThis.name = name;
{ type: 'Literal', value }, {
{ type: 'Literal', value: offset }, type: 'ExpressionStatement',
], expression: {
optional: false, type: 'AssignmentExpression',
}; operator: '=',
left: {
type: 'MemberExpression',
object: {
type: 'Identifier',
name: 'globalThis',
},
property: {
type: 'Identifier',
name: name,
},
computed: false,
},
right: {
type: 'Identifier',
name: name,
},
},
},
];
} }
function findMiniDisableRanges(comments, codeEnd) { function findMiniDisableRanges(comments, codeEnd) {
@@ -573,12 +346,3 @@ function findMiniDisableRanges(comments, codeEnd) {
} }
return ranges; return ranges;
} }
function isMiniDisabled(offset, miniDisableRanges) {
for (const [start, end] of miniDisableRanges) {
if (offset >= start && offset < end) {
return true;
}
}
return false;
}
+1
View File
@@ -34,6 +34,7 @@
"homepage": "https://codeberg.org/uzu/strudel#readme", "homepage": "https://codeberg.org/uzu/strudel#readme",
"dependencies": { "dependencies": {
"@strudel/core": "workspace:*", "@strudel/core": "workspace:*",
"@strudel/edo": "workspace:*",
"@strudel/mini": "workspace:*", "@strudel/mini": "workspace:*",
"@strudel/tonal": "workspace:*", "@strudel/tonal": "workspace:*",
"@strudel/transpiler": "workspace:*", "@strudel/transpiler": "workspace:*",
+2
View File
@@ -3,6 +3,7 @@ export * from '@strudel/webaudio';
//export * from '@strudel/soundfonts'; //export * from '@strudel/soundfonts';
export * from '@strudel/transpiler'; export * from '@strudel/transpiler';
export * from '@strudel/mini'; export * from '@strudel/mini';
export * from '@strudel/edo';
export * from '@strudel/tonal'; export * from '@strudel/tonal';
export * from '@strudel/webaudio'; export * from '@strudel/webaudio';
import { Pattern, evalScope, setTime } from '@strudel/core'; import { Pattern, evalScope, setTime } from '@strudel/core';
@@ -17,6 +18,7 @@ export async function defaultPrebake() {
evalScope, evalScope,
import('@strudel/core'), import('@strudel/core'),
import('@strudel/mini'), import('@strudel/mini'),
import('@strudel/edo'),
import('@strudel/tonal'), import('@strudel/tonal'),
import('@strudel/webaudio'), import('@strudel/webaudio'),
{ hush, evaluate }, { hush, evaluate },
+38 -1
View File
@@ -7,14 +7,51 @@ This program is free software: you can redistribute it and/or modify it under th
import Tune from './tunejs.js'; import Tune from './tunejs.js';
import { register } from '@strudel/core'; import { register } from '@strudel/core';
/**
* 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
* @example
* i("0 1 2 3 4 5").tune("hexany15").mul("220").freq()
* @example
* // You can set your root to be a
* // particular note with getFreq:
* i("4 8 9 10 - - 5 7 9 11 - -").tune("tranh3")
* .mul(getFreq('c3'))
* .freq().clip(.5).room(1)
* @example
* // You can also give tune a list of
* // frequencies to use as the scale:
* i("0 1 2 3 4").tune([
* 261.6255653006,
* 302.72962012827,
* 350.29154279212,
* 405.32593044476,
* 469.00678383895,
* 523.2511306012
* ]).mul(220).freq();
*
* @tags tonal
*/
// Tune.scale seems to be in ratio format
export const tune = register('tune', (scale, pat) => { export const tune = register('tune', (scale, pat) => {
const tune = new Tune(); const tune = new Tune();
if (!tune.isValidScale(scale)) { if (!tune.isValidScale(scale)) {
throw new Error('not a valid tune.js scale name: "' + scale + '". See http://abbernie.github.io/tune/scales.html'); throw new Error('not a valid tune.js scale name: "' + scale + '". See http://abbernie.github.io/tune/scales.html');
} }
tune.loadScale(scale); tune.loadScale(scale);
// if the tonic is a frequency, why are we putting in "1"
tune.tonicize(1); tune.tonicize(1);
return pat.withHap((hap) => { return pat.withHap((hap) => {
return hap.withValue(() => tune.note(hap.value)); if (typeof hap.value !== 'object') {
throw new Error(`Expected hap to have control 'i' set, but received ${hap.value.i}, try wrapping input in i()`);
}
// const { i, ...otherValues } = hap.value;
// hap.value = { ...otherValues, freq: tune.note(i)}
// return hap
return hap.withValue(() => tune.note(hap.value.i));
}); });
}); });
+14 -4
View File
@@ -78,14 +78,21 @@ Tune.prototype.frequency = function(stepIn, octaveIn) {
} }
// which scale degree (0 - scale length) is our input // which scale degree (0 - scale length) is our input
// 60 % 12 = 0
var scaleDegree = stepIn % this.scale.length var scaleDegree = stepIn % this.scale.length
// what's this doing
// 0 scaleDegree = 12
// seems to simply be for a negative result from above, maybe another way to do it, but ok for now
while (scaleDegree < 0) { while (scaleDegree < 0) {
scaleDegree += this.scale.length scaleDegree += this.scale.length
} }
// tonic is currently always 1
// so this is 1*scale[scaledegree]
var freq = this.tonic*this.scale[scaleDegree] var freq = this.tonic*this.scale[scaleDegree]
// map it to octave
freq = freq*(Math.pow(2,octave)) freq = freq*(Math.pow(2,octave))
// truncate irrational numbers // truncate irrational numbers
@@ -137,10 +144,13 @@ Tune.prototype.MIDI = function(stepIn,octaveIn) {
} }
/* Load a new scale */ /* Load a new scale */
// sets .scale to ratios
Tune.prototype.loadScale = function(scale){ Tune.prototype.loadScale = function(scale){
/* load the scale */ /* load the scale */
let name
if (typeof scale === 'string') name = scale;
var freqs = isArrayOfNumbers(scale) ? scale : TuningList[scale].frequencies var freqs = isArrayOfNumbers(scale) ? scale : TuningList[scale].frequencies
this.scale = [] this.scale = []
for (var i=0;i<freqs.length-1;i++) { for (var i=0;i<freqs.length-1;i++) {
@@ -148,10 +158,10 @@ Tune.prototype.loadScale = function(scale){
} }
/* visualize in console */ /* visualize in console */
/* console.log(" "); // console.log(" ");
console.log("LOADED "+name); // console.log("LOADED "+name);
console.log(TuningList[name].description); // console.log(TuningList[name].description);
console.log(this.scale); */ // console.log(this.scale);
var vis = []; var vis = [];
for (var i=0;i<100;i++) { for (var i=0;i<100;i++) {
vis[i] = " "; vis[i] = " ";
+176 -9
View File
@@ -4,8 +4,10 @@ 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/>. 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 } from '@strudel/core'; import { register, _mod, parseNumeral, removeUndefineds } from '@strudel/core';
import Tune from './tunejs.js';
// returns a list of frequency ratios for given edo scale
export function edo(name) { export function edo(name) {
if (!/^[1-9]+[0-9]*edo$/.test(name)) { if (!/^[1-9]+[0-9]*edo$/.test(name)) {
throw new Error('not an edo scale: "' + name + '"'); throw new Error('not an edo scale: "' + name + '"');
@@ -18,23 +20,33 @@ const presets = {
'12ji': [1 / 1, 16 / 15, 9 / 8, 6 / 5, 5 / 4, 4 / 3, 45 / 32, 3 / 2, 8 / 5, 5 / 3, 16 / 9, 15 / 8], '12ji': [1 / 1, 16 / 15, 9 / 8, 6 / 5, 5 / 4, 4 / 3, 45 / 32, 3 / 2, 8 / 5, 5 / 3, 16 / 9, 15 / 8],
}; };
function withBase(freq, scale) { // Given a base frequency such as 220 and an edo scale, returns
// an array of frequencies representing the given edo scale in that base
function _withBase(freq, scale) {
return scale.map((r) => r * freq); return scale.map((r) => r * freq);
} }
const defaultBase = 220; const defaultBase = 220;
const isEdo = (scale) => /^[1-9]+[0-9]*edo$/.test(scale);
// Assumes a base of 220. Returns a filtered scale based on 'indices'
// NOTE: indices functionality is unused
function getXenScale(scale, indices) { function getXenScale(scale, indices) {
let tune = new Tune();
if (typeof scale === 'string') { if (typeof scale === 'string') {
if (/^[1-9]+[0-9]*edo$/.test(scale)) { if (isEdo(scale)) {
scale = edo(scale); scale = edo(scale);
} else if (presets[scale]) { } else if (presets[scale]) {
scale = presets[scale]; scale = presets[scale];
} else if (tune.isValidScale(scale)) {
tune.loadScale(scale);
scale = tune.scale;
} else { } else {
throw new Error('unknown scale name: "' + scale + '"'); throw new Error('unknown scale name: "' + scale + '"');
} }
} }
scale = withBase(defaultBase, scale); scale = _withBase(defaultBase, scale);
if (!indices) { if (!indices) {
return scale; return scale;
} }
@@ -47,16 +59,171 @@ function xenOffset(xenScale, offset, index = 0) {
return xenScale[i] * Math.pow(2, oct); return xenScale[i] * Math.pow(2, oct);
} }
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 // 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.
*
* @name xen
* @returns Pattern
* @memberof Pattern
* @param {(string | number[] )} scaleNameOrRatios
* @tags tonal
* @example
* // A minor triad in 31edo:
* i("0 8 18").xen("31edo").piano()
* @example
* // You can also use xen with frequency ratios.
* // This is equivalent to the above:
* i("0 1 2").xen([
* Math.pow(2, 0/31),
* Math.pow(2, 8/31),
* Math.pow(2, 18/31),
* ]).piano()
* @example
* // xen also supports all scale names that
* // tune does:
* i("0 1 2 3 4 5").xen("hexany15")
* // equiv to:
* // "0 1 2 3 4 5".tune("hexany15").mul("220").freq()
* @example
* i("0 1 2 3 4 5 6 7").xen("<5edo 10edo 15edo hexany15>")
*/
export const xen = register('xen', function (scaleNameOrRatios, pat) { export const xen = register('xen', function (scaleNameOrRatios, pat) {
return pat.withHap((hap) => { return pat.withHaps((haps) => {
const scale = getXenScale(scaleNameOrRatios); haps = haps.map((hap) => {
const frequency = xenOffset(scale, parseNumeral(hap.value)); let hVal = hap.value;
return hap.withValue(() => frequency); 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 tuning = register('tuning', function (ratios, pat) { /**
* Assumes pattern of frequencies tuned to some `base` frequency, such as the output of `xen`
* Because `xen` defaults to `220Hz`, so will `withBase`.
* but you can specify a different original base with the standard optional array syntax '`:`'
* @name withBase
* @param {number} base
* @param {number} (optional) originalBase
* @tags tonal
*
* @example
* i("[0 1 2 3] [3 4] [4 3 2 1]").xen("hexany23").withBase("<220 [300 200]>")
* @example
* mini([1 / 1, 16 / 15, 9 / 8, 6 / 5, 5 / 4].join(' ')).withBase("220:1")
* // mini([1 / 1, 16 / 15, 9 / 8, 6 / 5, 5 / 4].join(' ')).mul(220).freq()
*
* @returns Pattern
*/
export const withBase = register('withBase', (b, pat) => {
let base;
let originalBase = 220;
if (Array.isArray(b)) {
base = b[0];
originalBase = b[1];
} else {
base = b;
}
return pat.withHaps((haps) => {
haps = haps.map((hap) => {
let hVal = hap.value;
const isObject = typeof hVal === 'object';
let freq = isObject ? hVal.freq : hVal;
freq = (freq * base) / originalBase;
hap.value = isObject ? { ...hap.value, freq } : { freq };
return hap;
});
return removeUndefineds(haps);
});
});
/**
* Frequency transpose. Assumes pattern either has `freq` set, or has values that can be interpreted as frequencies
* amt has optional `edoSize` param, defaults to 12.
* If haps have edoSize param set, such as from the output of `xen("31edo")`,
* `ftrans` will fallback to that instead of 12 as the default.
*
* Transposes the frequency by `amt` edoSteps
* @name ftranspose
* @synonyms ftrans, fTrans, ftranspose, fTranspose
* @tags tonal
* @param {number} amt
* @param {number} edoSize (optional)
* @returns {Pattern}
*
* @example
* i("0 1 2").xen("12edo").ftrans("7")
* // n("0 1 2").scale("A:chromatic").trans("7")
* @example
* i("0 8 18").xen("31edo").ftrans("<8 -8>")
* @example
* // to transpose by steps of an edo, use "step:edo" :
* i("0 7 8 18").xen("31edo").ftrans("<0 1:31 1:12>")
* @example
* // it can also work with frequency values directly
* freq("200 300 400").ftrans("<0 7:31 7>")
*/
/* f = frequency (Hz)
n = edo (steps per octave)
x = number of steps
if 0\n = f, then x\n = f * 2^(x/n)
example: 5edo, 0\5 = 220 Hz, then 2\5 = 220*2^(2/5) = 290.29 Hz */
export const { ftrans, fTrans, ftranspose, fTranspose } = register(
['ftrans', 'fTrans', 'ftranspose', 'fTranspose'],
(amt, pat) => {
let edoSize;
let numSteps;
if (Array.isArray(amt)) {
edoSize = amt[1];
numSteps = amt[0];
} else {
numSteps = amt;
}
return pat.withHaps((haps) => {
haps = haps.map((hap) => {
let hVal = hap.value;
const isObject = typeof hVal === 'object';
hVal = isObject ? hVal : { freq: hVal };
let { freq, ...otherValues } = hVal;
if (edoSize == undefined && hap.context.edoSize != undefined) {
edoSize = hap.context.edoSize;
} else if (edoSize == undefined) {
edoSize = 12;
}
freq = freq * Math.pow(2, numSteps / edoSize);
freq = trimFreq(freq);
hap.value = isObject ? { ...otherValues, freq } : freq;
return hap.setContext({ ...hap.context, edoSize });
});
return removeUndefineds(haps);
});
},
);
// not sure there's a point to having this and the above, seems like a proto version of the above.
const tuning = register('tuning', function (ratios, pat) {
return pat.withHap((hap) => { return pat.withHap((hap) => {
const frequency = xenOffset(ratios, parseNumeral(hap.value)); const frequency = xenOffset(ratios, parseNumeral(hap.value));
return hap.withValue(() => frequency); return hap.withValue(() => frequency);
+103 -7
View File
@@ -4,6 +4,30 @@ settings:
autoInstallPeers: true autoInstallPeers: true
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
catalogs:
default:
'@codemirror/autocomplete':
specifier: ^6.18.4
version: 6.18.4
'@codemirror/commands':
specifier: ^6.8.0
version: 6.8.0
'@codemirror/lang-javascript':
specifier: ^6.2.2
version: 6.2.2
'@codemirror/language':
specifier: ^6.10.8
version: 6.10.8
'@codemirror/search':
specifier: ^6.5.8
version: 6.5.8
'@codemirror/state':
specifier: ^6.5.1
version: 6.5.1
'@codemirror/view':
specifier: ^6.36.2
version: 6.36.2
importers: importers:
.: .:
@@ -11,6 +35,9 @@ importers:
'@strudel/core': '@strudel/core':
specifier: workspace:* specifier: workspace:*
version: link:packages/core version: link:packages/core
'@strudel/edo':
specifier: workspace:*
version: link:packages/edo
'@strudel/mini': '@strudel/mini':
specifier: workspace:* specifier: workspace:*
version: link:packages/mini version: link:packages/mini
@@ -93,6 +120,9 @@ importers:
'@strudel/draw': '@strudel/draw':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/draw version: link:../../packages/draw
'@strudel/edo':
specifier: workspace:*
version: link:../../packages/edo
'@strudel/mini': '@strudel/mini':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/mini version: link:../../packages/mini
@@ -171,25 +201,25 @@ importers:
packages/codemirror: packages/codemirror:
dependencies: dependencies:
'@codemirror/autocomplete': '@codemirror/autocomplete':
specifier: ^6.18.4 specifier: 'catalog:'
version: 6.18.4 version: 6.18.4
'@codemirror/commands': '@codemirror/commands':
specifier: ^6.8.0 specifier: 'catalog:'
version: 6.8.0 version: 6.8.0
'@codemirror/lang-javascript': '@codemirror/lang-javascript':
specifier: ^6.2.2 specifier: 'catalog:'
version: 6.2.2 version: 6.2.2
'@codemirror/language': '@codemirror/language':
specifier: ^6.10.8 specifier: 'catalog:'
version: 6.10.8 version: 6.10.8
'@codemirror/search': '@codemirror/search':
specifier: ^6.5.8 specifier: 'catalog:'
version: 6.5.8 version: 6.5.8
'@codemirror/state': '@codemirror/state':
specifier: ^6.5.1 specifier: 'catalog:'
version: 6.5.1 version: 6.5.1
'@codemirror/view': '@codemirror/view':
specifier: ^6.36.2 specifier: 'catalog:'
version: 6.36.2 version: 6.36.2
'@lezer/highlight': '@lezer/highlight':
specifier: ^1.2.1 specifier: ^1.2.1
@@ -221,6 +251,9 @@ importers:
'@tonaljs/tonal': '@tonaljs/tonal':
specifier: ^4.10.0 specifier: ^4.10.0
version: 4.10.0 version: 4.10.0
codemirror-helix:
specifier: ^0.5.0
version: 0.5.0(@codemirror/commands@6.8.0)(@codemirror/language@6.10.8)(@codemirror/search@6.5.8)(@codemirror/state@6.5.1)(@codemirror/view@6.36.2)
nanostores: nanostores:
specifier: ^0.11.3 specifier: ^0.11.3
version: 0.11.3 version: 0.11.3
@@ -280,6 +313,28 @@ importers:
specifier: ^6.0.11 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) 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/edo:
dependencies:
'@strudel/core':
specifier: workspace:*
version: link:../core
'@tonaljs/tonal':
specifier: ^4.10.0
version: 4.10.0
chord-voicings:
specifier: ^0.0.1
version: 0.0.1
webmidi:
specifier: ^3.1.12
version: 3.1.12
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)
vitest:
specifier: ^3.0.4
version: 3.0.4(@types/debug@4.1.12)(@types/node@22.10.10)(@vitest/ui@3.0.4)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.7.0)
packages/embed: {} packages/embed: {}
packages/gamepad: packages/gamepad:
@@ -446,6 +501,9 @@ importers:
'@strudel/draw': '@strudel/draw':
specifier: workspace:* specifier: workspace:*
version: link:../draw version: link:../draw
'@strudel/edo':
specifier: workspace:*
version: link:../edo
'@strudel/hydra': '@strudel/hydra':
specifier: workspace:* specifier: workspace:*
version: link:../hydra version: link:../hydra
@@ -621,6 +679,9 @@ importers:
'@strudel/core': '@strudel/core':
specifier: workspace:* specifier: workspace:*
version: link:../core version: link:../core
'@strudel/edo':
specifier: workspace:*
version: link:../edo
'@strudel/mini': '@strudel/mini':
specifier: workspace:* specifier: workspace:*
version: link:../mini version: link:../mini
@@ -702,6 +763,21 @@ importers:
'@astrojs/tailwind': '@astrojs/tailwind':
specifier: ^5.1.5 specifier: ^5.1.5
version: 5.1.5(astro@5.1.9(@types/node@22.10.10)(jiti@2.4.2)(lightningcss@1.29.1)(rollup@2.79.2)(terser@5.37.0)(typescript@5.7.3)(yaml@2.7.0))(tailwindcss@3.4.17) version: 5.1.5(astro@5.1.9(@types/node@22.10.10)(jiti@2.4.2)(lightningcss@1.29.1)(rollup@2.79.2)(terser@5.37.0)(typescript@5.7.3)(yaml@2.7.0))(tailwindcss@3.4.17)
'@codemirror/commands':
specifier: 'catalog:'
version: 6.8.0
'@codemirror/lang-javascript':
specifier: 'catalog:'
version: 6.2.2
'@codemirror/language':
specifier: 'catalog:'
version: 6.10.8
'@codemirror/state':
specifier: 'catalog:'
version: 6.5.1
'@codemirror/view':
specifier: 'catalog:'
version: 6.36.2
'@docsearch/css': '@docsearch/css':
specifier: ^3.8.3 specifier: ^3.8.3
version: 3.8.3 version: 3.8.3
@@ -735,6 +811,9 @@ importers:
'@strudel/draw': '@strudel/draw':
specifier: workspace:* specifier: workspace:*
version: link:../packages/draw version: link:../packages/draw
'@strudel/edo':
specifier: workspace:*
version: link:../packages/edo
'@strudel/gamepad': '@strudel/gamepad':
specifier: workspace:* specifier: workspace:*
version: link:../packages/gamepad version: link:../packages/gamepad
@@ -3551,6 +3630,15 @@ packages:
resolution: {integrity: sha512-FMabTRlc5t5zjdenF6mS0MBeFZm0XqHqeOkcskKFb/LYCcRQ5fVgLOHVc4Lq9CqABd9zhjwPjMBCJvMCziSVtA==} resolution: {integrity: sha512-FMabTRlc5t5zjdenF6mS0MBeFZm0XqHqeOkcskKFb/LYCcRQ5fVgLOHVc4Lq9CqABd9zhjwPjMBCJvMCziSVtA==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
codemirror-helix@0.5.0:
resolution: {integrity: sha512-hI56hf9VGz53H1YvL6H1GC7HtP6te8vX+MsIHaE9J7Q3PQ6KFapKtIRg6lqSH898ikHWpMCPu42r6HJN0IfVLA==}
peerDependencies:
'@codemirror/commands': ^6.0.0
'@codemirror/language': ^6.0.0
'@codemirror/search': ^6.0.0
'@codemirror/state': ^6.0.0
'@codemirror/view': ^6.0.0
collapse-white-space@2.1.0: collapse-white-space@2.1.0:
resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==}
@@ -11280,6 +11368,14 @@ snapshots:
cmd-shim@6.0.3: {} cmd-shim@6.0.3: {}
codemirror-helix@0.5.0(@codemirror/commands@6.8.0)(@codemirror/language@6.10.8)(@codemirror/search@6.5.8)(@codemirror/state@6.5.1)(@codemirror/view@6.36.2):
dependencies:
'@codemirror/commands': 6.8.0
'@codemirror/language': 6.10.8
'@codemirror/search': 6.5.8
'@codemirror/state': 6.5.1
'@codemirror/view': 6.36.2
collapse-white-space@2.1.0: {} collapse-white-space@2.1.0: {}
color-convert@2.0.1: color-convert@2.0.1:
+9
View File
@@ -10,3 +10,12 @@ onlyBuiltDependencies:
- sharp - sharp
- tree-sitter - tree-sitter
- tree-sitter-haskell - tree-sitter-haskell
catalog:
"@codemirror/commands": "^6.8.0"
"@codemirror/language": "^6.10.8"
"@codemirror/lang-javascript": "^6.2.2"
"@codemirror/state": "^6.5.1"
"@codemirror/view": "^6.36.2"
"@codemirror/autocomplete": "^6.18.4"
"@codemirror/search": "^6.5.8"
+701 -9
View File
@@ -1037,6 +1037,43 @@ exports[`runs examples > example "anchor" example index 0 1`] = `
] ]
`; `;
exports[`runs examples > example "anchor" example index 1 1`] = `
[
"[ 0/1 → 1/8 | anchor:c4 note:60 ]",
"[ 1/8 → 1/4 | anchor:c4 note:62 ]",
"[ 1/4 → 3/8 | anchor:c4 note:64 ]",
"[ 3/8 → 1/2 | anchor:c4 note:65 ]",
"[ 1/2 → 5/8 | anchor:c4 note:67 ]",
"[ 5/8 → 3/4 | anchor:c4 note:69 ]",
"[ 3/4 → 7/8 | anchor:c4 note:71 ]",
"[ 7/8 → 1/1 | anchor:c4 note:72 ]",
"[ 1/1 → 9/8 | anchor:g4 note:67 ]",
"[ 9/8 → 5/4 | anchor:g4 note:68 ]",
"[ 5/4 → 11/8 | anchor:g4 note:70 ]",
"[ 11/8 → 3/2 | anchor:g4 note:72 ]",
"[ 3/2 → 13/8 | anchor:g4 note:73 ]",
"[ 13/8 → 7/4 | anchor:g4 note:75 ]",
"[ 7/4 → 15/8 | anchor:g4 note:77 ]",
"[ 15/8 → 2/1 | anchor:g4 note:79 ]",
"[ 2/1 → 17/8 | anchor:c5 note:72 ]",
"[ 17/8 → 9/4 | anchor:c5 note:74 ]",
"[ 9/4 → 19/8 | anchor:c5 note:76 ]",
"[ 19/8 → 5/2 | anchor:c5 note:77 ]",
"[ 5/2 → 21/8 | anchor:c5 note:79 ]",
"[ 21/8 → 11/4 | anchor:c5 note:81 ]",
"[ 11/4 → 23/8 | anchor:c5 note:83 ]",
"[ 23/8 → 3/1 | anchor:c5 note:84 ]",
"[ 3/1 → 25/8 | anchor:g5 note:79 ]",
"[ 25/8 → 13/4 | anchor:g5 note:80 ]",
"[ 13/4 → 27/8 | anchor:g5 note:82 ]",
"[ 27/8 → 7/2 | anchor:g5 note:84 ]",
"[ 7/2 → 29/8 | anchor:g5 note:85 ]",
"[ 29/8 → 15/4 | anchor:g5 note:87 ]",
"[ 15/4 → 31/8 | anchor:g5 note:89 ]",
"[ 31/8 → 4/1 | anchor:g5 note:91 ]",
]
`;
exports[`runs examples > example "apply" example index 0 1`] = ` exports[`runs examples > example "apply" example index 0 1`] = `
[ [
"[ 0/1 → 1/1 | note:C3 ]", "[ 0/1 → 1/1 | note:C3 ]",
@@ -2975,15 +3012,6 @@ exports[`runs examples > example "delayfeedback" example index 0 1`] = `
] ]
`; `;
exports[`runs examples > example "delayfeedback" example index 0 2`] = `
[
"[ 0/1 → 1/1 | s:bd delay:0.25 delayfeedback:0.25 ]",
"[ 1/1 → 2/1 | s:bd delay:0.25 delayfeedback:0.5 ]",
"[ 2/1 → 3/1 | s:bd delay:0.25 delayfeedback:0.75 ]",
"[ 3/1 → 4/1 | s:bd delay:0.25 delayfeedback:1 ]",
]
`;
exports[`runs examples > example "delayspeed" example index 0 1`] = ` exports[`runs examples > example "delayspeed" example index 0 1`] = `
[ [
"[ 0/1 → 1/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]", "[ 0/1 → 1/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]",
@@ -3034,6 +3062,43 @@ exports[`runs examples > example "delaysync" example index 0 1`] = `
] ]
`; `;
exports[`runs examples > example "delaytime" example index 0 1`] = `
[
"[ 0/1 → 1/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]",
"[ 1/8 → 1/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]",
"[ 1/4 → 3/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]",
"[ 3/8 → 1/2 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]",
"[ 1/2 → 5/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]",
"[ 5/8 → 3/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]",
"[ 3/4 → 7/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]",
"[ 7/8 → 1/1 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]",
"[ 1/1 → 9/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]",
"[ 9/8 → 5/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]",
"[ 5/4 → 11/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]",
"[ 11/8 → 3/2 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]",
"[ 3/2 → 13/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]",
"[ 13/8 → 7/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]",
"[ 7/4 → 15/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]",
"[ 15/8 → 2/1 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]",
"[ 2/1 → 17/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]",
"[ 17/8 → 9/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]",
"[ 9/4 → 19/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]",
"[ 19/8 → 5/2 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]",
"[ 5/2 → 21/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]",
"[ 21/8 → 11/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]",
"[ 11/4 → 23/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]",
"[ 23/8 → 3/1 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]",
"[ 3/1 → 25/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]",
"[ 25/8 → 13/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]",
"[ 13/4 → 27/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]",
"[ 27/8 → 7/2 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]",
"[ 7/2 → 29/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]",
"[ 29/8 → 15/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]",
"[ 15/4 → 31/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]",
"[ 31/8 → 4/1 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]",
]
`;
exports[`runs examples > example "density" example index 0 1`] = ` exports[`runs examples > example "density" example index 0 1`] = `
[ [
"[ 0/1 → 1/4 | s:crackle density:0.01 ]", "[ 0/1 → 1/4 | s:crackle density:0.01 ]",
@@ -3922,6 +3987,93 @@ exports[`runs examples > example "echoWith" example index 0 1`] = `
] ]
`; `;
exports[`runs examples > example "edoScale" example index 0 1`] = `
[
"[ 0/1 → 1/6 | degree:1 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:130.813 edo:12 ]",
"[ 1/6 → 1/3 | degree:3 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:164.814 edo:12 ]",
"[ 1/3 → 1/2 | degree:5 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:195.998 edo:12 ]",
"[ 1/2 → 2/3 | degree:7 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:246.942 edo:12 ]",
"[ 2/3 → 5/6 | degree:5 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:195.998 edo:12 ]",
"[ 5/6 → 1/1 | degree:3 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:164.814 edo:12 ]",
"[ 1/1 → 7/6 | degree:1 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:130.813 edo:12 ]",
"[ 7/6 → 4/3 | degree:3 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:164.814 edo:12 ]",
"[ 4/3 → 3/2 | degree:5 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:195.998 edo:12 ]",
"[ 3/2 → 5/3 | degree:7 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:246.942 edo:12 ]",
"[ 5/3 → 11/6 | degree:5 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:195.998 edo:12 ]",
"[ 11/6 → 2/1 | degree:3 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:164.814 edo:12 ]",
"[ 2/1 → 13/6 | degree:1 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:130.813 edo:12 ]",
"[ 13/6 → 7/3 | degree:3 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:164.814 edo:12 ]",
"[ 7/3 → 5/2 | degree:5 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:195.998 edo:12 ]",
"[ 5/2 → 8/3 | degree:7 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:246.942 edo:12 ]",
"[ 8/3 → 17/6 | degree:5 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:195.998 edo:12 ]",
"[ 17/6 → 3/1 | degree:3 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:164.814 edo:12 ]",
"[ 3/1 → 19/6 | degree:1 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:130.813 edo:12 ]",
"[ 19/6 → 10/3 | degree:3 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:164.814 edo:12 ]",
"[ 10/3 → 7/2 | degree:5 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:195.998 edo:12 ]",
"[ 7/2 → 11/3 | degree:7 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:246.942 edo:12 ]",
"[ 11/3 → 23/6 | degree:5 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:195.998 edo:12 ]",
"[ 23/6 → 4/1 | degree:3 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:164.814 edo:12 ]",
]
`;
exports[`runs examples > example "edoScale" example index 1 1`] = `
[
"[ 0/1 → 1/4 | degree:1 degreeIndexes:[0 3 6 7 10 13] intLabels:[null S2 d4 N4 s6 s7 P8] root:97.9989 freq:97.999 edo:16 s:piano ]",
"[ 0/1 → 1/4 | degree:2 degreeIndexes:[0 3 6 7 10 13] intLabels:[null S2 d4 N4 s6 s7 P8] root:97.9989 freq:223.2 edo:16 s:piano ]",
"[ 1/4 → 1/2 | degree:5 degreeIndexes:[0 3 6 7 10 13] intLabels:[null S2 d4 N4 s6 s7 P8] root:97.9989 freq:151.135 edo:16 s:piano ]",
"[ 1/2 → 3/4 | degree:3 degreeIndexes:[0 3 6 7 10 13] intLabels:[null S2 d4 N4 s6 s7 P8] root:97.9989 freq:127.089 edo:16 s:piano ]",
"[ 1/2 → 3/4 | degree:2 degreeIndexes:[0 3 6 7 10 13] intLabels:[null S2 d4 N4 s6 s7 P8] root:97.9989 freq:223.2 edo:16 s:piano ]",
"[ 3/4 → 1/1 | degree:5 degreeIndexes:[0 3 6 7 10 13] intLabels:[null S2 d4 N4 s6 s7 P8] root:97.9989 freq:151.135 edo:16 s:piano ]",
"[ 1/1 → 5/4 | degree:1 degreeIndexes:[0 3 6 9 12 13] intLabels:[null S2 d4 M6 s7 P8] root:97.9989 freq:97.999 edo:16 s:piano ]",
"[ 1/1 → 5/4 | degree:2 degreeIndexes:[0 3 6 9 12 13] intLabels:[null S2 d4 M6 s7 P8] root:97.9989 freq:223.2 edo:16 s:piano ]",
"[ 5/4 → 3/2 | degree:5 degreeIndexes:[0 3 6 9 12 13] intLabels:[null S2 d4 M6 s7 P8] root:97.9989 freq:164.814 edo:16 s:piano ]",
"[ 3/2 → 7/4 | degree:3 degreeIndexes:[0 3 6 9 12 13] intLabels:[null S2 d4 M6 s7 P8] root:97.9989 freq:127.089 edo:16 s:piano ]",
"[ 3/2 → 7/4 | degree:2 degreeIndexes:[0 3 6 9 12 13] intLabels:[null S2 d4 M6 s7 P8] root:97.9989 freq:223.2 edo:16 s:piano ]",
"[ 7/4 → 2/1 | degree:5 degreeIndexes:[0 3 6 9 12 13] intLabels:[null S2 d4 M6 s7 P8] root:97.9989 freq:164.814 edo:16 s:piano ]",
"[ 2/1 → 9/4 | degree:1 degreeIndexes:[0 3 6 7 10 13] intLabels:[null S2 d4 N4 s6 s7 P8] root:97.9989 freq:97.999 edo:16 s:piano ]",
"[ 2/1 → 9/4 | degree:2 degreeIndexes:[0 3 6 7 10 13] intLabels:[null S2 d4 N4 s6 s7 P8] root:97.9989 freq:223.2 edo:16 s:piano ]",
"[ 9/4 → 5/2 | degree:5 degreeIndexes:[0 3 6 7 10 13] intLabels:[null S2 d4 N4 s6 s7 P8] root:97.9989 freq:151.135 edo:16 s:piano ]",
"[ 5/2 → 11/4 | degree:3 degreeIndexes:[0 3 6 7 10 13] intLabels:[null S2 d4 N4 s6 s7 P8] root:97.9989 freq:127.089 edo:16 s:piano ]",
"[ 5/2 → 11/4 | degree:2 degreeIndexes:[0 3 6 7 10 13] intLabels:[null S2 d4 N4 s6 s7 P8] root:97.9989 freq:223.2 edo:16 s:piano ]",
"[ 11/4 → 3/1 | degree:5 degreeIndexes:[0 3 6 7 10 13] intLabels:[null S2 d4 N4 s6 s7 P8] root:97.9989 freq:151.135 edo:16 s:piano ]",
"[ 3/1 → 13/4 | degree:1 degreeIndexes:[0 3 6 9 12 13] intLabels:[null S2 d4 M6 s7 P8] root:97.9989 freq:97.999 edo:16 s:piano ]",
"[ 3/1 → 13/4 | degree:2 degreeIndexes:[0 3 6 9 12 13] intLabels:[null S2 d4 M6 s7 P8] root:97.9989 freq:223.2 edo:16 s:piano ]",
"[ 13/4 → 7/2 | degree:5 degreeIndexes:[0 3 6 9 12 13] intLabels:[null S2 d4 M6 s7 P8] root:97.9989 freq:164.814 edo:16 s:piano ]",
"[ 7/2 → 15/4 | degree:3 degreeIndexes:[0 3 6 9 12 13] intLabels:[null S2 d4 M6 s7 P8] root:97.9989 freq:127.089 edo:16 s:piano ]",
"[ 7/2 → 15/4 | degree:2 degreeIndexes:[0 3 6 9 12 13] intLabels:[null S2 d4 M6 s7 P8] root:97.9989 freq:223.2 edo:16 s:piano ]",
"[ 15/4 → 4/1 | degree:5 degreeIndexes:[0 3 6 9 12 13] intLabels:[null S2 d4 M6 s7 P8] root:97.9989 freq:164.814 edo:16 s:piano ]",
]
`;
exports[`runs examples > example "edoScale" example index 2 1`] = `
[
"[ 0/1 → 1/6 | degree:1 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:97.9989 freq:97.999 edo:13 s:piano ]",
"[ 1/6 → 1/3 | degree:5 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:97.9989 freq:167.025 edo:13 s:piano ]",
"[ 1/3 → 1/2 | degree:3 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:97.9989 freq:134.945 edo:13 s:piano ]",
"[ 1/2 → 2/3 | degree:2 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:97.9989 freq:114.998 edo:13 s:piano ]",
"[ 2/3 → 5/6 | degree:3 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:97.9989 freq:134.945 edo:13 s:piano ]",
"[ 5/6 → 1/1 | degree:1 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:97.9989 freq:97.999 edo:13 s:piano ]",
"[ 1/1 → 7/6 | degree:4 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:130.8128 freq:189.995 edo:13 s:piano ]",
"[ 7/6 → 4/3 | degree:2 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:130.8128 freq:153.504 edo:13 s:piano ]",
"[ 4/3 → 3/2 | degree:3 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:130.8128 freq:180.13 edo:13 s:piano ]",
"[ 3/2 → 5/3 | degree:4 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:130.8128 freq:189.995 edo:13 s:piano ]",
"[ 5/3 → 11/6 | degree:1 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:130.8128 freq:130.813 edo:13 s:piano ]",
"[ 11/6 → 2/1 | degree:5 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:130.8128 freq:222.952 edo:13 s:piano ]",
"[ 2/1 → 13/6 | degree:1 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:97.9989 freq:195.998 edo:13 s:piano ]",
"[ 13/6 → 7/3 | degree:4 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:97.9989 freq:142.336 edo:13 s:piano ]",
"[ 7/3 → 5/2 | degree:3 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:97.9989 freq:134.945 edo:13 s:piano ]",
"[ 5/2 → 8/3 | degree:3 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:97.9989 freq:134.945 edo:13 s:piano ]",
"[ 8/3 → 17/6 | degree:4 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:97.9989 freq:142.336 edo:13 s:piano ]",
"[ 17/6 → 3/1 | degree:2 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:97.9989 freq:114.998 edo:13 s:piano ]",
"[ 3/1 → 19/6 | degree:2 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:130.8128 freq:153.504 edo:13 s:piano ]",
"[ 19/6 → 10/3 | degree:2 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:130.8128 freq:153.504 edo:13 s:piano ]",
"[ 10/3 → 7/2 | degree:1 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:130.8128 freq:261.626 edo:13 s:piano ]",
"[ 7/2 → 11/3 | degree:3 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:130.8128 freq:180.13 edo:13 s:piano ]",
"[ 11/3 → 23/6 | degree:3 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:130.8128 freq:180.13 edo:13 s:piano ]",
"[ 23/6 → 4/1 | degree:4 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:130.8128 freq:189.995 edo:13 s:piano ]",
]
`;
exports[`runs examples > example "end" example index 0 1`] = ` exports[`runs examples > example "end" example index 0 1`] = `
[ [
"[ 0/1 → 1/8 | s:oh end:0.1 ]", "[ 0/1 → 1/8 | s:oh end:0.1 ]",
@@ -4193,6 +4345,27 @@ exports[`runs examples > example "extend" example index 0 1`] = `
] ]
`; `;
exports[`runs examples > example "fadeTime" example index 0 1`] = `
[
"[ 0/1 → 1/4 | s:oh end:0.1 fadeTime:0 ]",
"[ 1/4 → 1/2 | s:oh end:0.1 fadeTime:0 ]",
"[ 1/2 → 3/4 | s:oh end:0.1 fadeTime:0 ]",
"[ 3/4 → 1/1 | s:oh end:0.1 fadeTime:0 ]",
"[ 1/1 → 5/4 | s:oh end:0.1 fadeTime:0.2 ]",
"[ 5/4 → 3/2 | s:oh end:0.1 fadeTime:0.2 ]",
"[ 3/2 → 7/4 | s:oh end:0.1 fadeTime:0.2 ]",
"[ 7/4 → 2/1 | s:oh end:0.1 fadeTime:0.2 ]",
"[ 2/1 → 9/4 | s:oh end:0.1 fadeTime:0.4 ]",
"[ 9/4 → 5/2 | s:oh end:0.1 fadeTime:0.4 ]",
"[ 5/2 → 11/4 | s:oh end:0.1 fadeTime:0.4 ]",
"[ 11/4 → 3/1 | s:oh end:0.1 fadeTime:0.4 ]",
"[ 3/1 → 13/4 | s:oh end:0.1 fadeTime:0.8 ]",
"[ 13/4 → 7/2 | s:oh end:0.1 fadeTime:0.8 ]",
"[ 7/2 → 15/4 | s:oh end:0.1 fadeTime:0.8 ]",
"[ 15/4 → 4/1 | s:oh end:0.1 fadeTime:0.8 ]",
]
`;
exports[`runs examples > example "fanchor" example index 0 1`] = ` exports[`runs examples > example "fanchor" example index 0 1`] = `
[ [
"[ 0/1 → 1/8 | note:f s:sawtooth cutoff:1000 lpenv:8 fanchor:0 ]", "[ 0/1 → 1/8 | note:f s:sawtooth cutoff:1000 lpenv:8 fanchor:0 ]",
@@ -4892,6 +5065,78 @@ exports[`runs examples > example "fscope" example index 0 1`] = `
] ]
`; `;
exports[`runs examples > example "ftranspose" example index 0 1`] = `
[
"[ 0/1 → 1/3 | freq:329.6275569 ]",
"[ 1/3 → 2/3 | freq:349.2282315 ]",
"[ 2/3 → 1/1 | freq:369.9944227 ]",
"[ 1/1 → 4/3 | freq:329.6275569 ]",
"[ 4/3 → 5/3 | freq:349.2282315 ]",
"[ 5/3 → 2/1 | freq:369.9944227 ]",
"[ 2/1 → 7/3 | freq:329.6275569 ]",
"[ 7/3 → 8/3 | freq:349.2282315 ]",
"[ 8/3 → 3/1 | freq:369.9944227 ]",
"[ 3/1 → 10/3 | freq:329.6275569 ]",
"[ 10/3 → 11/3 | freq:349.2282315 ]",
"[ 11/3 → 4/1 | freq:369.9944227 ]",
]
`;
exports[`runs examples > example "ftranspose" example index 1 1`] = `
[
"[ 0/1 → 1/3 | freq:263.0921203 ]",
"[ 1/3 → 2/3 | freq:314.6248353 ]",
"[ 2/3 → 1/1 | freq:393.4589706 ]",
"[ 1/1 → 4/3 | freq:183.965981 ]",
"[ 4/3 → 5/3 | freq:220 ]",
"[ 5/3 → 2/1 | freq:275.1244143 ]",
"[ 2/1 → 7/3 | freq:263.0921203 ]",
"[ 7/3 → 8/3 | freq:314.6248353 ]",
"[ 8/3 → 3/1 | freq:393.4589706 ]",
"[ 3/1 → 10/3 | freq:183.965981 ]",
"[ 10/3 → 11/3 | freq:220 ]",
"[ 11/3 → 4/1 | freq:275.1244143 ]",
]
`;
exports[`runs examples > example "ftranspose" example index 2 1`] = `
[
"[ 0/1 → 1/4 | freq:220 ]",
"[ 1/4 → 1/2 | freq:257.2747684 ]",
"[ 1/2 → 3/4 | freq:263.0921203 ]",
"[ 3/4 → 1/1 | freq:329.0139341 ]",
"[ 1/1 → 5/4 | freq:224.9745158 ]",
"[ 5/4 → 3/2 | freq:263.0921203 ]",
"[ 3/2 → 7/4 | freq:269.0410108 ]",
"[ 7/4 → 2/1 | freq:336.4534115 ]",
"[ 2/1 → 9/4 | freq:233.0818808 ]",
"[ 9/4 → 5/2 | freq:272.5731222 ]",
"[ 5/2 → 11/4 | freq:278.7363919 ]",
"[ 11/4 → 3/1 | freq:348.5781207 ]",
"[ 3/1 → 13/4 | freq:220 ]",
"[ 13/4 → 7/2 | freq:257.2747684 ]",
"[ 7/2 → 15/4 | freq:263.0921203 ]",
"[ 15/4 → 4/1 | freq:329.0139341 ]",
]
`;
exports[`runs examples > example "ftranspose" example index 3 1`] = `
[
"[ 0/1 → 1/3 | freq:200 ]",
"[ 1/3 → 2/3 | freq:300 ]",
"[ 2/3 → 1/1 | freq:400 ]",
"[ 1/1 → 4/3 | freq:233.8861531 ]",
"[ 4/3 → 5/3 | freq:350.8292297 ]",
"[ 5/3 → 2/1 | freq:467.7723062 ]",
"[ 2/1 → 7/3 | freq:299.6614154 ]",
"[ 7/3 → 8/3 | freq:449.4921231 ]",
"[ 8/3 → 3/1 | freq:599.3228308 ]",
"[ 3/1 → 10/3 | freq:200 ]",
"[ 10/3 → 11/3 | freq:300 ]",
"[ 11/3 → 4/1 | freq:400 ]",
]
`;
exports[`runs examples > example "ftype" example index 0 1`] = ` exports[`runs examples > example "ftype" example index 0 1`] = `
[ [
"[ 0/1 → 1/8 | note:f s:sawtooth lpenv:4 cutoff:500 ftype:0 resonance:1 ]", "[ 0/1 → 1/8 | note:f s:sawtooth lpenv:4 cutoff:500 ftype:0 resonance:1 ]",
@@ -5061,6 +5306,15 @@ exports[`runs examples > example "gain" example index 0 1`] = `
exports[`runs examples > example "gap" example index 0 1`] = `[]`; exports[`runs examples > example "gap" example index 0 1`] = `[]`;
exports[`runs examples > example "getDur" example index 0 1`] = `
[
"[ 0/1 → 1/1 | s:sax cps:null ]",
"[ 1/1 → 2/1 | s:sax cps:null ]",
"[ 2/1 → 3/1 | s:sax cps:null ]",
"[ 3/1 → 4/1 | s:sax cps:null ]",
]
`;
exports[`runs examples > example "grow" example index 0 1`] = ` exports[`runs examples > example "grow" example index 0 1`] = `
[ [
"[ 0/1 → 1/10 | s:tha bank:mridangam ]", "[ 0/1 → 1/10 | s:tha bank:mridangam ]",
@@ -5576,6 +5830,43 @@ exports[`runs examples > example "hush" example index 0 1`] = `
] ]
`; `;
exports[`runs examples > example "i" example index 0 1`] = `
[
"[ 0/1 → 1/8 | freq:220 ]",
"[ 1/8 → 1/4 | freq:252.7136381 ]",
"[ 1/4 → 3/8 | freq:290.2917404 ]",
"[ 3/8 → 1/2 | freq:333.4576446 ]",
"[ 1/2 → 5/8 | freq:383.0422479 ]",
"[ 5/8 → 3/4 | freq:440 ]",
"[ 3/4 → 7/8 | freq:505.4272762 ]",
"[ 7/8 → 1/1 | freq:580.5834807 ]",
"[ 1/1 → 9/8 | freq:220 ]",
"[ 9/8 → 5/4 | freq:235.7901618 ]",
"[ 5/4 → 11/8 | freq:252.7136381 ]",
"[ 11/8 → 3/2 | freq:270.8517709 ]",
"[ 3/2 → 13/8 | freq:290.2917404 ]",
"[ 13/8 → 7/4 | freq:311.1269837 ]",
"[ 7/4 → 15/8 | freq:333.4576446 ]",
"[ 15/8 → 2/1 | freq:357.3910544 ]",
"[ 2/1 → 17/8 | freq:220 ]",
"[ 17/8 → 9/4 | freq:230.404707 ]",
"[ 9/4 → 19/8 | freq:241.3014955 ]",
"[ 19/8 → 5/2 | freq:252.7136381 ]",
"[ 5/2 → 21/8 | freq:264.6655079 ]",
"[ 21/8 → 11/4 | freq:277.182631 ]",
"[ 11/4 → 23/8 | freq:290.2917404 ]",
"[ 23/8 → 3/1 | freq:304.0208336 ]",
"[ 3/1 → 25/8 | freq:220 ]",
"[ 25/8 → 13/4 | freq:275 ]",
"[ 13/4 → 27/8 | freq:293.3333333 ]",
"[ 27/8 → 7/2 | freq:330 ]",
"[ 7/2 → 29/8 | freq:352 ]",
"[ 29/8 → 15/4 | freq:440 ]",
"[ 15/4 → 31/8 | freq:550 ]",
"[ 31/8 → 4/1 | freq:586.6666667 ]",
]
`;
exports[`runs examples > example "inhabit" example index 0 1`] = ` exports[`runs examples > example "inhabit" example index 0 1`] = `
[ [
"[ 0/1 → 1/8 | s:bd ]", "[ 0/1 → 1/8 | s:bd ]",
@@ -6236,6 +6527,27 @@ exports[`runs examples > example "juxBy" example index 0 1`] = `
] ]
`; `;
exports[`runs examples > example "keep" example index 0 1`] = `
[
"[ 0/1 → 1/4 | note:c s:piano ]",
"[ 1/4 → 1/2 | note:a s:piano ]",
"[ 1/2 → 3/4 | note:f s:piano ]",
"[ 3/4 → 1/1 | note:e s:piano ]",
"[ 1/1 → 5/4 | note:c s:piano ]",
"[ 5/4 → 3/2 | note:a s:piano ]",
"[ 3/2 → 7/4 | note:f s:piano ]",
"[ 7/4 → 2/1 | note:e s:piano ]",
"[ 2/1 → 9/4 | note:c s:piano ]",
"[ 9/4 → 5/2 | note:a s:piano ]",
"[ 5/2 → 11/4 | note:f s:piano ]",
"[ 11/4 → 3/1 | note:e s:piano ]",
"[ 3/1 → 13/4 | note:c s:piano ]",
"[ 13/4 → 7/2 | note:a s:piano ]",
"[ 7/2 → 15/4 | note:f s:piano ]",
"[ 15/4 → 4/1 | note:e s:piano ]",
]
`;
exports[`runs examples > example "keyDown" example index 0 1`] = `[]`; exports[`runs examples > example "keyDown" example index 0 1`] = `[]`;
exports[`runs examples > example "lastOf" example index 0 1`] = ` exports[`runs examples > example "lastOf" example index 0 1`] = `
@@ -8099,6 +8411,48 @@ exports[`runs examples > example "panchor" example index 0 1`] = `
] ]
`; `;
exports[`runs examples > example "panspan" example index 0 1`] = `
[
"[ 0/1 → 1/4 | s:bd pan:0.5 panspan:0 ]",
"[ 1/4 → 1/2 | s:hh pan:0.5 panspan:0 ]",
"[ 1/2 → 3/4 | s:bd pan:0.5 panspan:0 ]",
"[ 3/4 → 1/1 | s:hh pan:0.5 panspan:0 ]",
"[ 1/1 → 5/4 | s:bd pan:1 panspan:0.5 ]",
"[ 5/4 → 3/2 | s:hh pan:1 panspan:0.5 ]",
"[ 3/2 → 7/4 | s:bd pan:1 panspan:0.5 ]",
"[ 7/4 → 2/1 | s:hh pan:1 panspan:0.5 ]",
"[ 2/1 → 9/4 | s:bd pan:0.5 panspan:1 ]",
"[ 9/4 → 5/2 | s:hh pan:0.5 panspan:1 ]",
"[ 5/2 → 11/4 | s:bd pan:0.5 panspan:1 ]",
"[ 11/4 → 3/1 | s:hh pan:0.5 panspan:1 ]",
"[ 3/1 → 13/4 | s:bd pan:0 panspan:0 ]",
"[ 13/4 → 7/2 | s:hh pan:0 panspan:0 ]",
"[ 7/2 → 15/4 | s:bd pan:0 panspan:0 ]",
"[ 15/4 → 4/1 | s:hh pan:0 panspan:0 ]",
]
`;
exports[`runs examples > example "pansplay" example index 0 1`] = `
[
"[ 0/1 → 1/4 | s:bd pan:0.5 pansplay:0 ]",
"[ 1/4 → 1/2 | s:hh pan:0.5 pansplay:0 ]",
"[ 1/2 → 3/4 | s:bd pan:0.5 pansplay:0 ]",
"[ 3/4 → 1/1 | s:hh pan:0.5 pansplay:0 ]",
"[ 1/1 → 5/4 | s:bd pan:1 pansplay:0.5 ]",
"[ 5/4 → 3/2 | s:hh pan:1 pansplay:0.5 ]",
"[ 3/2 → 7/4 | s:bd pan:1 pansplay:0.5 ]",
"[ 7/4 → 2/1 | s:hh pan:1 pansplay:0.5 ]",
"[ 2/1 → 9/4 | s:bd pan:0.5 pansplay:1 ]",
"[ 9/4 → 5/2 | s:hh pan:0.5 pansplay:1 ]",
"[ 5/2 → 11/4 | s:bd pan:0.5 pansplay:1 ]",
"[ 11/4 → 3/1 | s:hh pan:0.5 pansplay:1 ]",
"[ 3/1 → 13/4 | s:bd pan:0 pansplay:0 ]",
"[ 13/4 → 7/2 | s:hh pan:0 pansplay:0 ]",
"[ 7/2 → 15/4 | s:bd pan:0 pansplay:0 ]",
"[ 15/4 → 4/1 | s:hh pan:0 pansplay:0 ]",
]
`;
exports[`runs examples > example "partials" example index 0 1`] = ` exports[`runs examples > example "partials" example index 0 1`] = `
[ [
"[ 0/1 → 1/16 | s:user note:A3 partials:[1 0 1 0 0 1] ]", "[ 0/1 → 1/16 | s:user note:A3 partials:[1 0 1 0 0 1] ]",
@@ -8723,6 +9077,33 @@ exports[`runs examples > example "pickF" example index 1 1`] = `
] ]
`; `;
exports[`runs examples > example "pickF" example index 2 1`] = `
[
"[ 0/1 → 1/8 | note:c2 s:square pan:0 ]",
"[ 1/8 → 1/4 | note:c2 s:square pan:1 ]",
"[ 3/8 → 1/2 | note:c2 s:square pan:0 ]",
"[ 1/2 → 9/16 | note:d2 s:square ]",
"[ 11/16 → 3/4 | note:d2 s:square ]",
"[ 7/8 → 15/16 | note:d2 s:square ]",
"[ 1/1 → 9/8 | note:d2 s:square cutoff:800 ]",
"[ 11/8 → 3/2 | note:d2 s:square cutoff:800 ]",
"[ 3/2 → 25/16 | note:d2 s:square ]",
"[ 27/16 → 7/4 | note:d2 s:square ]",
"[ 15/8 → 31/16 | note:d2 s:square ]",
"[ 2/1 → 17/8 | note:c2 s:square pan:0 ]",
"[ 17/8 → 9/4 | note:c2 s:square pan:1 ]",
"[ 19/8 → 5/2 | note:c2 s:square pan:0 ]",
"[ 5/2 → 41/16 | note:d2 s:square ]",
"[ 43/16 → 11/4 | note:d2 s:square ]",
"[ 23/8 → 47/16 | note:d2 s:square ]",
"[ 3/1 → 25/8 | note:d2 s:square cutoff:800 ]",
"[ 27/8 → 7/2 | note:d2 s:square cutoff:800 ]",
"[ 7/2 → 57/16 | note:d2 s:square ]",
"[ 59/16 → 15/4 | note:d2 s:square ]",
"[ 31/8 → 63/16 | note:d2 s:square ]",
]
`;
exports[`runs examples > example "pickmodRestart" example index 0 1`] = ` exports[`runs examples > example "pickmodRestart" example index 0 1`] = `
[ [
"[ 0/1 → 1/4 | note:C3 s:piano ]", "[ 0/1 → 1/4 | note:C3 s:piano ]",
@@ -10874,6 +11255,56 @@ exports[`runs examples > example "seqPLoop" example index 0 1`] = `
] ]
`; `;
exports[`runs examples > example "set" example index 0 1`] = `
[
"[ 0/1 → 1/4 | note:c s:triangle ]",
"[ 1/4 → 1/2 | note:a s:triangle ]",
"[ 1/2 → 3/4 | note:f s:triangle ]",
"[ 3/4 → 1/1 | note:e s:triangle ]",
"[ 1/1 → 5/4 | note:c s:triangle ]",
"[ 5/4 → 3/2 | note:a s:triangle ]",
"[ 3/2 → 7/4 | note:f s:triangle ]",
"[ 7/4 → 2/1 | note:e s:triangle ]",
"[ 2/1 → 9/4 | note:c s:triangle ]",
"[ 9/4 → 5/2 | note:a s:triangle ]",
"[ 5/2 → 11/4 | note:f s:triangle ]",
"[ 11/4 → 3/1 | note:e s:triangle ]",
"[ 3/1 → 13/4 | note:c s:triangle ]",
"[ 13/4 → 7/2 | note:a s:triangle ]",
"[ 7/2 → 15/4 | note:f s:triangle ]",
"[ 15/4 → 4/1 | note:e s:triangle ]",
]
`;
exports[`runs examples > example "setDefaultJoin" example index 0 1`] = `
[
"[ 0/1 → 1/4 | s:saw velocity:1 note:F delay:0 ]",
"[ 1/4 → 1/3 | s:saw velocity:1 note:A delay:0 ]",
"[ 1/3 → 1/2 | s:saw velocity:1 note:A delay:0.2 ]",
"[ 1/2 → 2/3 | s:saw velocity:0.5 note:C delay:0.2 ]",
"[ 2/3 → 3/4 | s:saw velocity:0.5 note:C delay:0.3 ]",
"[ 3/4 → 1/1 | s:saw velocity:0.5 note:E delay:0.3 ]",
"[ 1/1 → 5/4 | s:saw velocity:1 note:F delay:0 ]",
"[ 5/4 → 4/3 | s:saw velocity:1 note:A delay:0 ]",
"[ 4/3 → 3/2 | s:saw velocity:1 note:A delay:0.2 ]",
"[ 3/2 → 5/3 | s:saw velocity:0.5 note:C delay:0.2 ]",
"[ 5/3 → 7/4 | s:saw velocity:0.5 note:C delay:0.3 ]",
"[ 7/4 → 2/1 | s:saw velocity:0.5 note:E delay:0.3 ]",
"[ 2/1 → 9/4 | s:saw velocity:1 note:F delay:0 ]",
"[ 9/4 → 7/3 | s:saw velocity:1 note:A delay:0 ]",
"[ 7/3 → 5/2 | s:saw velocity:1 note:A delay:0.2 ]",
"[ 5/2 → 8/3 | s:saw velocity:0.5 note:C delay:0.2 ]",
"[ 8/3 → 11/4 | s:saw velocity:0.5 note:C delay:0.3 ]",
"[ 11/4 → 3/1 | s:saw velocity:0.5 note:E delay:0.3 ]",
"[ 3/1 → 13/4 | s:saw velocity:1 note:F delay:0 ]",
"[ 13/4 → 10/3 | s:saw velocity:1 note:A delay:0 ]",
"[ 10/3 → 7/2 | s:saw velocity:1 note:A delay:0.2 ]",
"[ 7/2 → 11/3 | s:saw velocity:0.5 note:C delay:0.2 ]",
"[ 11/3 → 15/4 | s:saw velocity:0.5 note:C delay:0.3 ]",
"[ 15/4 → 4/1 | s:saw velocity:0.5 note:E delay:0.3 ]",
]
`;
exports[`runs examples > example "setGainCurve" example index 0 1`] = ` exports[`runs examples > example "setGainCurve" example index 0 1`] = `
[ [
"[ 0/1 → 1/4 | s:bd gain:0.5 ]", "[ 0/1 → 1/4 | s:bd gain:0.5 ]",
@@ -13059,6 +13490,97 @@ exports[`runs examples > example "tri" example index 0 1`] = `
] ]
`; `;
exports[`runs examples > example "tune" example index 0 1`] = `
[
"[ 0/1 → 1/6 | freq:{i:0} ]",
"[ 1/6 → 1/3 | freq:{i:1} ]",
"[ 1/3 → 1/2 | freq:{i:2} ]",
"[ 1/2 → 2/3 | freq:{i:3} ]",
"[ 2/3 → 5/6 | freq:{i:4} ]",
"[ 5/6 → 1/1 | freq:{i:5} ]",
"[ 1/1 → 7/6 | freq:{i:0} ]",
"[ 7/6 → 4/3 | freq:{i:1} ]",
"[ 4/3 → 3/2 | freq:{i:2} ]",
"[ 3/2 → 5/3 | freq:{i:3} ]",
"[ 5/3 → 11/6 | freq:{i:4} ]",
"[ 11/6 → 2/1 | freq:{i:5} ]",
"[ 2/1 → 13/6 | freq:{i:0} ]",
"[ 13/6 → 7/3 | freq:{i:1} ]",
"[ 7/3 → 5/2 | freq:{i:2} ]",
"[ 5/2 → 8/3 | freq:{i:3} ]",
"[ 8/3 → 17/6 | freq:{i:4} ]",
"[ 17/6 → 3/1 | freq:{i:5} ]",
"[ 3/1 → 19/6 | freq:{i:0} ]",
"[ 19/6 → 10/3 | freq:{i:1} ]",
"[ 10/3 → 7/2 | freq:{i:2} ]",
"[ 7/2 → 11/3 | freq:{i:3} ]",
"[ 11/3 → 23/6 | freq:{i:4} ]",
"[ 23/6 → 4/1 | freq:{i:5} ]",
]
`;
exports[`runs examples > example "tune" example index 1 1`] = `
[
"[ 0/1 → 1/12 | freq:{i:4} clip:0.5 room:1 ]",
"[ 1/12 → 1/6 | freq:{i:8} clip:0.5 room:1 ]",
"[ 1/6 → 1/4 | freq:{i:9} clip:0.5 room:1 ]",
"[ 1/4 → 1/3 | freq:{i:10} clip:0.5 room:1 ]",
"[ 1/2 → 7/12 | freq:{i:5} clip:0.5 room:1 ]",
"[ 7/12 → 2/3 | freq:{i:7} clip:0.5 room:1 ]",
"[ 2/3 → 3/4 | freq:{i:9} clip:0.5 room:1 ]",
"[ 3/4 → 5/6 | freq:{i:11} clip:0.5 room:1 ]",
"[ 1/1 → 13/12 | freq:{i:4} clip:0.5 room:1 ]",
"[ 13/12 → 7/6 | freq:{i:8} clip:0.5 room:1 ]",
"[ 7/6 → 5/4 | freq:{i:9} clip:0.5 room:1 ]",
"[ 5/4 → 4/3 | freq:{i:10} clip:0.5 room:1 ]",
"[ 3/2 → 19/12 | freq:{i:5} clip:0.5 room:1 ]",
"[ 19/12 → 5/3 | freq:{i:7} clip:0.5 room:1 ]",
"[ 5/3 → 7/4 | freq:{i:9} clip:0.5 room:1 ]",
"[ 7/4 → 11/6 | freq:{i:11} clip:0.5 room:1 ]",
"[ 2/1 → 25/12 | freq:{i:4} clip:0.5 room:1 ]",
"[ 25/12 → 13/6 | freq:{i:8} clip:0.5 room:1 ]",
"[ 13/6 → 9/4 | freq:{i:9} clip:0.5 room:1 ]",
"[ 9/4 → 7/3 | freq:{i:10} clip:0.5 room:1 ]",
"[ 5/2 → 31/12 | freq:{i:5} clip:0.5 room:1 ]",
"[ 31/12 → 8/3 | freq:{i:7} clip:0.5 room:1 ]",
"[ 8/3 → 11/4 | freq:{i:9} clip:0.5 room:1 ]",
"[ 11/4 → 17/6 | freq:{i:11} clip:0.5 room:1 ]",
"[ 3/1 → 37/12 | freq:{i:4} clip:0.5 room:1 ]",
"[ 37/12 → 19/6 | freq:{i:8} clip:0.5 room:1 ]",
"[ 19/6 → 13/4 | freq:{i:9} clip:0.5 room:1 ]",
"[ 13/4 → 10/3 | freq:{i:10} clip:0.5 room:1 ]",
"[ 7/2 → 43/12 | freq:{i:5} clip:0.5 room:1 ]",
"[ 43/12 → 11/3 | freq:{i:7} clip:0.5 room:1 ]",
"[ 11/3 → 15/4 | freq:{i:9} clip:0.5 room:1 ]",
"[ 15/4 → 23/6 | freq:{i:11} clip:0.5 room:1 ]",
]
`;
exports[`runs examples > example "tune" example index 2 1`] = `
[
"[ 0/1 → 1/5 | freq:{i:0} ]",
"[ 1/5 → 2/5 | freq:{i:1} ]",
"[ 2/5 → 3/5 | freq:{i:2} ]",
"[ 3/5 → 4/5 | freq:{i:3} ]",
"[ 4/5 → 1/1 | freq:{i:4} ]",
"[ 1/1 → 6/5 | freq:{i:0} ]",
"[ 6/5 → 7/5 | freq:{i:1} ]",
"[ 7/5 → 8/5 | freq:{i:2} ]",
"[ 8/5 → 9/5 | freq:{i:3} ]",
"[ 9/5 → 2/1 | freq:{i:4} ]",
"[ 2/1 → 11/5 | freq:{i:0} ]",
"[ 11/5 → 12/5 | freq:{i:1} ]",
"[ 12/5 → 13/5 | freq:{i:2} ]",
"[ 13/5 → 14/5 | freq:{i:3} ]",
"[ 14/5 → 3/1 | freq:{i:4} ]",
"[ 3/1 → 16/5 | freq:{i:0} ]",
"[ 16/5 → 17/5 | freq:{i:1} ]",
"[ 17/5 → 18/5 | freq:{i:2} ]",
"[ 18/5 → 19/5 | freq:{i:3} ]",
"[ 19/5 → 4/1 | freq:{i:4} ]",
]
`;
exports[`runs examples > example "undegrade" example index 0 1`] = ` exports[`runs examples > example "undegrade" example index 0 1`] = `
[ [
"[ 0/1 → 1/8 | s:hh ]", "[ 0/1 → 1/8 | s:hh ]",
@@ -13832,6 +14354,76 @@ exports[`runs examples > example "when" example index 0 1`] = `
exports[`runs examples > example "whenKey" example index 0 1`] = `[]`; exports[`runs examples > example "whenKey" example index 0 1`] = `[]`;
exports[`runs examples > example "withBase" example index 0 1`] = `
[
"[ 0/1 → 1/12 | freq:220 ]",
"[ 1/12 → 1/6 | freq:293.3333333 ]",
"[ 1/6 → 1/4 | freq:302.5 ]",
"[ 1/4 → 1/3 | freq:320 ]",
"[ 1/3 → 1/2 | freq:320 ]",
"[ 1/2 → 2/3 | freq:330 ]",
"[ 2/3 → 3/4 | freq:330 ]",
"[ 3/4 → 5/6 | freq:320 ]",
"[ 5/6 → 11/12 | freq:302.5 ]",
"[ 11/12 → 1/1 | freq:293.3333333 ]",
"[ 1/1 → 13/12 | freq:300 ]",
"[ 13/12 → 7/6 | freq:399.99999995454544 ]",
"[ 7/6 → 5/4 | freq:412.5 ]",
"[ 5/4 → 4/3 | freq:436.3636363636364 ]",
"[ 4/3 → 3/2 | freq:436.3636363636364 ]",
"[ 3/2 → 5/3 | freq:300 ]",
"[ 5/3 → 7/4 | freq:300 ]",
"[ 7/4 → 11/6 | freq:290.90909090909093 ]",
"[ 11/6 → 23/12 | freq:275 ]",
"[ 23/12 → 2/1 | freq:266.66666663636363 ]",
"[ 2/1 → 25/12 | freq:220 ]",
"[ 25/12 → 13/6 | freq:293.3333333 ]",
"[ 13/6 → 9/4 | freq:302.5 ]",
"[ 9/4 → 7/3 | freq:320 ]",
"[ 7/3 → 5/2 | freq:320 ]",
"[ 5/2 → 8/3 | freq:330 ]",
"[ 8/3 → 11/4 | freq:330 ]",
"[ 11/4 → 17/6 | freq:320 ]",
"[ 17/6 → 35/12 | freq:302.5 ]",
"[ 35/12 → 3/1 | freq:293.3333333 ]",
"[ 3/1 → 37/12 | freq:300 ]",
"[ 37/12 → 19/6 | freq:399.99999995454544 ]",
"[ 19/6 → 13/4 | freq:412.5 ]",
"[ 13/4 → 10/3 | freq:436.3636363636364 ]",
"[ 10/3 → 7/2 | freq:436.3636363636364 ]",
"[ 7/2 → 11/3 | freq:300 ]",
"[ 11/3 → 15/4 | freq:300 ]",
"[ 15/4 → 23/6 | freq:290.90909090909093 ]",
"[ 23/6 → 47/12 | freq:275 ]",
"[ 47/12 → 4/1 | freq:266.66666663636363 ]",
]
`;
exports[`runs examples > example "withBase" example index 1 1`] = `
[
"[ 0/1 → 1/5 | freq:220 ]",
"[ 1/5 → 2/5 | freq:234.66666666666666 ]",
"[ 2/5 → 3/5 | freq:247.5 ]",
"[ 3/5 → 4/5 | freq:264 ]",
"[ 4/5 → 1/1 | freq:275 ]",
"[ 1/1 → 6/5 | freq:220 ]",
"[ 6/5 → 7/5 | freq:234.66666666666666 ]",
"[ 7/5 → 8/5 | freq:247.5 ]",
"[ 8/5 → 9/5 | freq:264 ]",
"[ 9/5 → 2/1 | freq:275 ]",
"[ 2/1 → 11/5 | freq:220 ]",
"[ 11/5 → 12/5 | freq:234.66666666666666 ]",
"[ 12/5 → 13/5 | freq:247.5 ]",
"[ 13/5 → 14/5 | freq:264 ]",
"[ 14/5 → 3/1 | freq:275 ]",
"[ 3/1 → 16/5 | freq:220 ]",
"[ 16/5 → 17/5 | freq:234.66666666666666 ]",
"[ 17/5 → 18/5 | freq:247.5 ]",
"[ 18/5 → 19/5 | freq:264 ]",
"[ 19/5 → 4/1 | freq:275 ]",
]
`;
exports[`runs examples > example "withValue" example index 0 1`] = ` exports[`runs examples > example "withValue" example index 0 1`] = `
[ [
"[ 0/1 → 1/3 | 10 ]", "[ 0/1 → 1/3 | 10 ]",
@@ -13971,6 +14563,106 @@ exports[`runs examples > example "wtphaserand" example index 0 1`] = `
] ]
`; `;
exports[`runs examples > example "xen" example index 0 1`] = `
[
"[ 0/1 → 1/3 | freq:220 clip:1 s:piano release:0.1 pan:0.5138888888888888 ]",
"[ 1/3 → 2/3 | freq:263.0921203 clip:1 s:piano release:0.1 pan:0.5277777777777778 ]",
"[ 2/3 → 1/1 | freq:329.0139341 clip:1 s:piano release:0.1 pan:0.5462962962962963 ]",
"[ 1/1 → 4/3 | freq:220 clip:1 s:piano release:0.1 pan:0.5138888888888888 ]",
"[ 4/3 → 5/3 | freq:263.0921203 clip:1 s:piano release:0.1 pan:0.5277777777777778 ]",
"[ 5/3 → 2/1 | freq:329.0139341 clip:1 s:piano release:0.1 pan:0.5462962962962963 ]",
"[ 2/1 → 7/3 | freq:220 clip:1 s:piano release:0.1 pan:0.5138888888888888 ]",
"[ 7/3 → 8/3 | freq:263.0921203 clip:1 s:piano release:0.1 pan:0.5277777777777778 ]",
"[ 8/3 → 3/1 | freq:329.0139341 clip:1 s:piano release:0.1 pan:0.5462962962962963 ]",
"[ 3/1 → 10/3 | freq:220 clip:1 s:piano release:0.1 pan:0.5138888888888888 ]",
"[ 10/3 → 11/3 | freq:263.0921203 clip:1 s:piano release:0.1 pan:0.5277777777777778 ]",
"[ 11/3 → 4/1 | freq:329.0139341 clip:1 s:piano release:0.1 pan:0.5462962962962963 ]",
]
`;
exports[`runs examples > example "xen" example index 1 1`] = `
[
"[ 0/1 → 1/3 | freq:220 clip:1 s:piano release:0.1 pan:0.5138888888888888 ]",
"[ 1/3 → 2/3 | freq:263.0921203 clip:1 s:piano release:0.1 pan:0.5277777777777778 ]",
"[ 2/3 → 1/1 | freq:329.0139341 clip:1 s:piano release:0.1 pan:0.5462962962962963 ]",
"[ 1/1 → 4/3 | freq:220 clip:1 s:piano release:0.1 pan:0.5138888888888888 ]",
"[ 4/3 → 5/3 | freq:263.0921203 clip:1 s:piano release:0.1 pan:0.5277777777777778 ]",
"[ 5/3 → 2/1 | freq:329.0139341 clip:1 s:piano release:0.1 pan:0.5462962962962963 ]",
"[ 2/1 → 7/3 | freq:220 clip:1 s:piano release:0.1 pan:0.5138888888888888 ]",
"[ 7/3 → 8/3 | freq:263.0921203 clip:1 s:piano release:0.1 pan:0.5277777777777778 ]",
"[ 8/3 → 3/1 | freq:329.0139341 clip:1 s:piano release:0.1 pan:0.5462962962962963 ]",
"[ 3/1 → 10/3 | freq:220 clip:1 s:piano release:0.1 pan:0.5138888888888888 ]",
"[ 10/3 → 11/3 | freq:263.0921203 clip:1 s:piano release:0.1 pan:0.5277777777777778 ]",
"[ 11/3 → 4/1 | freq:329.0139341 clip:1 s:piano release:0.1 pan:0.5462962962962963 ]",
]
`;
exports[`runs examples > example "xen" example index 2 1`] = `
[
"[ 0/1 → 1/6 | freq:220 ]",
"[ 1/6 → 1/3 | freq:275 ]",
"[ 1/3 → 1/2 | freq:293.3333333 ]",
"[ 1/2 → 2/3 | freq:330 ]",
"[ 2/3 → 5/6 | freq:352 ]",
"[ 5/6 → 1/1 | freq:440 ]",
"[ 1/1 → 7/6 | freq:220 ]",
"[ 7/6 → 4/3 | freq:275 ]",
"[ 4/3 → 3/2 | freq:293.3333333 ]",
"[ 3/2 → 5/3 | freq:330 ]",
"[ 5/3 → 11/6 | freq:352 ]",
"[ 11/6 → 2/1 | freq:440 ]",
"[ 2/1 → 13/6 | freq:220 ]",
"[ 13/6 → 7/3 | freq:275 ]",
"[ 7/3 → 5/2 | freq:293.3333333 ]",
"[ 5/2 → 8/3 | freq:330 ]",
"[ 8/3 → 17/6 | freq:352 ]",
"[ 17/6 → 3/1 | freq:440 ]",
"[ 3/1 → 19/6 | freq:220 ]",
"[ 19/6 → 10/3 | freq:275 ]",
"[ 10/3 → 7/2 | freq:293.3333333 ]",
"[ 7/2 → 11/3 | freq:330 ]",
"[ 11/3 → 23/6 | freq:352 ]",
"[ 23/6 → 4/1 | freq:440 ]",
]
`;
exports[`runs examples > example "xen" example index 3 1`] = `
[
"[ 0/1 → 1/8 | freq:220 ]",
"[ 1/8 → 1/4 | freq:252.7136381 ]",
"[ 1/4 → 3/8 | freq:290.2917404 ]",
"[ 3/8 → 1/2 | freq:333.4576446 ]",
"[ 1/2 → 5/8 | freq:383.0422479 ]",
"[ 5/8 → 3/4 | freq:440 ]",
"[ 3/4 → 7/8 | freq:505.4272762 ]",
"[ 7/8 → 1/1 | freq:580.5834807 ]",
"[ 1/1 → 9/8 | freq:220 ]",
"[ 9/8 → 5/4 | freq:235.7901618 ]",
"[ 5/4 → 11/8 | freq:252.7136381 ]",
"[ 11/8 → 3/2 | freq:270.8517709 ]",
"[ 3/2 → 13/8 | freq:290.2917404 ]",
"[ 13/8 → 7/4 | freq:311.1269837 ]",
"[ 7/4 → 15/8 | freq:333.4576446 ]",
"[ 15/8 → 2/1 | freq:357.3910544 ]",
"[ 2/1 → 17/8 | freq:220 ]",
"[ 17/8 → 9/4 | freq:230.404707 ]",
"[ 9/4 → 19/8 | freq:241.3014955 ]",
"[ 19/8 → 5/2 | freq:252.7136381 ]",
"[ 5/2 → 21/8 | freq:264.6655079 ]",
"[ 21/8 → 11/4 | freq:277.182631 ]",
"[ 11/4 → 23/8 | freq:290.2917404 ]",
"[ 23/8 → 3/1 | freq:304.0208336 ]",
"[ 3/1 → 25/8 | freq:220 ]",
"[ 25/8 → 13/4 | freq:275 ]",
"[ 13/4 → 27/8 | freq:293.3333333 ]",
"[ 27/8 → 7/2 | freq:330 ]",
"[ 7/2 → 29/8 | freq:352 ]",
"[ 29/8 → 15/4 | freq:440 ]",
"[ 15/4 → 31/8 | freq:550 ]",
"[ 31/8 → 4/1 | freq:586.6666667 ]",
]
`;
exports[`runs examples > example "xfade" example index 0 1`] = ` exports[`runs examples > example "xfade" example index 0 1`] = `
[ [
"[ 0/1 → 1/8 | s:hh gain:0 ]", "[ 0/1 → 1/8 | s:hh gain:0 ]",
+1
View File
@@ -20,6 +20,7 @@ const skippedExamples = [
'accelerationX', 'accelerationX',
'defaultmidimap', 'defaultmidimap',
'midimaps', 'midimaps',
'clearScope',
'bmod', 'bmod',
]; ];
+4
View File
@@ -13,6 +13,7 @@ import { mini, m } from '@strudel/mini/mini.mjs';
// import euclid from '@strudel/core/euclid.mjs'; // import euclid from '@strudel/core/euclid.mjs';
//import '@strudel/midi/midi.mjs'; //import '@strudel/midi/midi.mjs';
import * as tonalHelpers from '@strudel/tonal'; import * as tonalHelpers from '@strudel/tonal';
import * as edoHelpers from '@strudel/edo';
import '@strudel/xen/xen.mjs'; import '@strudel/xen/xen.mjs';
// import '@strudel/xen/tune.mjs'; // import '@strudel/xen/tune.mjs';
// import '@strudel/core/euclid.mjs'; // import '@strudel/core/euclid.mjs';
@@ -126,6 +127,7 @@ const loadSoundfont = () => {};
const loadCsound = () => {}; const loadCsound = () => {};
const loadCSound = () => {}; const loadCSound = () => {};
const loadcsound = () => {}; const loadcsound = () => {};
const getDuration = () => {};
const midin = () => { const midin = () => {
return (ccNum) => strudel.ref(() => 0); // returns ref with default value 0 return (ccNum) => strudel.ref(() => 0); // returns ref with default value 0
@@ -144,6 +146,7 @@ evalScope(
toneHelpersMocked, toneHelpersMocked,
uiHelpersMocked, uiHelpersMocked,
webaudio, webaudio,
edoHelpers,
tonalHelpers, tonalHelpers,
gamepadHelpers, gamepadHelpers,
/* /*
@@ -168,6 +171,7 @@ evalScope(
loadCSound, loadCSound,
loadCsound, loadCsound,
loadcsound, loadcsound,
getDuration,
setcps: id, setcps: id,
setcpm: id, setcpm: id,
Clock: {}, // whatever Clock: {}, // whatever
+2
View File
@@ -1,7 +1,9 @@
import { afterEach } from 'vitest'; import { afterEach } from 'vitest';
import { useRNG } from './packages/core/signal.mjs'; import { useRNG } from './packages/core/signal.mjs';
import { setDefaultJoin } from './packages/core/pattern.mjs';
afterEach(() => { afterEach(() => {
// Avoid bleed between tests // Avoid bleed between tests
useRNG('legacy'); useRNG('legacy');
setDefaultJoin('in');
}); });
+5 -2
View File
@@ -10,8 +10,11 @@ import bundleAudioWorkletPlugin from 'vite-plugin-bundle-audioworklet';
import tailwind from '@astrojs/tailwind'; import tailwind from '@astrojs/tailwind';
import AstroPWA from '@vite-pwa/astro'; import AstroPWA from '@vite-pwa/astro';
const site = process.env.STRUDEL_SITE || `https://strudel.cc/`; // root url without a path import process from 'node:process';
const base = process.env.STRUDEL_BASE || '/'; // base path of the strudel site
const site = process.env.SITE_URL || `https://strudel.cc/`; // root url without a path
const base = process.env.BASE_PATH || ''; // base path of the strudel site
const baseNoTrailing = base.endsWith('/') ? base.slice(0, -1) : base; const baseNoTrailing = base.endsWith('/') ? base.slice(0, -1) : base;
// this rehype plugin fixes relative links // this rehype plugin fixes relative links
+6
View File
@@ -17,6 +17,11 @@
"@astrojs/react": "^4.1.6", "@astrojs/react": "^4.1.6",
"@astrojs/rss": "^4.0.11", "@astrojs/rss": "^4.0.11",
"@astrojs/tailwind": "^5.1.5", "@astrojs/tailwind": "^5.1.5",
"@codemirror/commands": "catalog:",
"@codemirror/language": "catalog:",
"@codemirror/lang-javascript": "catalog:",
"@codemirror/state": "catalog:",
"@codemirror/view": "catalog:",
"@docsearch/css": "^3.8.3", "@docsearch/css": "^3.8.3",
"@docsearch/react": "^3.8.3", "@docsearch/react": "^3.8.3",
"@headlessui/react": "^2.2.0", "@headlessui/react": "^2.2.0",
@@ -28,6 +33,7 @@
"@strudel/csound": "workspace:*", "@strudel/csound": "workspace:*",
"@strudel/desktopbridge": "workspace:*", "@strudel/desktopbridge": "workspace:*",
"@strudel/draw": "workspace:*", "@strudel/draw": "workspace:*",
"@strudel/edo": "workspace:*",
"@strudel/gamepad": "workspace:*", "@strudel/gamepad": "workspace:*",
"@strudel/hydra": "workspace:*", "@strudel/hydra": "workspace:*",
"@strudel/midi": "workspace:*", "@strudel/midi": "workspace:*",
+1 -1
View File
@@ -104,7 +104,7 @@ export const SIDEBAR: Sidebar = {
Understand: [ Understand: [
{ text: 'Coding syntax', link: 'learn/code' }, { text: 'Coding syntax', link: 'learn/code' },
{ text: 'Pitch', link: 'understand/pitch' }, { text: 'Pitch', link: 'understand/pitch' },
{ text: 'Xen Harmonic Functions', link: 'learn/xen' }, { text: 'Xenharmonic Functions', link: 'learn/xen' },
{ text: 'Cycles', link: 'understand/cycles' }, { text: 'Cycles', link: 'understand/cycles' },
{ text: 'Voicings', link: 'understand/voicings' }, { text: 'Voicings', link: 'understand/voicings' },
{ text: 'Pattern Alignment', link: 'technical-manual/alignment' }, { text: 'Pattern Alignment', link: 'technical-manual/alignment' },
+35 -12
View File
@@ -1,22 +1,26 @@
--- ---
title: Xen Harmonic Functions title: Xenharmonic Functions
layout: ../../layouts/MainLayout.astro layout: ../../layouts/MainLayout.astro
--- ---
import { MiniRepl } from '../../docs/MiniRepl'; import { MiniRepl } from '../../docs/MiniRepl';
import { JsDoc } from '../../docs/JsDoc'; import { JsDoc } from '../../docs/JsDoc';
# Xen Harmonic Functions # Xenharmonic Functions (experimental)
{/* TODO expand explanation of xenharmony */}
These functions allow the use of scales other than your typical chromatic 12 based ones. These functions allow the use of scales other than your typical chromatic 12 based ones.
### tune(scale) ### tune(scale)
{/* TODO (maybe): combine jsdoc things in tune.mjs with here */}
<JsDoc client:idle name="tune" h={0} /> <JsDoc client:idle name="tune" h={0} />
Here's an example of how to configure a basic hexany scale: Here's an example of how to configure a basic hexany scale:
<MiniRepl client:idle tune={`"0 1 2 3 4 5".tune("hexany15").mul("220").freq()`} /> <MiniRepl client:idle tune={`i("0 1 2 3 4 5").tune("hexany15").mul("220").freq()`} />
Try other scales like `hexany1`, `iraq`, `gumbeng`, `gunkali`, or `tranh3` Try other scales like `hexany1`, `iraq`, `gumbeng`, `gunkali`, or `tranh3`
@@ -26,7 +30,7 @@ You can set your root to be a particular note with `getFreq`
<MiniRepl <MiniRepl
client:idle client:idle
tune={`"4 8 9 10 - - 5 7 9 11 - -".tune("tranh3") tune={`i("4 8 9 10 - - 5 7 9 11 - -").tune("tranh3")
.mul(getFreq('c3')) .mul(getFreq('c3'))
.freq().clip(.5).room(1)`} .freq().clip(.5).room(1)`}
/> />
@@ -35,7 +39,7 @@ Some tunings become more pronounced with a longer reverb decay:
<MiniRepl <MiniRepl
client:idle client:idle
tune={`"<[5 6 8 10] - [5 7 9 12] -> -".tune("gumbeng") tune={`i("<[5 6 8 10] - [5 7 9 12] -> -").tune("gumbeng")
.mul(getFreq('c3')) .mul(getFreq('c3'))
.freq().clip(.8).room("3:10").rdim(10000).rfade(5)`} .freq().clip(.8).room("3:10").rdim(10000).rfade(5)`}
/> />
@@ -44,7 +48,7 @@ Additionally, you can combo this with `fmap` so that the base note changes:
<MiniRepl <MiniRepl
client:idle client:idle
tune={`"9 11 12 10 - - -".tune("gunkali") tune={`i("9 11 12 10 - - -").tune("gunkali")
.mul("<c3 c3 a3 d#3>".fmap(getFreq)) .mul("<c3 c3 a3 d#3>".fmap(getFreq))
.freq().legato("2 .7").room("1:15").rdim(8500).rlp(14000).rfade(8)`} .freq().legato("2 .7").room("1:15").rdim(8500).rlp(14000).rfade(8)`}
/> />
@@ -53,8 +57,7 @@ Combining this with various polyrhythm tricks can become very evocative:
<MiniRepl <MiniRepl
client:idle client:idle
tune={`"<[0 3 1 -] [-1 4 2 8]> ~ ~,<-4 -5>" tune={`i("<[0 3 1 -] [-1 4 2 8]> ~ ~,<-4 -5>".add(4))
.transpose(4)
.tune("iraq") .tune("iraq")
.mul("<c3 d3 c#3>".fmap(getFreq)) .mul("<c3 d3 c#3>".fmap(getFreq))
.freq().clip(.5).room(1).rfade(9)`} .freq().clip(.5).room(1).rfade(9)`}
@@ -67,7 +70,7 @@ Take the `sanza` tuning:
<MiniRepl <MiniRepl
client:idle client:idle
tune={`"4 5 6 7 8 9".tune("sanza") tune={`i("4 5 6 7 8 9").tune("sanza")
.mul(getFreq('c3')) .mul(getFreq('c3'))
.freq()`} .freq()`}
/> />
@@ -75,15 +78,15 @@ Take the `sanza` tuning:
Notes 7 and 9 will clash quite a bit if you arp them normally. Many tunings will have this sort of sound, and it can feel distracting on its own. Notes 7 and 9 will clash quite a bit if you arp them normally. Many tunings will have this sort of sound, and it can feel distracting on its own.
See how close they are on the pitch wheel? See how close they are on the pitch wheel?
<MiniRepl client:idle tune={`"[7 9]!3".tune("sanza").mul(getFreq('c3')).freq()._pitchwheel()`} /> <MiniRepl client:idle tune={`i("[7 9]!3").tune("sanza").mul(getFreq('c3')).freq()._pitchwheel()`} />
This quality is often due to how the tunings were formed with instruments that were played differently than a piano. This quality is often due to how the tunings were formed with instruments that were played differently than a piano.
As such, some tunings are much better strummed, with the subtle clash of the detuned notes actually making the sound much more magical: As such, some tunings are much better strummed, with the subtle clash of the detuned notes actually making the sound much more magical:
<MiniRepl <MiniRepl
client:idle client:idle
tune={`"[0 1 2 3 4 5 6]@0.3 -" tune={`i("[0 1 2 3 4 5 6]@0.3 -"
.transpose("<2 5 8 1>") .add("<2 5 8 1>"))
.tune("sanza") .tune("sanza")
.mul(getFreq('c3')).freq() .mul(getFreq('c3')).freq()
.legato("3").room(1).rfade(5)`} .legato("3").room(1).rfade(5)`}
@@ -93,3 +96,23 @@ Note the legato and reverb effects make sure the sound of the strumming gets to
tones sound even more alive, too. tones sound even more alive, too.
The `tranh3` tuning has a similar set of notes, with two clashing. You might trying plugging that in above and see if you find a favorite strumming pattern. The `tranh3` tuning has a similar set of notes, with two clashing. You might trying plugging that in above and see if you find a favorite strumming pattern.
You can also give tune a list of frequencies to use as the scale:
<MiniRepl
client:idle
tune={`i("0 1 2 3 4").tune([
261.6255653006,
302.72962012827,
350.29154279212,
405.32593044476,
469.00678383895,
523.2511306012
]).mul(220).freq();`}
/>
### xen(scaleOrRatios)
{/* TODO add explanation of EDO to documentation */}
<JsDoc client:idle name="Pattern.xen" h={0} />
@@ -1,11 +1,11 @@
--- ---
title: Pattern Aligment title: Pattern Alignment
layout: ../../layouts/MainLayout.astro layout: ../../layouts/MainLayout.astro
--- ---
import { MiniRepl } from '../../docs/MiniRepl'; import { MiniRepl } from '../../docs/MiniRepl';
# Pattern Aligment & Combination # Pattern Alignment & Combination
One core aspect of Strudel, inherited from Tidal, is the flexible way that patterns can be combined, irrespective of their structure. Its declarative approach means a live coder does not have to think about the details of _how_ this is done, only _what_ is to be done. One core aspect of Strudel, inherited from Tidal, is the flexible way that patterns can be combined, irrespective of their structure. Its declarative approach means a live coder does not have to think about the details of _how_ this is done, only _what_ is to be done.
@@ -0,0 +1,85 @@
---
title: Helix Keybindings
layout: ../../layouts/MainLayout.astro
---
# Helix Keybindings in the REPL
The Strudel REPL supports [Helix editor](https://helix-editor.com/) keybindings through the `codemirror-helix` extension. Helix is a post-modern modal text editor with a focus on selection-first editing and multiple cursors.
## Enabling Helix Mode
1. Open the Settings panel in the REPL (click the settings icon or use the keyboard shortcut)
2. Find the "Keybindings" section
3. Select "Helix" from the available options
## Key Differences from Vim
Helix uses a selection-first approach, which means:
- **Selection → Action** (Helix) vs **Action → Motion** (Vim)
- For example, to delete a word in Helix: `w` (select word) → `d` (delete)
- In Vim, it would be: `dw` (delete word)
## Common Helix Commands
### Normal Mode
- `i` — Enter insert mode before selection
- `a` — Enter insert mode after selection
- `v` — Enter visual/select mode
- `w` — Select next word
- `e` — Select to end of word
- `b` — Select previous word
- `x` — Select/extend line
- `d` — Delete selection
- `c` — Change selection (delete and enter insert mode)
- `y` — Yank (copy) selection
- `p` — Paste after selection
- `u` — Undo
- `U` — Redo
- `/` — Search
- `n` — Select next search match
- `N` — Select previous search match
### Movement
- `h, j, k, l` — Move left, down, up, right
- `gg` — Go to start of document
- `ge` — Go to end of document
- `{` / `}` — Move to previous/next paragraph
- `%` — Match bracket
### Multi-cursor
- `C` — Duplicate cursor to line below
- `Alt-C` — Duplicate cursor to line above
- `,` — Remove primary cursor
- `Alt-,` — Remove all secondary cursors
## Strudel-Specific Features
Currently, Helix mode in Strudel provides standard Helix keybindings. Unlike Vim mode, there are no custom Strudel-specific commands (like `:w` to evaluate) yet.
To evaluate code while in Helix mode, use the standard shortcuts:
- `Ctrl+Enter` or `Alt+Enter` — Evaluate code
- `Alt+.` — Stop playback
## Resources
- [Helix Documentation](https://docs.helix-editor.com/)
- [Helix Keymap](https://docs.helix-editor.com/keymap.html)
- [codemirror-helix on npm](https://www.npmjs.com/package/codemirror-helix)
- [codemirror-helix on GitLab](https://gitlab.com/_rvidal/codemirror-helix)
## Notes
- The `codemirror-helix` extension is described as an "initial version" and may not implement all Helix features
- Behavior may differ slightly from the native Helix editor
- If you encounter issues, you can switch back to other keybinding modes in Settings
- The keybinding preference is saved in your browser's local storage
## Contributing
If you'd like to add Strudel-specific Helix commands (similar to Vim's `:w` for evaluate), contributions are welcome! See the implementation in `/packages/codemirror/keybindings.mjs` for reference.
+11 -11
View File
@@ -10,50 +10,50 @@
opacity: 0.5; opacity: 0.5;
} }
#code .cm-scroller { .code-container .cm-scroller {
padding-top: 10px !important; padding-top: 10px !important;
height: 100%; height: 100%;
font-family: inherit; font-family: inherit;
} }
#code .cm-content { .code-container .cm-content {
padding-bottom: 50vh; padding-bottom: 50vh;
} }
#code .cm-line > * { .code-container .cm-line > * {
background: var(--lineBackground); background: var(--lineBackground);
} }
#code .cm-editor { .code-container .cm-editor {
background-color: transparent !important; background-color: transparent !important;
height: 100%; height: 100%;
z-index: 11; z-index: 11;
} }
#code .cm-theme { .code-container .cm-theme {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
#code .cm-theme-light { .code-container .cm-theme-light {
width: 100%; width: 100%;
} }
#code .cm-cursorLayer { .code-container .cm-cursorLayer {
animation-name: inherit !important; animation-name: inherit !important;
} }
#code .cm-cursor { .code-container .cm-cursor {
border-left: 2px solid currentcolor !important; border-left: 2px solid currentcolor !important;
} }
#code .cm-foldGutter { .code-container .cm-foldGutter {
display: none !important; display: none !important;
} }
#code .cm-focused { .code-container .cm-focused {
outline: none; outline: none;
} }
#code .cm-matchingBracket { .code-container .cm-matchingBracket {
text-decoration: underline 0.18rem; text-decoration: underline 0.18rem;
text-underline-offset: 0.22rem; text-underline-offset: 0.22rem;
} }
+1 -2
View File
@@ -8,8 +8,7 @@ export function Code(Props) {
return ( return (
<section <section
className={'text-gray-100 cursor-text pb-0 overflow-auto grow z-10'} className={'code-container text-gray-100 cursor-text pb-0 overflow-auto grow z-10'}
id="code"
ref={(el) => { ref={(el) => {
containerRef.current = el; containerRef.current = el;
if (!editorRef.current) { if (!editorRef.current) {
@@ -8,31 +8,3 @@ export function ActionButton({ children, label, labelIsHidden, className, ...but
</button> </button>
); );
} }
export function SpecialActionButton(props) {
const { className, ...buttonProps } = props;
return (
<ActionButton {...buttonProps} className={cx('bg-background p-2 max-w-[300px] hover:opacity-50', className)} />
);
}
export function ActionInput({ label, className, ...props }) {
return (
<label className={cx('inline-flex items-center cursor-pointer ', className)}>
<input {...props} className="sr-only peer" />
<span className="inline-flex items-center peer-hover:opacity-50">{label}</span>
</label>
);
}
export function SpecialActionInput({ className, ...props }) {
return (
<ActionInput
{...props}
className={className}
label={<span className="bg-background p-2 max-w-[300px]">{props.label}</span>}
/>
);
}
@@ -1,42 +1,50 @@
import { errorLogger } from '@strudel/core'; import { errorLogger } from '@strudel/core';
import { useSettings, storePrebakeScript } from '../../../settings.mjs'; import { storePrebakeScript } from '../../../settings.mjs';
import { SpecialActionInput } from '../button/action-button'; import { confirmDialog } from '@src/repl/util.mjs';
import { confirmDialog, SETTING_CHANGE_RELOAD_MSG } from '@src/repl/util.mjs';
async function importScript(script) { async function importScript(script, updateEditor) {
const reader = new FileReader(); const reader = new FileReader();
reader.readAsText(script); reader.readAsText(script);
reader.onload = () => { reader.onload = () => {
const text = reader.result; const text = reader.result;
storePrebakeScript(text); storePrebakeScript(text);
updateEditor && updateEditor(text);
}; };
reader.onerror = () => { reader.onerror = () => {
errorLogger(new Error('failed to import prebake script'), 'importScript'); errorLogger(new Error('failed to import prebake script'), 'importScript');
}; };
} }
export function ImportPrebakeScriptButton() {
const settings = useSettings();
export async function exportScript(script) {
const blob = new Blob([script], { type: 'application/javascript' });
const downloadLink = document.createElement('a');
downloadLink.href = window.URL.createObjectURL(blob);
const date = new Date().toISOString().split('T')[0];
downloadLink.download = `prebake_${date}.strudel`;
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
}
export function ImportPrebakeScriptButton({ updateEditor }) {
const handleChange = async (e) => {
const file = e.target.files[0];
const confirmed = await confirmDialog('Warning: This will overwrite the current prebake.\nContinue?');
if (!confirmed) {
return;
}
try {
await importScript(file, updateEditor);
} catch (e) {
errorLogger(e);
}
};
return ( return (
<SpecialActionInput <label className="space-x-1 inline-flex items-center cursor-pointer">
type="file" <input type="file" accept=".strudel,.js" className="sr-only peer" onChange={handleChange} />
label="import prebake script" <span className="inline-flex items-center peer-hover:opacity-50 text-xs max-w-[300px]">import</span>
accept=".strudel" </label>
onChange={async (e) => {
const file = e.target.files[0];
const confirmed = await confirmDialog(SETTING_CHANGE_RELOAD_MSG);
if (!confirmed) {
return;
}
try {
await importScript(file);
window.location.reload();
} catch (e) {
errorLogger(e);
}
}}
/>
); );
} }
+17 -17
View File
@@ -4,7 +4,11 @@ import jsdocJson from '../../../../../doc.json';
import { Textbox } from '@src/repl/components/panel/SettingsTab'; import { Textbox } from '@src/repl/components/panel/SettingsTab';
import { settingsMap, useSettings } from '@src/settings.mjs'; import { settingsMap, useSettings } from '@src/settings.mjs';
const isValid = ({ name, description }) => name && !name.startsWith('_') && !!description; const isValid = ({ name, description, tags = [] }) => {
const isSupradoughOnly = tags.includes('supradough') && !tags.includes('superdough');
const isSuperdirtOnly = tags.includes('superdirt') && !tags.includes('superdough');
return name && !name.startsWith('_') && !!description && !isSupradoughOnly && !isSuperdirtOnly;
};
const availableFunctions = (() => { const availableFunctions = (() => {
const seen = new Set(); // avoid repetition const seen = new Set(); // avoid repetition
@@ -14,36 +18,32 @@ const availableFunctions = (() => {
if (seen.has(doc.name)) continue; if (seen.has(doc.name)) continue;
// jsdoc also uses "tags" for when you use @something in the comments and it doesn't know what // jsdoc also uses "tags" for when you use @something in the comments and it doesn't know what
// @something is. We only want data from comments like `@tags fx, superdough` here. // @something is. We only want data from comments like `@tags superdough` here.
// If nothing is specified, we default to "untagged" for debugging // If nothing is specified, we default to "untagged" for debugging
doc.tags = doc.tags?.filter((t) => t && typeof t === 'string') || ['untagged']; doc.tags = doc.tags?.filter((t) => t && typeof t === 'string') || ['untagged'];
functions.push(doc);
const synonyms = doc.synonyms || []; const synonyms = doc.synonyms || [];
let names = [doc.name];
seen.add(doc.name); seen.add(doc.name);
for (const s of synonyms) { for (const s of synonyms) {
if (!s || seen.has(s)) continue; if (!s || seen.has(s)) continue;
names.push(s);
seen.add(s); seen.add(s);
// Swap `doc.name` in for `s` in the list of synonyms
const synonymsWithDoc = [doc.name, ...synonyms].filter((x) => x && x !== s);
functions.push({
...doc,
name: s, // update names for the synonym
longname: s,
synonyms: synonymsWithDoc,
synonyms_text: synonymsWithDoc.join(', '),
});
} }
doc.allNames = names.join(' ');
doc.synonyms = names.slice(1);
functions.push(doc);
} }
return functions.sort((a, b) => /* a.meta.filename.localeCompare(b.meta.filename) + */ a.name.localeCompare(b.name)); return functions.sort((a, b) => /* a.meta.filename.localeCompare(b.meta.filename) + */ a.name.localeCompare(b.name));
})(); })();
const tagCounts = {}; const tagCounts = {};
const ignoredTags = ['supradough', 'superdirt'];
// const tagOptions = { all: `all (${availableFunctions.length})` }; // const tagOptions = { all: `all (${availableFunctions.length})` };
const tagOptions = { all: `all` }; const tagOptions = { all: `all` };
for (const doc of availableFunctions) { for (const doc of availableFunctions) {
(doc.tags || ['untagged']).forEach((t) => { (doc.tags || ['untagged']).forEach((t) => {
if (typeof t === 'string' && t) { if (typeof t === 'string' && t && !ignoredTags.includes(t)) {
tagCounts[t] = (tagCounts[t] || 0) + 1; tagCounts[t] = (tagCounts[t] || 0) + 1;
//tagOptions[t] = `${t} (${tagCounts[t]})`; //tagOptions[t] = `${t} (${tagCounts[t]})`;
tagOptions[t] = t; tagOptions[t] = t;
@@ -82,7 +82,7 @@ export const Reference = memo(function Reference() {
} }
const lowerCaseSearch = search.toLowerCase(); const lowerCaseSearch = search.toLowerCase();
return ( return (
entry.name.toLowerCase().includes(lowerCaseSearch) || (entry.allNames || entry.name).toLowerCase().includes(lowerCaseSearch) ||
(entry.synonyms?.some((s) => s.toLowerCase().includes(lowerCaseSearch)) ?? false) (entry.synonyms?.some((s) => s.toLowerCase().includes(lowerCaseSearch)) ?? false)
); );
}); });
@@ -151,7 +151,7 @@ export const Reference = memo(function Reference() {
<Fragment key={`entry-${entry.name}`}> <Fragment key={`entry-${entry.name}`}>
<a <a
className={ className={
'cursor-pointer hover:opacity-50 text-ellipsis block' + 'whitespace-nowrap cursor-pointer hover:opacity-50 text-ellipsis block' +
(entry.name === selectedFunction ? 'bg-lineHighlight font-bold' : '') (entry.name === selectedFunction ? 'bg-lineHighlight font-bold' : '')
} }
onClick={() => { onClick={() => {
@@ -162,7 +162,7 @@ export const Reference = memo(function Reference() {
} }
}} }}
> >
{entry.name} {entry.name} {entry.synonyms && <small className="opacity-50">{entry.synonyms?.join(', ')}</small>}
</a>{' '} </a>{' '}
</Fragment> </Fragment>
))} ))}
@@ -204,7 +204,7 @@ export const Reference = memo(function Reference() {
</h3> </h3>
{entry.tags && ( {entry.tags && (
<span className="ml-2 text-xs text-foreground border border-muted px-1 py-0.5"> <span className="ml-2 text-xs text-foreground border border-muted px-1 py-0.5">
{entry.tags.join(', ')} {entry.tags.filter((t) => !ignoredTags.includes(t)).join(', ')}
</span> </span>
)} )}
</div> </div>
@@ -1,18 +1,16 @@
import { defaultSettings, settingsMap, useSettings } from '../../../settings.mjs'; import { defaultSettings, settingsMap, useSettings, storePrebakeScript, setSettingsTab } from '../../../settings.mjs';
import { themes } from '@strudel/codemirror'; import { themes } from '@strudel/codemirror';
import { PrebakeCodeMirror } from '../../../repl/prebakeCodeMirror.mjs';
import { confirmAndReloadPage, isUdels } from '../../util.mjs'; import { confirmAndReloadPage, isUdels } from '../../util.mjs';
import { ButtonGroup } from './Forms.jsx'; import { ButtonGroup } from './Forms.jsx';
import { AudioDeviceSelector } from './AudioDeviceSelector.jsx'; import { AudioDeviceSelector } from './AudioDeviceSelector.jsx';
import { AudioEngineTargetSelector } from './AudioEngineTargetSelector.jsx'; import { AudioEngineTargetSelector } from './AudioEngineTargetSelector.jsx';
import { confirmDialog } from '../../util.mjs'; import { confirmDialog } from '../../util.mjs';
import { DEFAULT_MAX_POLYPHONY, setMaxPolyphony, setMultiChannelOrbits } from '@strudel/webaudio'; import { DEFAULT_MAX_POLYPHONY, setMaxPolyphony, setMultiChannelOrbits } from '@strudel/webaudio';
import { SpecialActionButton } from '../button/action-button.jsx'; import { ActionButton } from '../button/action-button.jsx';
import { ImportPrebakeScriptButton } from './ImportPrebakeScriptButton.jsx'; import { exportScript, ImportPrebakeScriptButton } from './ImportPrebakeScriptButton.jsx';
import { useEffect, useRef } from 'react';
function cx(...classes) { import cx from '@src/cx.mjs';
// : Array<string | undefined>
return classes.filter(Boolean).join(' ');
}
const inputClass = const inputClass =
'bg-background text-xs h-8 max-h-8 border border-box rounded-0 text-foreground border-muted placeholder-muted focus:outline-none focus:ring-0 focus:border-foreground'; 'bg-background text-xs h-8 max-h-8 border border-box rounded-0 text-foreground border-muted placeholder-muted focus:outline-none focus:ring-0 focus:border-foreground';
@@ -123,7 +121,7 @@ const fontFamilyOptions = {
galactico: 'galactico', galactico: 'galactico',
}; };
export function SettingsTab({ started }) { function MainSettingsContent({ started }) {
const { const {
theme, theme,
keybindings, keybindings,
@@ -149,12 +147,12 @@ export function SettingsTab({ started }) {
isTabIndentationEnabled, isTabIndentationEnabled,
isMultiCursorEnabled, isMultiCursorEnabled,
patternAutoStart, patternAutoStart,
includePrebakeScriptInShare, isBlockBasedEvalEnabled,
} = useSettings(); } = useSettings();
const shouldAlwaysSync = isUdels(); const shouldAlwaysSync = isUdels();
const canChangeAudioDevice = AudioContext.prototype.setSinkId != null; const canChangeAudioDevice = AudioContext.prototype.setSinkId != null;
return ( return (
<div className="p-4 text-foreground space-y-4 w-full" style={{ fontFamily }}> <div className="p-4 text-foreground space-y-4 w-full overflow-auto" style={{ fontFamily }}>
{canChangeAudioDevice && ( {canChangeAudioDevice && (
<FormItem label="Audio Output Device"> <FormItem label="Audio Output Device">
<AudioDeviceSelector <AudioDeviceSelector
@@ -232,20 +230,12 @@ export function SettingsTab({ started }) {
/> />
</FormItem> </FormItem>
</div> </div>
<FormItem label="Prebake">
<ImportPrebakeScriptButton />
<Checkbox
label="Include prebake script in share"
onChange={(cbEvent) => settingsMap.setKey('includePrebakeScriptInShare', cbEvent.target.checked)}
value={includePrebakeScriptInShare}
/>
</FormItem>
<FormItem label="Keybindings"> <FormItem label="Keybindings">
<ButtonGroup <ButtonGroup
value={keybindings} value={keybindings}
onChange={(keybindings) => settingsMap.setKey('keybindings', keybindings)} onChange={(keybindings) => settingsMap.setKey('keybindings', keybindings)}
items={{ codemirror: 'Codemirror', vim: 'Vim', emacs: 'Emacs', vscode: 'VSCode' }} items={{ codemirror: 'Codemirror', vim: 'Vim', emacs: 'Emacs', helix: 'Helix', vscode: 'VSCode' }}
></ButtonGroup> ></ButtonGroup>
</FormItem> </FormItem>
<FormItem label="Panel Position"> <FormItem label="Panel Position">
@@ -306,6 +296,11 @@ export function SettingsTab({ started }) {
onChange={(cbEvent) => settingsMap.setKey('isMultiCursorEnabled', cbEvent.target.checked)} onChange={(cbEvent) => settingsMap.setKey('isMultiCursorEnabled', cbEvent.target.checked)}
value={isMultiCursorEnabled} value={isMultiCursorEnabled}
/> />
<Checkbox
label="Enable Block-based Evaluation (EXPERIMENTAL)"
onChange={(cbEvent) => settingsMap.setKey('isBlockBasedEvalEnabled', cbEvent.target.checked)}
value={isBlockBasedEvalEnabled}
/>
<Checkbox <Checkbox
label="Enable flashing on evaluation" label="Enable flashing on evaluation"
onChange={(cbEvent) => settingsMap.setKey('isFlashEnabled', cbEvent.target.checked)} onChange={(cbEvent) => settingsMap.setKey('isFlashEnabled', cbEvent.target.checked)}
@@ -340,7 +335,7 @@ export function SettingsTab({ started }) {
</FormItem> </FormItem>
<FormItem label="Zen Mode">Try clicking the logo in the top left!</FormItem> <FormItem label="Zen Mode">Try clicking the logo in the top left!</FormItem>
<FormItem label="Reset Settings"> <FormItem label="Reset Settings">
<SpecialActionButton <ActionButton
onClick={() => { onClick={() => {
confirmDialog('Sure?').then((r) => { confirmDialog('Sure?').then((r) => {
if (r) { if (r) {
@@ -349,10 +344,70 @@ export function SettingsTab({ started }) {
} }
}); });
}} }}
className="bg-background p-2 max-w-[300px] hover:opacity-50"
> >
restore default settings restore default settings
</SpecialActionButton> </ActionButton>
</FormItem> </FormItem>
</div> </div>
); );
} }
function PrebakeSettingsContent() {
const { fontFamily, includePrebakeScriptInShare, prebakeScript } = useSettings();
const editorRef = useRef();
useEffect(() => {
return () => {
editorRef.current?.cleanup();
};
});
return (
<div className="flex flex-col h-full text-foreground w-full overflow-auto" style={{ fontFamily }}>
<div className="flex flex-col grow overflow-hidden h-full bg-background">
<section
className="pb-0 overflow-auto grow z-10 code-container"
ref={(el) => {
if (editorRef.current) {
return;
}
editorRef.current = new PrebakeCodeMirror(prebakeScript, (code) => storePrebakeScript(code), el);
}}
></section>
</div>
<div className="flex justify-between items-center border-t border-muted px-4 whitespace-nowrap">
<Checkbox
label="share with patterns"
className="whitespace-nowrap max-w-[200px]"
onChange={(cbEvent) => settingsMap.setKey('includePrebakeScriptInShare', cbEvent.target.checked)}
value={includePrebakeScriptInShare}
/>
<div className="py-2 flex flex-row items-center space-x-3 ">
<ImportPrebakeScriptButton updateEditor={(code) => editorRef?.current.setCode(code)} />
<ActionButton onClick={() => exportScript(prebakeScript)}>export</ActionButton>
<ActionButton onClick={() => editorRef.current?.savePrebake()}>save</ActionButton>
</div>
</div>
</div>
);
}
export function SettingsTab({ started }) {
const { settingsTab } = useSettings();
return (
<div className="w-full h-full text-foreground flex flex-col overflow-hidden">
<div className="px-2 shrink-0 h-8 space-x-4 flex max-w-full overflow-x-auto border-b border-muted">
<ButtonGroup
wrap
value={settingsTab}
onChange={(value) => setSettingsTab(value)}
items={{
settings: 'settings',
prebake: 'prebake',
}}
></ButtonGroup>
</div>
{settingsTab === 'settings' && <MainSettingsContent started={started} />}
{settingsTab === 'prebake' && <PrebakeSettingsContent />}
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
import { toggleLineComment } from '@codemirror/commands';
import { javascript, javascriptLanguage } from '@codemirror/lang-javascript';
import { defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { Compartment, EditorState, Prec } from '@codemirror/state';
import { drawSelection, EditorView, keymap } from '@codemirror/view';
import { logger } from '@strudel/core';
import { basicSetup, flash, initTheme, extensions, parseBooleans, codemirrorSettings } from '@strudel/codemirror';
import { evaluate } from '@strudel/transpiler';
export class PrebakeCodeMirror {
constructor(initialCode, storePrebake, container) {
const settings = codemirrorSettings.get();
this.storePrebake = storePrebake;
const compartments = Object.fromEntries(Object.keys(extensions).map((key) => [key, new Compartment()]));
const initialSettings = Object.keys(compartments).map((key) =>
compartments[key].of(extensions[key](parseBooleans(settings[key]))),
);
initTheme(settings.theme);
let state = EditorState.create({
doc: initialCode,
extensions: [
...initialSettings,
basicSetup,
javascript(),
javascriptLanguage.data.of({
closeBrackets: { brackets: ['(', '[', '{', "'", '"', '<'] },
bracketMatching: { brackets: ['(', '[', '{', "'", '"', '<'] },
}),
syntaxHighlighting(defaultHighlightStyle),
EditorView.updateListener.of((v) => {
if (v.docChanged) {
this.code = v.state.doc.toString();
}
}),
drawSelection({ cursorBlinkRate: 0 }),
Prec.highest(
keymap.of([
{
mac: 'Meta-Enter',
run: () => {
this.savePrebake();
},
},
{
key: 'Ctrl-Enter',
run: () => {
this.savePrebake();
},
},
{
key: 'Alt-Enter',
run: () => {
this.savePrebake();
},
},
]),
),
],
});
this.code = initialCode;
this.view = new EditorView({
state,
parent: container,
});
const handleSaveEvent = async (e) => {
if (e.detail.view !== this.view) {
return; // ignore events from other editors
}
await this.savePrebake();
e?.cancelable && e.preventDefault?.();
};
const handleToggleComment = (e) => {
if (e.detail.view !== this.view) {
return; // ignore events from other editors
}
this.toggleComment();
e?.cancelable && e.preventDefault?.();
};
document.addEventListener('repl-evaluate', handleSaveEvent);
document.addEventListener('repl-toggle-comment', handleToggleComment);
this.cleanup = () => {
document.removeEventListener('prebake-evaluate', handleSaveEvent);
document.removeEventListener('prebake-toggle-comment', handleToggleComment);
};
}
async savePrebake() {
flash(this.view);
this.storePrebake(this.code);
evaluate(this.code, { addReturn: false }); // run prebake
logger('[prebake] prebake saved');
}
toggleComment() {
try {
// Honor selections; toggleLineComment handles both selections and
// single line
toggleLineComment(this.view);
} catch (err) {
console.error('Error handling repl-toggle-comment event', err);
}
}
setCode(code) {
const changes = {
from: 0,
to: this.view.state.doc.length,
insert: code,
};
this.view.dispatch({ changes });
}
}
+23 -16
View File
@@ -6,7 +6,7 @@ This program is free software: you can redistribute it and/or modify it under th
import { code2hash, getPerformanceTimeSeconds, logger, silence } from '@strudel/core'; import { code2hash, getPerformanceTimeSeconds, logger, silence } from '@strudel/core';
import { getDrawContext } from '@strudel/draw'; import { getDrawContext } from '@strudel/draw';
import { evaluate, transpiler } from '@strudel/transpiler'; import { transpiler, evaluate } from '@strudel/transpiler';
import { import {
getAudioContextCurrentTime, getAudioContextCurrentTime,
renderPatternAudio, renderPatternAudio,
@@ -71,6 +71,7 @@ export function useReplContext() {
const defaultOutput = shouldUseWebaudio ? webaudioOutput : superdirtOutput; const defaultOutput = shouldUseWebaudio ? webaudioOutput : superdirtOutput;
const getTime = shouldUseWebaudio ? getAudioContextCurrentTime : getPerformanceTimeSeconds; const getTime = shouldUseWebaudio ? getAudioContextCurrentTime : getPerformanceTimeSeconds;
const init = useCallback(() => { const init = useCallback(() => {
setActivePattern(getViewingPatternData().id);
const drawTime = [-2, 2]; const drawTime = [-2, 2];
const drawContext = getDrawContext(); const drawContext = getDrawContext();
const editor = new StrudelMirror({ const editor = new StrudelMirror({
@@ -86,12 +87,12 @@ export function useReplContext() {
pattern: silence, pattern: silence,
drawTime, drawTime,
drawContext, drawContext,
prebake: async () => prebake: async () => {
Promise.all([modulesLoading, presets]).then(() => { await Promise.all([modulesLoading, presets]);
if (prebakeScript?.length) { if (prebakeScript) {
return evaluate(prebakeScript ?? ''); return evaluate(prebakeScript, { addReturn: false });
} }
}), },
onUpdateState: (state) => { onUpdateState: (state) => {
setReplState({ ...state }); setReplState({ ...state });
}, },
@@ -106,17 +107,24 @@ export function useReplContext() {
//post to iframe parent (like Udels) if it exists... //post to iframe parent (like Udels) if it exists...
window.parent?.postMessage(code); window.parent?.postMessage(code);
setLatestCode(code); // Get the full buffer content from the editor instead of just the evaluated block
window.location.hash = '#' + code2hash(code); const fullBufferCode = editorRef.current?.code || code;
setDocumentTitle(code); setLatestCode(fullBufferCode);
try {
window.location.hash = '#' + code2hash(fullBufferCode);
} catch (e) {
console.warn('[useReplContext] Failed to update hash:', e.message);
}
setDocumentTitle(fullBufferCode);
const viewingPatternData = getViewingPatternData(); const viewingPatternData = getViewingPatternData();
setVersionDefaultsFrom(code); setVersionDefaultsFrom(fullBufferCode);
const data = { ...viewingPatternData, code }; const data = { ...viewingPatternData, code: fullBufferCode };
let id = data.id; let id = data.id;
const isExamplePattern = viewingPatternData.collection !== userPattern.collection; const isExamplePattern = viewingPatternData.collection !== userPattern.collection;
if (isExamplePattern) { if (isExamplePattern) {
const codeHasChanged = code !== viewingPatternData.code; const codeHasChanged = fullBufferCode !== viewingPatternData.code;
if (codeHasChanged) { if (codeHasChanged) {
// fork example // fork example
const newPattern = userPattern.duplicate(data); const newPattern = userPattern.duplicate(data);
@@ -168,9 +176,8 @@ export function useReplContext() {
useEffect(() => { useEffect(() => {
let editorSettings = {}; let editorSettings = {};
Object.keys(defaultSettings).forEach((key) => { Object.keys(defaultSettings).forEach((key) => {
if (Object.prototype.hasOwnProperty.call(_settings, key)) { // Don't use hasOwnProperty - nanostore uses proxies so values may not be own properties
editorSettings[key] = _settings[key]; editorSettings[key] = _settings[key];
}
}); });
editorRef.current?.updateSettings(editorSettings); editorRef.current?.updateSettings(editorSettings);
}, [_settings]); }, [_settings]);
+2
View File
@@ -70,6 +70,7 @@ export function loadModules() {
let modules = [ let modules = [
import('@strudel/core'), import('@strudel/core'),
import('@strudel/draw'), import('@strudel/draw'),
import('@strudel/edo'),
import('@strudel/tonal'), import('@strudel/tonal'),
import('@strudel/mini'), import('@strudel/mini'),
import('@strudel/xen'), import('@strudel/xen'),
@@ -108,6 +109,7 @@ export function confirmDialog(msg) {
}); });
} }
export const SETTING_CHANGE_RELOAD_MSG = 'Changing this setting requires the window to reload itself. OK?'; export const SETTING_CHANGE_RELOAD_MSG = 'Changing this setting requires the window to reload itself. OK?';
export function confirmAndReloadPage(onSuccess) { export function confirmAndReloadPage(onSuccess) {
confirmDialog(SETTING_CHANGE_RELOAD_MSG).then((r) => { confirmDialog(SETTING_CHANGE_RELOAD_MSG).then((r) => {
if (r == true) { if (r == true) {
+19 -1
View File
@@ -18,6 +18,20 @@ export const soundFilterType = {
ALL: 'all', ALL: 'all',
}; };
const initialPrebakeScript = `// Prebake script
//
// This is code that is loaded before your pattern is run.
// You can use it to define custom functions to use in any pattern.
//
// This is an initial example script. You can edit it to add
// your own funtions.
//
// To use a script shared by some other user you can use
// the import-button or paste the script in this editor.
const ratchet = register('ratchet', (pat) => pat.sometimes(ply(2)))
`;
export const defaultSettings = { export const defaultSettings = {
activeFooter: 'intro', activeFooter: 'intro',
keybindings: 'codemirror', keybindings: 'codemirror',
@@ -33,6 +47,7 @@ export const defaultSettings = {
isPatternHighlightingEnabled: true, isPatternHighlightingEnabled: true,
isTabIndentationEnabled: false, isTabIndentationEnabled: false,
isMultiCursorEnabled: false, isMultiCursorEnabled: false,
isBlockBasedEvalEnabled: false,
theme: 'strudelTheme', theme: 'strudelTheme',
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: 18, fontSize: 18,
@@ -46,13 +61,14 @@ export const defaultSettings = {
isPanelPinned: false, isPanelPinned: false,
isPanelOpen: true, isPanelOpen: true,
userPatterns: '{}', userPatterns: '{}',
prebakeScript: '', prebakeScript: initialPrebakeScript,
audioEngineTarget: audioEngineTargets.webaudio, audioEngineTarget: audioEngineTargets.webaudio,
isButtonRowHidden: false, isButtonRowHidden: false,
isCSSAnimationDisabled: false, isCSSAnimationDisabled: false,
maxPolyphony: 128, maxPolyphony: 128,
multiChannelOrbits: false, multiChannelOrbits: false,
includePrebakeScriptInShare: true, includePrebakeScriptInShare: true,
settingsTab: 'settings',
}; };
let search = null; let search = null;
@@ -89,6 +105,7 @@ export const $settings = computed(settingsMap, (state) => {
isSyncEnabled: isUdels() ? true : parseBoolean(state.isSyncEnabled), isSyncEnabled: isUdels() ? true : parseBoolean(state.isSyncEnabled),
isTabIndentationEnabled: parseBoolean(state.isTabIndentationEnabled), isTabIndentationEnabled: parseBoolean(state.isTabIndentationEnabled),
isMultiCursorEnabled: parseBoolean(state.isMultiCursorEnabled), isMultiCursorEnabled: parseBoolean(state.isMultiCursorEnabled),
isBlockBasedEvalEnabled: parseBoolean(state.isBlockBasedEvalEnabled),
fontSize: Number(state.fontSize), fontSize: Number(state.fontSize),
panelPosition: state.activeFooter !== '' && !isUdels() ? state.panelPosition : 'bottom', // <-- keep this 'bottom' where it is! panelPosition: state.activeFooter !== '' && !isUdels() ? state.panelPosition : 'bottom', // <-- keep this 'bottom' where it is!
isPanelPinned: parseBoolean(state.isPanelPinned), isPanelPinned: parseBoolean(state.isPanelPinned),
@@ -113,6 +130,7 @@ export function useSettings() {
export const setActiveFooter = (tab) => settingsMap.setKey('activeFooter', tab); export const setActiveFooter = (tab) => settingsMap.setKey('activeFooter', tab);
export const setPanelPinned = (bool) => settingsMap.setKey('isPanelPinned', bool); export const setPanelPinned = (bool) => settingsMap.setKey('isPanelPinned', bool);
export const setIsPanelOpened = (bool) => settingsMap.setKey('isPanelOpen', bool); export const setIsPanelOpened = (bool) => settingsMap.setKey('isPanelOpen', bool);
export const setSettingsTab = (tab) => settingsMap.setKey('settingsTab', tab);
export const storePrebakeScript = (script) => settingsMap.setKey('prebakeScript', script); export const storePrebakeScript = (script) => settingsMap.setKey('prebakeScript', script);
+2 -1
View File
@@ -207,9 +207,10 @@ export async function exportPatterns() {
const userPatterns = userPattern.getAll(); const userPatterns = userPattern.getAll();
const blob = new Blob([JSON.stringify(userPatterns)], { type: 'application/json' }); const blob = new Blob([JSON.stringify(userPatterns)], { type: 'application/json' });
const downloadLink = document.createElement('a'); const downloadLink = document.createElement('a');
const prefix = window.location.hostname.split('.').join('_');
downloadLink.href = window.URL.createObjectURL(blob); downloadLink.href = window.URL.createObjectURL(blob);
const date = new Date().toISOString().split('T')[0]; const date = new Date().toISOString().split('T')[0];
downloadLink.download = `strudel_patterns_${date}.json`; downloadLink.download = `${prefix}_patterns_${date}.json`;
document.body.appendChild(downloadLink); document.body.appendChild(downloadLink);
downloadLink.click(); downloadLink.click();
document.body.removeChild(downloadLink); document.body.removeChild(downloadLink);