mirror of
https://codeberg.org/uzu/strudel
synced 2026-07-14 06:43:47 -04:00
Compare commits
930 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec26392509 | |||
| 30cd486f37 | |||
| cde0520ed9 | |||
| 405338e4f0 | |||
| afbcaa54d0 | |||
| 6a1c6081e0 | |||
| 3c4ba667c8 | |||
| 7fcb5262b8 | |||
| b824c45ba4 | |||
| e47e6c87fc | |||
| 69b5ba89cb | |||
| 33a51761ce | |||
| d217119391 | |||
| d5d6284a90 | |||
| fb790de26c | |||
| ec6f6a1f1c | |||
| a126bc9b86 | |||
| a5c7fcc136 | |||
| d70b758065 | |||
| 9fdba6374c | |||
| af90baea33 | |||
| 743e239e52 | |||
| f44a96a5ce | |||
| b94a686394 | |||
| 728552b669 | |||
| 8b427bc1dd | |||
| ae47d767ce | |||
| ab64451d65 | |||
| 89da9bbdb0 | |||
| 2bc208d8f5 | |||
| cfeff26d42 | |||
| e60ecba393 | |||
| f0d49e0e22 | |||
| 7ee6b933cc | |||
| a3947d1ccf | |||
| e86850b6d8 | |||
| 89a6efb69e | |||
| 31c694b596 | |||
| 7a4babf6e1 | |||
| d5e5ab83ac | |||
| 20016fd9aa | |||
| 132a124644 | |||
| 4caacb0de1 | |||
| 8325b4cfc4 | |||
| 3b2df0b725 | |||
| f80fdbc38a | |||
| 3a7b237765 | |||
| f5af55b63b | |||
| d5dfbd7aa4 | |||
| 6221099af8 | |||
| 22f1512697 | |||
| 6c0d550b6b | |||
| cf12e3e1d8 | |||
| 9a05a679aa | |||
| 321e76166a | |||
| bde17594ce | |||
| 7bbb653963 | |||
| 30e672a027 | |||
| f7f3aa7668 | |||
| adc3a9ac6c | |||
| 2518b2aed2 | |||
| 1d5f3a4f30 | |||
| 2efd56e331 | |||
| 5fa6cb4653 | |||
| 19fd0fc649 | |||
| e72e26eb81 | |||
| 1c4b05d55f | |||
| d453c861d3 | |||
| f637b8b0dd | |||
| 06bf17f444 | |||
| 21543956c6 | |||
| 4758effdc6 | |||
| 6f58e74bea | |||
| 907f03f3bf | |||
| 0435711051 | |||
| 8f6718b9e8 | |||
| aa77255d98 | |||
| 7267990e20 | |||
| 387e5db0fe | |||
| d9f63b8dfa | |||
| 8ed72b4573 | |||
| 74488f8480 | |||
| 8144ec46bd | |||
| dd2c808cd7 | |||
| 389a63a850 | |||
| 99beb4454d | |||
| 30cc46ea66 | |||
| ea7dc7a732 | |||
| 641bd2f356 | |||
| f5ed6dbe79 | |||
| 7a3bea6f90 | |||
| cc10122b71 | |||
| b66c0d18b3 | |||
| 0331c91b80 | |||
| 4d5ebfc204 | |||
| 921cb17d33 | |||
| 832fd04a3d | |||
| d9c21489f8 | |||
| 3962558c09 | |||
| bfa8fa3c75 | |||
| 91799f2014 | |||
| f6bb3869de | |||
| b9e9c9e9e6 | |||
| 92d87093e0 | |||
| 3e05525dc2 | |||
| 22807b3833 | |||
| 0960741156 | |||
| bbb977afcc | |||
| 81a4981a68 | |||
| 90d82c8988 | |||
| 2875814a36 | |||
| 157b05536a | |||
| f8c9425569 | |||
| be3a88b09c | |||
| aabf122161 | |||
| 3c6de3be6d | |||
| 15f7b7c1a2 | |||
| b6e382508f | |||
| 3e7ae88043 | |||
| 897a2016d0 | |||
| 7a1c1a75b1 | |||
| 7f06f11e96 | |||
| 5acb00795e | |||
| f4b68fa2e6 | |||
| 1b37f1ed1d | |||
| f8943dbc94 | |||
| a3f82f90a9 | |||
| 670f75b7e8 | |||
| 79875ebc50 | |||
| a1bd905c06 | |||
| 247bf9b930 | |||
| 8f24ce223f | |||
| 51648d3e6e | |||
| d4e67e1c20 | |||
| edf4739cc6 | |||
| 3e636bd6f0 | |||
| 4df2cc3086 | |||
| 809eb7132c | |||
| dd6429007e | |||
| e211d5a6bb | |||
| 404d9999f3 | |||
| bc1c83ed64 | |||
| 91e1cc136e | |||
| 92e3a80e65 | |||
| 4f046ae883 | |||
| 2b575d50b9 | |||
| 7b92874909 | |||
| 10bfe32a2d | |||
| a41b07c67a | |||
| 20027d7208 | |||
| f38792c569 | |||
| 2c2bfd7070 | |||
| 484c5e6cdd | |||
| a036c4f921 | |||
| 4c35f6a77b | |||
| e92f4938cd | |||
| 12204c9053 | |||
| 980bf17059 | |||
| f2009a33aa | |||
| a65a68b828 | |||
| cbe7aaacfb | |||
| c0e7971be7 | |||
| 35014b5190 | |||
| 9ff0449ca3 | |||
| b491c42af5 | |||
| 8398385dcd | |||
| 629c2b9301 | |||
| 5cc93996ce | |||
| ee43d85f29 | |||
| 7cef558440 | |||
| c261fb6dd6 | |||
| fcd83cce55 | |||
| fddf2ff2a7 | |||
| 6d4787ee33 | |||
| 1a362bd8ea | |||
| 28056deb66 | |||
| 20bbda30f8 | |||
| a8c613b7eb | |||
| fd0a910bfb | |||
| 7071dec775 | |||
| 3d55567445 | |||
| 39e2221c29 | |||
| b8ce920f50 | |||
| ccb5ec1685 | |||
| b09230fe90 | |||
| 8f7b439dce | |||
| c823a37c93 | |||
| 0c9f535ff5 | |||
| fb5884ab81 | |||
| ea91d30ce4 | |||
| f0bd5bffaa | |||
| ac37b6a029 | |||
| a986688874 | |||
| 4ab8c256b2 | |||
| 06ea5a36e2 | |||
| 98f5a56ef6 | |||
| 46af6ed9ef | |||
| 752940b3ab | |||
| c727534d29 | |||
| 20e5fdedfb | |||
| 188f0e9098 | |||
| c2a5562bad | |||
| 57f2a97b7b | |||
| 72423c3a25 | |||
| 105db2a4ca | |||
| be61d08cc6 | |||
| 6e58c973af | |||
| 2473a5391d | |||
| c02def03b6 | |||
| 4e17cfbdd6 | |||
| 83c7e63432 | |||
| 909e0154fe | |||
| ef4e21ac40 | |||
| 857c82e260 | |||
| 0d5dc1f8c1 | |||
| 47c85f8540 | |||
| fd03d1fdfb | |||
| 35016b5847 | |||
| 0fe7edc157 | |||
| d6de160224 | |||
| ba93bd1bc5 | |||
| 3c1a8c8bdb | |||
| b7e941c649 | |||
| ac7e9c6978 | |||
| c13a726129 | |||
| ded91fab7d | |||
| f40d079543 | |||
| d5833dd475 | |||
| 99b3c04a76 | |||
| e3658ec30e | |||
| 72eaf96b4f | |||
| 9c5c71c31a | |||
| af53bab259 | |||
| 0065db8569 | |||
| 7eebf3a466 | |||
| 57fac57677 | |||
| a3b803c3f3 | |||
| 6f703e48e7 | |||
| 64f16c4f33 | |||
| ffbbc43c89 | |||
| 086596c47c | |||
| fdde05e751 | |||
| 694162a7b1 | |||
| 319a2d7289 | |||
| 3a7d50924d | |||
| cbcb25b063 | |||
| f33db5f07f | |||
| b7fa440bda | |||
| 3dc9b3524c | |||
| f48f8e46e5 | |||
| a79fc3db5b | |||
| 30a2e14959 | |||
| cfe17fcba5 | |||
| 46ba165c7e | |||
| 73a9834959 | |||
| be9368e8a2 | |||
| 1ae773acc4 | |||
| 39a73f2499 | |||
| 53c504f260 | |||
| 0d08225983 | |||
| 00872bd1de | |||
| cc4452ce58 | |||
| 11dd8a318a | |||
| 3ef6c7c921 | |||
| 120f89d57f | |||
| d496919fe5 | |||
| f369de13d7 | |||
| 908a703b7f | |||
| 99e14dae5c | |||
| ac582b4d40 | |||
| b7d98dd370 | |||
| 0ecacae71e | |||
| 8cd497abb2 | |||
| 070055d2ef | |||
| e500ebe268 | |||
| 72298c5f03 | |||
| c885d84785 | |||
| a14bf4d97b | |||
| c5f1085bd9 | |||
| ccbf28f2db | |||
| 3a7ec18d46 | |||
| a2b8407fbb | |||
| ac1fd88d8d | |||
| 9f8143e062 | |||
| cdc5e5f3f3 | |||
| bad498a1e5 | |||
| f4cf77f5c6 | |||
| 4de0c11f8c | |||
| 97af50222f | |||
| f360d394a7 | |||
| 48718daea8 | |||
| 8d45016ccf | |||
| ec69f6fd77 | |||
| d94f145649 | |||
| bf3fe605d9 | |||
| 54ebc97ddc | |||
| c689874bb4 | |||
| 23d4bfa9d9 | |||
| 91eb50f1c1 | |||
| 5838ade740 | |||
| 917d01c47d | |||
| 0a51718df3 | |||
| 774372a339 | |||
| 626a99ba5b | |||
| 8c3ee6db6b | |||
| c5c05ebadc | |||
| a8918d55fb | |||
| 07e3ddbe40 | |||
| a9957b45e5 | |||
| 4bd103e612 | |||
| 466234bad6 | |||
| 97beaec25a | |||
| 2b3b638943 | |||
| e1629e8383 | |||
| 266cb614a8 | |||
| dfc4a0818c | |||
| 236ff577a6 | |||
| 66a32ac45d | |||
| 8b2c35b7a3 | |||
| f8f42565ae | |||
| 96981c3c1d | |||
| bd8d207a3d | |||
| e3741ae8fa | |||
| ef87f65bef | |||
| b3537a9acb | |||
| d3d9f23c3c | |||
| c474b8c92e | |||
| da81ba00fb | |||
| f0669ce3ba | |||
| 28c94efaa9 | |||
| 0633954e24 | |||
| cedf09168f | |||
| 3373225f53 | |||
| 1eb9dc73fa | |||
| fdcdc3aaa0 | |||
| da926805b3 | |||
| 82893ffc22 | |||
| beafd9c700 | |||
| c5bd2a7487 | |||
| 4d39f36868 | |||
| c64aa3bf01 | |||
| b4d57f12fe | |||
| 118086aff5 | |||
| 452827630b | |||
| 86248328cc | |||
| 8833d623f7 | |||
| 3fff4609a0 | |||
| 98a1573e27 | |||
| 7080490c68 | |||
| e2b5b92a0c | |||
| 1c3e07afd3 | |||
| c54fa7d266 | |||
| a25f763796 | |||
| 91ad3d729f | |||
| a0c2edad30 | |||
| 82c9047195 | |||
| f51ad2cb7e | |||
| 4cb0488839 | |||
| 663229ecde | |||
| cdb623cb66 | |||
| 28d7e0e489 | |||
| aba594b72a | |||
| 5d7c6c3e4b | |||
| 820744dc27 | |||
| 5445f0812f | |||
| 49868fc119 | |||
| a68c784753 | |||
| f2b2f9f959 | |||
| 53dd53553a | |||
| 89b7eac789 | |||
| 9cdba1a50e | |||
| 98b7003504 | |||
| cb2dc6304f | |||
| edb6c0db2e | |||
| a46a055246 | |||
| 9a8f8a051c | |||
| 3eb40ee7d3 | |||
| 4cc453c640 | |||
| 74e27ca94f | |||
| 7dfd7dbcdc | |||
| 2cba1dcb31 | |||
| 48a6a12648 | |||
| 71dd45cd4b | |||
| 6bbf7cbe32 | |||
| 9f36b73081 | |||
| 6ebe84e096 | |||
| a7e485992e | |||
| cb011c8bb0 | |||
| c8875b29c1 | |||
| 978616833a | |||
| ab74ccfe7c | |||
| 92b2013cf6 | |||
| 2e97fb7be2 | |||
| df1934f87b | |||
| d3472feb5c | |||
| addb6db1c7 | |||
| 566d590453 | |||
| e57bd98da0 | |||
| f5c4373f99 | |||
| e762291bb7 | |||
| 9da027d377 | |||
| 96dccdc38b | |||
| d6254294b0 | |||
| 339c1bb573 | |||
| 9782795761 | |||
| 2d2b238da9 | |||
| f0839e02eb | |||
| a9f7d825c8 | |||
| 709b654ed3 | |||
| c265595536 | |||
| 1670aebad8 | |||
| 39460eb100 | |||
| cd659ac2a1 | |||
| ed182d4b3f | |||
| 90614612e2 | |||
| 6b6d4b86a6 | |||
| 0b67473bd3 | |||
| d87ce960d2 | |||
| 69b034349a | |||
| ed00686804 | |||
| e10c469749 | |||
| 5f7e10733a | |||
| d81a214c74 | |||
| e57f240e70 | |||
| e18296e760 | |||
| c145355ccc | |||
| 352a3c39d7 | |||
| c85a4d4fc1 | |||
| 7676641c9e | |||
| 260d2e499e | |||
| a46617f3f0 | |||
| d164b5e7f5 | |||
| c0bf86d11e | |||
| 6f06683d70 | |||
| e3409e52d0 | |||
| 41d6f1b5da | |||
| aca4601734 | |||
| 2a00fcdf5d | |||
| 9200a67a60 | |||
| 80fe5b8140 | |||
| 085e839bfe | |||
| 7ff7e8852b | |||
| 637d714ab2 | |||
| c42d00f048 | |||
| 702e558d1a | |||
| bcc2e8f759 | |||
| 27339715b7 | |||
| 5e2e6411e5 | |||
| 9848740e18 | |||
| 51b150b829 | |||
| 0fac16e769 | |||
| d0ce82e3cd | |||
| e82629de2e | |||
| 38ce593c60 | |||
| 8a91549a7b | |||
| cc27d482cd | |||
| 37e640470b | |||
| 5e5e7730c1 | |||
| 89e7d52308 | |||
| 99bb227cf4 | |||
| 70e776e799 | |||
| 66aad0004a | |||
| a968545458 | |||
| 5414bbe85d | |||
| e7e80bfd83 | |||
| c57f5bb429 | |||
| f2608e712b | |||
| 34b41fc26f | |||
| f6e171fed8 | |||
| 2511dcc09e | |||
| 798eb22d9a | |||
| 977420e74d | |||
| 9c108862ef | |||
| 2fbdc9fdd7 | |||
| 0a96fa5896 | |||
| 46a45b4596 | |||
| 1205573bc6 | |||
| 9b1c23d49f | |||
| 42ab65abdb | |||
| bb982e6b9b | |||
| 3e0e903d38 | |||
| 591c3fe08f | |||
| 56b1ea3fa5 | |||
| c199b51645 | |||
| 8d2a368da9 | |||
| 052d09e892 | |||
| 439a7dc5e6 | |||
| 06ebcbff8c | |||
| 4e2e790864 | |||
| 4f3b40bd7b | |||
| 142160d79a | |||
| ff2c59a2a6 | |||
| 9b9176325b | |||
| b8e1b8d7ac | |||
| e84c39837f | |||
| 6527c68cda | |||
| 6a09f54b25 | |||
| dc6b766ab7 | |||
| 9eb3c9410d | |||
| e4070b86f8 | |||
| 189e650a73 | |||
| fe46e1da53 | |||
| d1869c18ba | |||
| 02cd79a6d9 | |||
| 9eeb6da3e6 | |||
| 869141b0a3 | |||
| ea017e7622 | |||
| 763f900c69 | |||
| a5886bb9d4 | |||
| 877bc95a58 | |||
| 18b1739b89 | |||
| 022ac95bbd | |||
| b3e25cfc52 | |||
| 3d25aa1b91 | |||
| b54139376e | |||
| d64a0ef0eb | |||
| 659071a4ee | |||
| 64f7bc4442 | |||
| 8c97f2e2d0 | |||
| b8c98736c5 | |||
| b8c46d6b26 | |||
| 79453ac2c3 | |||
| b08890bd9e | |||
| 938c92fda2 | |||
| a0fc52b1ec | |||
| 9a9fe83f9c | |||
| 0693a32736 | |||
| 42a903ecc0 | |||
| da283eb55a | |||
| b12707316a | |||
| 66aa3ac1da | |||
| bf5d9917ab | |||
| 4f15d681b0 | |||
| d81b1ab65c | |||
| 8465517c76 | |||
| 215ab87809 | |||
| e5fe998327 | |||
| 6f75a0a62b | |||
| 0464845262 | |||
| e490774294 | |||
| 34e8a57472 | |||
| 59c8d70714 | |||
| ce7cff2c3b | |||
| 32fe73aba2 | |||
| d6ee10e05c | |||
| 36441e7b73 | |||
| 189daa3942 | |||
| d009b9592e | |||
| f7f1bd63e8 | |||
| 7d24e45692 | |||
| cf55d4c8d1 | |||
| f17a4d045f | |||
| e7e636886d | |||
| 8e1e83f3cf | |||
| 2d64b538e7 | |||
| b717615558 | |||
| e9aef9e4d4 | |||
| 8c4f10464b | |||
| 018649c4f3 | |||
| 5ff59a878d | |||
| a20468b85f | |||
| 9648e35de2 | |||
| cd344cefe0 | |||
| 1f6a313f02 | |||
| 5cbeb227d8 | |||
| c8c6a2ce08 | |||
| 2ab2cc5fc8 | |||
| 3dd667e825 | |||
| 482ebb695a | |||
| 218952b8b6 | |||
| 2e8a8b50df | |||
| 612efad3eb | |||
| e7839a09a1 | |||
| 3292f88810 | |||
| a4c040e101 | |||
| 7046c1f546 | |||
| a4ab8e1b06 | |||
| f22f89347e | |||
| de15d79edf | |||
| 23e5358ae4 | |||
| 3b6777e259 | |||
| ed5bf3b3fc | |||
| b089328da3 | |||
| 784c711870 | |||
| 34b10b6c84 | |||
| 450f9685f3 | |||
| 03090e399c | |||
| ed58d26da6 | |||
| e3605590e5 | |||
| d55ef8e843 | |||
| efc2333360 | |||
| 92626e7c20 | |||
| ad113b3888 | |||
| 39c45bc2b2 | |||
| 899af88bd9 | |||
| c2b8cc4b82 | |||
| 40ed7b5382 | |||
| 5d829419ba | |||
| e26da854c0 | |||
| a28b7a2d5a | |||
| 83c776d70f | |||
| 051efdc13b | |||
| ef3cd56b0d | |||
| 9017085fa3 | |||
| cdbbe079db | |||
| d6045c943b | |||
| 6ee9dea2ff | |||
| 03cbe4b6d4 | |||
| 784ee576da | |||
| ee3e1217b4 | |||
| 30c80119e9 | |||
| 7f103e77e3 | |||
| 973ae4c7da | |||
| 4d13b981f1 | |||
| 7691403fd0 | |||
| 3b6e3624be | |||
| 51f0bab1f8 | |||
| dae1560c40 | |||
| 82f0dbb750 | |||
| fb7d76a2ab | |||
| 26225e4ea7 | |||
| ab12c6ae0a | |||
| 4a894b0be2 | |||
| 4ad305550e | |||
| cf5c502a05 | |||
| 2415210be6 | |||
| fe18aa770d | |||
| 2eda047108 | |||
| c8cf1dc712 | |||
| e136e95375 | |||
| a26559fd3d | |||
| 004c0baa59 | |||
| 5f44ae9c4b | |||
| 66828e17d3 | |||
| b2f90ecb3b | |||
| ccf2a6de52 | |||
| 9416f317d4 | |||
| 75b6f59212 | |||
| 84238c8999 | |||
| 2d855cf5e8 | |||
| dd04d49c75 | |||
| 34874862ab | |||
| 5448fc6077 | |||
| 06fafbd8eb | |||
| 10156f0067 | |||
| 5632d83afb | |||
| a32473d570 | |||
| 0361bc629f | |||
| 1fdd87a4f3 | |||
| 16e9aca222 | |||
| 4a3d5ca44f | |||
| 6c0e8a0833 | |||
| 7336ff0ded | |||
| 735f57e276 | |||
| eb4f1f69f6 | |||
| e7e321fe03 | |||
| bf3de368f0 | |||
| d645cc6fe5 | |||
| 6fe11be218 | |||
| cf7fc23981 | |||
| bfdfab0a00 | |||
| edb347e85a | |||
| 63fcce79e0 | |||
| 8defdc424c | |||
| 16b2fc0632 | |||
| d0a544d7a7 | |||
| 9b3c6b9f94 | |||
| 6327e17feb | |||
| 15fe2734e9 | |||
| 1dd1ce254f | |||
| 3ea91adfd6 | |||
| ce5f425142 | |||
| 671a22fc22 | |||
| 976955dae3 | |||
| a6ad58a375 | |||
| 681eed3d0e | |||
| 81a9811803 | |||
| 72b582c605 | |||
| 2f0891d4e5 | |||
| a7a283a99e | |||
| d9e44dcda6 | |||
| e87bb4376f | |||
| 0722cf7ded | |||
| 37fde8f3e8 | |||
| acd5661106 | |||
| b0b2b6a120 | |||
| 664d76cf1b | |||
| a901e2e387 | |||
| 82b14b5292 | |||
| 05bcd5e32f | |||
| 077556e7cb | |||
| 4cc2de9b7e | |||
| 8655c5b73c | |||
| 31c1450edc | |||
| cbb4207bab | |||
| b0c0a393a1 | |||
| 168269a839 | |||
| 34771f03d4 | |||
| f5ae50c3ce | |||
| 504fe9ed40 | |||
| caf6686d01 | |||
| c063fb6b1c | |||
| e3680b96de | |||
| 688e275e0c | |||
| 2e33569786 | |||
| 46fe6d3c4d | |||
| 9220b95677 | |||
| 5b27b19972 | |||
| 5a72ea94b1 | |||
| eac6f27ca4 | |||
| f2330a2307 | |||
| db413881db | |||
| 77bab433e0 | |||
| f8cc1c2022 | |||
| e0bf9ea4d6 | |||
| 374661eb23 | |||
| df06248c54 | |||
| 27073d6c17 | |||
| f44caf9096 | |||
| ff5b11f5ed | |||
| 719c70a598 | |||
| 7a53f6c021 | |||
| 09d05abd70 | |||
| 3acc364a03 | |||
| 2dd528134a | |||
| fef50e654c | |||
| b1aba294ad | |||
| 63ac80eb79 | |||
| 82e3f9b837 | |||
| e19fd24447 | |||
| fe9a91d1e4 | |||
| 860bb58d61 | |||
| d58b3e490b | |||
| 0003ce174e | |||
| 8e0f23e081 | |||
| 71e60ed8de | |||
| e2a29914c7 | |||
| a9a3d491a2 | |||
| a750764f88 | |||
| 5b397009ab | |||
| e68df9e789 | |||
| 4d3bbd5057 | |||
| 15f3321b04 | |||
| 67bfedb317 | |||
| c0184dd827 | |||
| 09fdc45ff2 | |||
| c01b4c651c | |||
| 634eed1d82 | |||
| a1d181d609 | |||
| 440f1cb081 | |||
| 95193b0bfd | |||
| 1cc15c7e8d | |||
| 10ac7c353c | |||
| 9412bf60bf | |||
| c751bb7e5f | |||
| 28cd7c1681 | |||
| 5efcd14f40 | |||
| 8051e56d13 | |||
| 239987af0b | |||
| 8363ba5a41 | |||
| 30f7127a92 | |||
| 14193abe03 | |||
| 3caf08e199 | |||
| 5cbd38a3e8 | |||
| bbd7ed0f27 | |||
| 43504d16b6 | |||
| 1b7fbecf50 | |||
| 7e8206dcbc | |||
| 3963b807eb | |||
| e99adffc56 | |||
| c9f494d865 | |||
| 117cf7e18a | |||
| e8254735bb | |||
| 3d30007756 | |||
| aeaed50446 | |||
| ae15bb7274 | |||
| 3ac0fdf619 | |||
| d1b6b6be85 | |||
| 1081fc69e9 | |||
| 73ef43dd2c | |||
| 181e51ef0e | |||
| 1167fdb910 | |||
| ec9109dd89 | |||
| 55aef18851 | |||
| 356d4360ec | |||
| 0f2aa9569b | |||
| ac7ee376e5 | |||
| ab052ce1ac | |||
| 5988e3d3b4 | |||
| 2f2bf5bf29 | |||
| b000f2297d | |||
| 77463a199a | |||
| e54449f5f7 | |||
| d743570546 | |||
| 1dac69635d | |||
| 49f38aeddb | |||
| 43c05cb504 | |||
| ccc90d547c | |||
| 998a010247 | |||
| e4cd5dc552 | |||
| 6cf0b6f651 | |||
| 794bf86904 | |||
| 9ed690ce45 | |||
| 623bf93368 | |||
| 0cc18a6468 | |||
| 87598e10d1 | |||
| ccec7e725a | |||
| f0e6c5483c | |||
| 2cbd35f6d2 | |||
| 930dabe4d6 | |||
| ff4b7ca171 | |||
| 5129fa6677 | |||
| 5336569c25 | |||
| 483843bd0a | |||
| 1b3b07842e | |||
| 98efd72ab4 | |||
| 1eb5f6a995 | |||
| 9b10dc8535 | |||
| 2951a60fac | |||
| 94257e81ee | |||
| 006ce9d1da | |||
| 607a6121bc | |||
| 4208c79c09 | |||
| 7565354274 | |||
| ffe831d24b | |||
| 60dd8bd763 | |||
| 29e5833c8c | |||
| e3a6444f41 | |||
| 30fd4c5dec | |||
| 2da03ab46f | |||
| 243adda3dd | |||
| 7fc21d55d7 | |||
| d5ef686fe0 | |||
| d4733a30ab | |||
| 989bd0990e | |||
| 60d09adb44 | |||
| b2588e8c93 | |||
| 39d2784441 | |||
| aa70246afc | |||
| f6ffdd6ae6 | |||
| 077aac1888 | |||
| 94a3a60e49 | |||
| f2372e7a41 | |||
| a0fb8fb37f | |||
| b4027fd92a | |||
| c9cafa37fd | |||
| 2e017e46f9 | |||
| c9f58220a3 | |||
| e9de45993e | |||
| 6b2b8b5124 | |||
| 64a6dacc1e | |||
| 1738e4d538 | |||
| abc7a1e48d | |||
| 7716fdb98e | |||
| 2938bfd88f | |||
| 222e479e30 | |||
| 92b5b6538f | |||
| 3a19e23473 | |||
| dd5743ab8c | |||
| 51e3aa257c | |||
| 5eabcf0618 | |||
| 6d21456423 | |||
| bca16cdf99 | |||
| 492271d786 | |||
| f71db4aeed | |||
| 5d4ef46ac7 | |||
| bd93e44154 | |||
| 6fd89ddee7 | |||
| cf1f4ec35c | |||
| 12bb82d295 | |||
| c33cfc78c5 | |||
| ac6472a43a | |||
| 989fdfa20b | |||
| 05ae31838e | |||
| c691916cbf | |||
| e16295623d | |||
| 9626f3cc9a | |||
| 432e8dcccc | |||
| d2e93a99f1 | |||
| 3505732afa | |||
| 55a5f1d62a | |||
| 3657e2fd65 | |||
| 65a7b3030e | |||
| 208706fb52 | |||
| e04f250adb | |||
| 642ddcdb59 | |||
| 88ca54461e | |||
| 129fd7d6b0 | |||
| 7db52b3dc2 | |||
| 40f6489111 | |||
| 118b619b74 | |||
| 2dd445c102 | |||
| 3c3832dbce | |||
| 9b8761bc45 | |||
| 77ade0758e | |||
| 902012759a | |||
| ea61627963 | |||
| 95526ac99c | |||
| b0da353115 | |||
| fae59a6bcd | |||
| 11196fb1e6 | |||
| e782dc09dd | |||
| e3df504423 | |||
| b71b1354c3 | |||
| dbe3915368 | |||
| 62705da3de | |||
| 81ca199a96 | |||
| bee8c81254 | |||
| 8ad48e1633 | |||
| 4a181d5b33 | |||
| d5ab6b7211 | |||
| 4ba9d47bff | |||
| c6c5a3035c | |||
| e024ae2142 | |||
| 19df19375d | |||
| cba96049a3 | |||
| 1509ff1793 | |||
| 6a33032da7 | |||
| 6c05a4eb7b | |||
| 8d8b8ab99e | |||
| a4f10d5339 | |||
| 16472b59dd | |||
| 33a73c37bc | |||
| 4e6d39666a | |||
| a5b6022506 | |||
| 7df663e87d | |||
| 3159f66560 | |||
| 0d900c0134 | |||
| 66a27a609c | |||
| 496d60dc1c |
@@ -0,0 +1,37 @@
|
||||
name: Build and Deploy to beta (warm.strudel.cc)
|
||||
|
||||
on: [workflow_dispatch]
|
||||
|
||||
# Allow one concurrent deployment
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: docker
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.2
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
# cache: "pnpm"
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Deploy
|
||||
run: |
|
||||
eval $(ssh-agent -s)
|
||||
echo "$SSH_PRIVATE_KEY" | ssh-add -
|
||||
apt update && apt install -y rsync
|
||||
mkdir ~/.ssh
|
||||
ssh-keyscan matrix.toplap.org > ~/.ssh/known_hosts
|
||||
rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy/warm.strudel.cc
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Build and Deploy
|
||||
name: Build and Deploy to live (strudel.cc)
|
||||
|
||||
on: [workflow_dispatch]
|
||||
|
||||
@@ -34,4 +34,4 @@ jobs:
|
||||
apt update && apt install -y rsync
|
||||
mkdir ~/.ssh
|
||||
ssh-keyscan matrix.toplap.org > ~/.ssh/known_hosts
|
||||
rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy
|
||||
rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy/strudel.cc
|
||||
@@ -1,6 +1,6 @@
|
||||
name: Strudel tests
|
||||
|
||||
on: [push]
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'pnpm'
|
||||
cache: "pnpm"
|
||||
- run: pnpm install
|
||||
- run: pnpm run format-check
|
||||
- run: pnpm run lint
|
||||
|
||||
+14
@@ -45,6 +45,20 @@ tidal-drum-machines
|
||||
webaudiofontdata
|
||||
src-tauri/target
|
||||
|
||||
|
||||
|
||||
### BEGIN Visual Studio Code ###
|
||||
# Blanket, recursive exclude for .vscode directory and files
|
||||
.vscode/**/*
|
||||
# Unexclude specific files and directories within .vscode
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
### END Visual Studio Code ###
|
||||
|
||||
|
||||
|
||||
# BEGIN JetBrains -> END JetBrains
|
||||
|
||||
# for JetBrains IDE users, e.g. WebStorm. Source: https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# changelog
|
||||
|
||||
NOTE: you can generate this with `node warm.js`. it might still not be perfectly sorted...
|
||||
|
||||
## november 2025
|
||||
|
||||
- 2025-11-27T22:03:53+01:00 [perf] in `noise`, let noiseMix do the disconnect when it exists by jeromew in: [#1783](https://codeberg.org/uzu/strudel/pulls/1783)
|
||||
- 2025-11-27T22:01:01+01:00 Bug Fix: Retries for sounds tab by glossing in: [#1754](https://codeberg.org/uzu/strudel/pulls/1754)
|
||||
- 2025-11-27T20:36:46+01:00 [hydra] return the hydra object when await initHydra(..) is called by jeromew in: [#1784](https://codeberg.org/uzu/strudel/pulls/1784)
|
||||
- 2025-11-27T20:36:14+01:00 Use errorLogger for query and tonal errors by glossing in: [#1782](https://codeberg.org/uzu/strudel/pulls/1782)
|
||||
- 2025-11-27T20:27:10+01:00 [perf] level 5 `connect-leak` on `vowel` by jeromew in: [#1779](https://codeberg.org/uzu/strudel/pulls/1779)
|
||||
- 2025-11-26T08:23:38+01:00 feat: add prebake script import button for loading .strudel files by daslyfe in: [#1774](https://codeberg.org/uzu/strudel/pulls/1774)
|
||||
- 2025-11-26T07:39:28+01:00 Fix Sampler port trampling by Dayglo in: [#1717](https://codeberg.org/uzu/strudel/pulls/1717)
|
||||
- 2025-11-25T20:59:09+01:00 Feat: Hook up octave and fix example by glossing in: [#1773](https://codeberg.org/uzu/strudel/pulls/1773)
|
||||
- 2025-11-25T20:48:09+01:00 [perf] fix `connect-leak` in `tremolo` param by jeromew in: [#1780](https://codeberg.org/uzu/strudel/pulls/1780)
|
||||
- 2025-11-24T17:51:20+01:00 filter modulation improvements! by daslyfe in: [#1769](https://codeberg.org/uzu/strudel/pulls/1769)
|
||||
- 2025-11-24T01:20:53+01:00 Add o as a synonym for orbit by daslyfe in: [#1766](https://codeberg.org/uzu/strudel/pulls/1766)
|
||||
- 2025-11-23T22:23:26+01:00 [perf] fix `connect-leak` in `fm` modulation by jeromew in: [#1758](https://codeberg.org/uzu/strudel/pulls/1758)
|
||||
- 2025-11-23T09:47:24+01:00 Fix interoperability issue between `all` and `await initHydra()` by jeromew in: [#1663](https://codeberg.org/uzu/strudel/pulls/1663)
|
||||
- 2025-11-23T04:23:06+01:00 Bug fix: Swap l/r gains with temp variable by glossing in: [#1768](https://codeberg.org/uzu/strudel/pulls/1768)
|
||||
- 2025-11-23T00:52:28+01:00 FIX: prevent LFO filter modulation from popping from negative values by daslyfe in: [#1767](https://codeberg.org/uzu/strudel/pulls/1767)
|
||||
- 2025-11-21T01:56:11+01:00 Bug Fix: Use frac due to negative frequencies from FM by glossing in: [#1759](https://codeberg.org/uzu/strudel/pulls/1759)
|
||||
- 2025-11-20T19:48:16+01:00 [perf] fix `connect-leak` added by #1742 when noise() is not used by jeromew in: [#1757](https://codeberg.org/uzu/strudel/pulls/1757)
|
||||
- 2025-11-20T14:51:00+01:00 [perf] fix `connect-leak` in `delay` effect by jeromew in: [#1755](https://codeberg.org/uzu/strudel/pulls/1755)
|
||||
- 2025-11-20T14:49:41+01:00 [perf] fix `connect leak` when .noise() is in the mix by jeromew in: [#1742](https://codeberg.org/uzu/strudel/pulls/1742)
|
||||
- 2025-11-18T23:52:45+01:00 wchooseCycles has now notes in an example by scrappy_fiddler in: [#1748](https://codeberg.org/uzu/strudel/pulls/1748)
|
||||
- 2025-11-17T05:31:54+01:00 Feature: Partials by glossing in: [#1659](https://codeberg.org/uzu/strudel/pulls/1659)
|
||||
- 2025-11-16T22:06:14+01:00 README: update superdough documentation by TristanMlct in: [#1741](https://codeberg.org/uzu/strudel/pulls/1741)
|
||||
- 2025-11-16T21:08:54+01:00 Worklet optimizations by glossing in: [#1730](https://codeberg.org/uzu/strudel/pulls/1730)
|
||||
- 2025-11-15T14:59:30+01:00 [perf] disconnect Offline AudioNode connections in generateReverb by jeromew in: [#1740](https://codeberg.org/uzu/strudel/pulls/1740)
|
||||
- 2025-11-15T14:57:47+01:00 [perf] disconnect `send` effect AudioNode when `room` is used by jeromew in: [#1736](https://codeberg.org/uzu/strudel/pulls/1736)
|
||||
- 2025-11-12T21:22:34+01:00 [supradough] fix: unstable filter by froos in: [#1593](https://codeberg.org/uzu/strudel/pulls/1593)
|
||||
- 2025-11-12T21:12:07+01:00 Fix link syntax in `project-start` by Kissaki in: [#1724](https://codeberg.org/uzu/strudel/pulls/1724)
|
||||
- 2025-11-12T21:06:13+01:00 Fix web README sample code by Kissaki in: [#1725](https://codeberg.org/uzu/strudel/pulls/1725)
|
||||
- 2025-11-12T20:59:39+01:00 Fix typo: 'studel' -> 'strudel' by drhayes in: [#1726](https://codeberg.org/uzu/strudel/pulls/1726)
|
||||
- 2025-11-12T15:23:41+01:00 fix for node 24 support in tests - #1718 by ausav in: [#1719](https://codeberg.org/uzu/strudel/pulls/1719)
|
||||
- 2025-11-11T09:00:49+01:00 soundAlias example fix by PepsiiMan in: [#1720](https://codeberg.org/uzu/strudel/pulls/1720)
|
||||
- 2025-11-10T08:18:36+01:00 fix: Make Supradough package build by munshkr in: [#1711](https://codeberg.org/uzu/strudel/pulls/1711)
|
||||
- 2025-11-10T08:18:00+01:00 added docs for spaces in scale names by ondras in: [#1715](https://codeberg.org/uzu/strudel/pulls/1715)
|
||||
- 2025-11-10T02:44:01+01:00 Feature: LFOs for Filters by glossing in: [#1636](https://codeberg.org/uzu/strudel/pulls/1636)
|
||||
- 2025-11-05T17:07:00+01:00 improvement: sync midi messages to audio context clock by daslyfe in: [#1708](https://codeberg.org/uzu/strudel/pulls/1708)
|
||||
- 2025-11-04T19:36:14+01:00 Add stick button mappings to gamepad implementation and improve docs by kaosuryoko in: [#1696](https://codeberg.org/uzu/strudel/pulls/1696)
|
||||
- 2025-11-04T19:29:35+01:00 Refactor sound stopping and triggering logic in SoundsTab component by IJOL in: [#1688](https://codeberg.org/uzu/strudel/pulls/1688)
|
||||
- 2025-11-04T19:13:21+01:00 Add setting to toggle pattern auto-start on pattern change by moumar in: [#1690](https://codeberg.org/uzu/strudel/pulls/1690)
|
||||
- 2025-11-04T18:58:50+01:00 Voicings JSDoc by sharkeys_lunchbox in: [#1686](https://codeberg.org/uzu/strudel/pulls/1686)
|
||||
- 2025-11-01T07:50:40+01:00 FIX: Loading local samples uses too much memory by daslyfe in: [#1706](https://codeberg.org/uzu/strudel/pulls/1706)
|
||||
|
||||
|
||||
## october 2025
|
||||
|
||||
- 2025-10-28T22:58:24+01:00 Bug Fix: Handle scale-for-notes when n also supplied by glossing in: [#1625](https://codeberg.org/uzu/strudel/pulls/1625)
|
||||
- 2025-10-28T22:51:16+01:00 Repurpose vim shortcuts for usability by dtricks in: [#1624](https://codeberg.org/uzu/strudel/pulls/1624)
|
||||
- 2025-10-28T22:35:21+01:00 Add support for euclidian in mondo with `bd&3:8` by TristanCacqueray in: [#1630](https://codeberg.org/uzu/strudel/pulls/1630)
|
||||
- 2025-10-27T10:42:07+01:00 Use bunny cdn for all samples and json files by yaxu in: [#1701](https://codeberg.org/uzu/strudel/pulls/1701)
|
||||
- 2025-10-26T22:52:54+01:00 degithub - switch some samples to bunnycdn by yaxu in: [#1697](https://codeberg.org/uzu/strudel/pulls/1697)
|
||||
- 2025-10-26T17:09:22+01:00 Fix ZZFX example by moumar in: [#1685](https://codeberg.org/uzu/strudel/pulls/1685)
|
||||
- 2025-10-23T15:56:04+02:00 Make osc port and host configurable. Changes dependencies. by yaxu in: [#1682](https://codeberg.org/uzu/strudel/pulls/1682)
|
||||
- 2025-10-23T03:47:31+02:00 Adds back shape to superdough by glossing in: [#1672](https://codeberg.org/uzu/strudel/pulls/1672)
|
||||
- 2025-10-22T22:15:19+02:00 Fix sampler.mjs githubPath by jeromew in: [#1684](https://codeberg.org/uzu/strudel/pulls/1684)
|
||||
- 2025-10-22T16:20:50+02:00 Fix onPaint for widgets by yaxu in: [#1658](https://codeberg.org/uzu/strudel/pulls/1658)
|
||||
- 2025-10-22T15:19:12+02:00 Fix a bug introduced by #4e17cfbdd6 by milliganf in: [#1679](https://codeberg.org/uzu/strudel/pulls/1679)
|
||||
- 2025-10-18T04:29:20+02:00 Bug Fix: Wavetable: phase wrapping at 1 and detune by glossing in: [#1620](https://codeberg.org/uzu/strudel/pulls/1620)
|
||||
- 2025-10-16T20:26:28+02:00 github samples: default to "samples" if repository is not specified by prezmop in: [#1644](https://codeberg.org/uzu/strudel/pulls/1644)
|
||||
- 2025-10-16T17:33:39+02:00 Docs: add example of custom chained function by dariusk in: [#1642](https://codeberg.org/uzu/strudel/pulls/1642)
|
||||
- 2025-10-14T12:39:48+02:00 textbox by yaxu in: [#1650](https://codeberg.org/uzu/strudel/pulls/1650)
|
||||
- 2025-10-13T07:05:57+02:00 Feature: Wavetable FM by glossing in: [#1623](https://codeberg.org/uzu/strudel/pulls/1623)
|
||||
- 2025-10-12T20:43:42+02:00 fix: repair REPL sample sources and URL concat (#1640) by erikfox in: [#1646](https://codeberg.org/uzu/strudel/pulls/1646)
|
||||
- 2025-10-10T11:25:02+02:00 Add control-metadata by yaxu in: [#1634](https://codeberg.org/uzu/strudel/pulls/1634)
|
||||
- 2025-10-07T22:59:39+02:00 Fix MQTT: change the trigger handler to match new hap by jurrchen in: [#1629](https://codeberg.org/uzu/strudel/pulls/1629)
|
||||
- 2025-10-05T21:42:19+02:00 Fix case sensitivity for synonym search by vvolhejn in: [#1618](https://codeberg.org/uzu/strudel/pulls/1618)
|
||||
- 2025-10-01T08:56:35+02:00 Optimize wavetable synth by glossing in: [#1613](https://codeberg.org/uzu/strudel/pulls/1613)
|
||||
- 2025-10-01T02:02:33+02:00 fix: pattern switching by daslyfe in: [#1616](https://codeberg.org/uzu/strudel/pulls/1616)
|
||||
|
||||
## September 2025
|
||||
|
||||
- 2025-09-29T02:10:54+02:00 fix wavetable JSON by daslyfe in: [#1611](https://codeberg.org/uzu/strudel/pulls/1611)
|
||||
- 2025-09-28T21:28:16+02:00 rename wavetable controls to match existing naming conventions by daslyfe in: [#1610](https://codeberg.org/uzu/strudel/pulls/1610)
|
||||
- 2025-09-27T23:28:13+02:00 Feature: Wavetable synth improvements by glossing in: [#1607](https://codeberg.org/uzu/strudel/pulls/1607)
|
||||
- 2025-09-27T21:43:10+02:00 wavetable improvements by daslyfe in: [#1608](https://codeberg.org/uzu/strudel/pulls/1608)
|
||||
- 2025-09-26T08:48:46+02:00 create orbit based DJ filter by daslyfe in: [#1603](https://codeberg.org/uzu/strudel/pulls/1603)
|
||||
- 2025-09-23T13:28:20+02:00 fix: time signal by daslyfe in: [#1583](https://codeberg.org/uzu/strudel/pulls/1583)
|
||||
- 2025-09-21T16:16:06+02:00 mondo: fix all + setcpm + setcps by froos in: [#1600](https://codeberg.org/uzu/strudel/pulls/1600)
|
||||
- 2025-09-19T21:59:43+02:00 fix: osc error message by froos in: [#1597](https://codeberg.org/uzu/strudel/pulls/1597)
|
||||
- 2025-09-18T21:09:56+02:00 osc: --debug flag to see incoming messages by froos in: [#1595](https://codeberg.org/uzu/strudel/pulls/1595)
|
||||
- 2025-09-17T14:12:10+02:00 simplify osc usage by froos in: [#1588](https://codeberg.org/uzu/strudel/pulls/1588)
|
||||
- 2025-09-16T16:59:58+02:00 dev: logger errors to console in dev mode by daslyfe in: [#1585](https://codeberg.org/uzu/strudel/pulls/1585)
|
||||
- 2025-09-15T22:52:14+02:00 add tic80 font by froos in: [#1579](https://codeberg.org/uzu/strudel/pulls/1579)
|
||||
- 2025-09-15T00:00:19+02:00 supradough: fix delay + add some jsdoc types by froos in: [#1578](https://codeberg.org/uzu/strudel/pulls/1578)
|
||||
+77
-1
@@ -17,7 +17,7 @@ git remote set-url origin git@codeberg.org:uzu/strudel.git
|
||||
|
||||
To get in touch with the contributors, either
|
||||
|
||||
- [join the Tidal Discord Channel](https://discord.gg/remJ6gQA) and go to the #strudel channel
|
||||
- [join the Tidal Discord Channel](https://discord.com/invite/HGEdXmRkzT) and go to the #strudel channel
|
||||
- Find related discussions on the [tidal club forum](https://club.tidalcycles.org/)
|
||||
|
||||
## Ask a Question
|
||||
@@ -150,6 +150,7 @@ Important: Always publish with `pnpm`, as `npm` does not support overriding main
|
||||
|
||||
|
||||
## useful commands
|
||||
|
||||
```sh
|
||||
#regenerate the test snapshots (ex: when updating or creating new pattern functions)
|
||||
pnpm snapshot
|
||||
@@ -160,6 +161,81 @@ pnpm run osc
|
||||
#build the standalone version
|
||||
pnpm tauri build
|
||||
```
|
||||
|
||||
## version tag patching
|
||||
|
||||
here's a little guide on how to patch patterns in the database to prevent breaking old patterns due to breaking changes in newer versions.
|
||||
|
||||
the general tactic is to use `// @version x.y` to tag a pattern with a specific strudel version. when a pattern is evaluated, this metadata will de-activate any breaking changes that came after the specified version.
|
||||
for example, in version 1.1, the default value for `fanchor` was changed from `0.5` to `0`.
|
||||
if play a pattern that was made before that change, sounds that use filter evenlopes can sound very different, so by adding `// @version 1.0` will make it sound like it used to.
|
||||
before releasing a new version with breaking changes, we can edit all patterns in the database, inserting the version tag they were created under:
|
||||
|
||||
as an example, to release version 1.2, do the following:
|
||||
|
||||
1. get date range
|
||||
|
||||
```sh
|
||||
# get date of last version:
|
||||
git log -1 --format=%aI @strudel/core@1.1.0
|
||||
# 2024-05-31T23:07:26+02:00
|
||||
|
||||
# get date of current version:
|
||||
git log -1 --format=%aI @strudel/core@1.2.0
|
||||
# 2025-05-01T12:39:24+02:00
|
||||
# might also use todays timestamp if version is not yet released
|
||||
```
|
||||
|
||||
now we know, all patterns between these 2 dates have to receive a version tag (unless they already have one).
|
||||
|
||||
2. get patterns in question
|
||||
|
||||
```sql
|
||||
SELECT *
|
||||
FROM code_v1
|
||||
WHERE code NOT LIKE '%@version%'
|
||||
AND created_at > '2024-05-31T23:07:26+02:00'
|
||||
AND created_at < '2025-05-01T12:39:24+02:00'
|
||||
ORDER BY created_at ASC;
|
||||
```
|
||||
|
||||
this gives us all unversioned patterns that were saved between 1.1.0 and 1.2.0. in this case, it's 9373 patterns!
|
||||
|
||||
3. insert version tags
|
||||
|
||||
we are now ready to insert the version tag to these patterns.
|
||||
before updating thousands of patterns, it's probably a good idea to test if a single one gets udpated:
|
||||
|
||||
```sql
|
||||
UPDATE code_v1
|
||||
SET code = code || E'\n// @version 1.1'
|
||||
WHERE hash = 'Ns2sMB40yIw4';
|
||||
```
|
||||
|
||||
after [verifying](https://strudel.cc/?Ns2sMB40yIw4) that the version tag has been added, let's insert it everywhere:
|
||||
|
||||
```sql
|
||||
UPDATE code_v1
|
||||
SET code = code || E'\n// @version 1.1'
|
||||
WHERE code NOT LIKE '%@version%'
|
||||
AND created_at > '2024-05-31T23:07:26+02:00'
|
||||
AND created_at < '2025-05-01T12:39:24+02:00'
|
||||
```
|
||||
|
||||
4. verify
|
||||
|
||||
we can verify that the edits worked by querying all patterns that contain the new version tag:
|
||||
|
||||
```sql
|
||||
SELECT *
|
||||
FROM code_v1
|
||||
WHERE code LIKE '%@version 1.1%'
|
||||
AND created_at > '2024-05-31T23:07:26+02:00'
|
||||
AND created_at < '2025-05-01T12:39:24+02:00'
|
||||
ORDER BY created_at ASC;
|
||||
```
|
||||
|
||||
|
||||
## Have Fun
|
||||
|
||||
Remember to have fun, and that this project is driven by the passion of volunteers!
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
FROM node:24
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm install pnpm --global
|
||||
|
||||
COPY pnpm-workspace.yaml ./
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY packages/ ./packages/
|
||||
COPY examples/ ./examples/
|
||||
RUN mkdir -p website/public
|
||||
COPY website/package.json ./website/
|
||||
|
||||
RUN pnpm install
|
||||
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 4321
|
||||
|
||||
CMD ["pnpm", "dev"]
|
||||
@@ -3,8 +3,6 @@
|
||||
Live coding patterns on the web
|
||||
https://strudel.cc/
|
||||
|
||||
Development is moving to https://codeberg.org/uzu/strudel
|
||||
|
||||
- Try it here: <https://strudel.cc>
|
||||
- Docs: <https://strudel.cc/learn>
|
||||
- Technical Blog Post: <https://loophole-letters.vercel.app/strudel>
|
||||
@@ -15,7 +13,7 @@ Development is moving to https://codeberg.org/uzu/strudel
|
||||
|
||||
After cloning the project, you can run the REPL locally:
|
||||
|
||||
1. Install [Node.js](https://nodejs.org/)
|
||||
1. Install [Node.js](https://nodejs.org/) 18 or newer
|
||||
2. Install [pnpm](https://pnpm.io/installation)
|
||||
3. Install dependencies by running the following command:
|
||||
```bash
|
||||
@@ -32,19 +30,13 @@ This project is organized into many [packages](./packages), which are also avail
|
||||
|
||||
Read more about how to use these in your own project [here](https://strudel.cc/technical-manual/project-start).
|
||||
|
||||
You will need to abide by the terms of the [GNU Affero Public Licence v3](LICENSE.md). As such, Strudel code can only be shared within free/open source projects under the same license -- see the license for details.
|
||||
You will need to abide by the terms of the [GNU Affero Public Licence v3](LICENSE). As such, Strudel code can only be shared within free/open source projects under the same license -- see the license for details.
|
||||
|
||||
Licensing info for the default sound banks can be found over on the [dough-samples](https://github.com/felixroos/dough-samples/blob/main/README.md) repository.
|
||||
|
||||
## Contributing
|
||||
|
||||
There are many ways to contribute to this project! See [contribution guide](./CONTRIBUTING.md).
|
||||
|
||||
<a href="https://codeberg.org/uzu/strudel/activity/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=tidalcycles/strudel" />
|
||||
</a>
|
||||
|
||||
Made with [contrib.rocks](https://contrib.rocks).
|
||||
There are many ways to contribute to this project! See [contribution guide](./CONTRIBUTING.md). You can find the full list of contributors [here](https://codeberg.org/uzu/strudel/activity/contributors).
|
||||
|
||||
## Community
|
||||
|
||||
@@ -53,3 +45,5 @@ There is a #strudel channel on the TidalCycles discord: <https://discord.com/inv
|
||||
You can also ask questions and find related discussions on the tidal club forum: <https://club.tidalcycles.org/>
|
||||
|
||||
The discord and forum is shared with the haskell (tidal) and python (vortex) siblings of this project.
|
||||
|
||||
We also have a mastodon account: <a rel="me" href="https://social.toplap.org/@strudel">social.toplap.org/@strudel</a>
|
||||
|
||||
@@ -42,6 +42,7 @@ export default [
|
||||
'**/hydra.mjs',
|
||||
'**/jsdoc-synonyms.js',
|
||||
'packages/hs2js/src/hs2js.mjs',
|
||||
'packages/supradough/dough-export.mjs',
|
||||
'**/samples',
|
||||
],
|
||||
},
|
||||
@@ -83,4 +84,14 @@ export default [
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// Properties provided by AudioWorkletGlobalScope
|
||||
files: ['packages/superdough/worklets.mjs'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
currentTime: 'readonly',
|
||||
sampleRate: 'readonly',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -20,5 +20,8 @@
|
||||
"@strudel/tonal": "workspace:*",
|
||||
"@strudel/transpiler": "workspace:*",
|
||||
"@strudel/webaudio": "workspace:*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,5 +14,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@strudel/web": "workspace:*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,5 +18,8 @@
|
||||
"@strudel/transpiler": "workspace:*",
|
||||
"@strudel/webaudio": "workspace:*",
|
||||
"@strudel/tonal": "workspace:*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,5 +13,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,5 +32,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
jsdoc-synonyms.js - Add support for @synonym tag
|
||||
jsdoc-synonyms.js - Add support for @synonyms tag
|
||||
Copyright (C) 2023 Strudel contributors - see <https://codeberg.org/uzu/strudel/activity/contributors>
|
||||
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/>.
|
||||
*/
|
||||
|
||||
+4
-1
@@ -73,5 +73,8 @@
|
||||
"prettier": "^3.4.2",
|
||||
"vitest": "^3.0.4",
|
||||
"vite-plugin-bundle-audioworklet": "workspace:*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,84 +1,464 @@
|
||||
import jsdoc from '../../doc.json';
|
||||
// import { javascriptLanguage } from '@codemirror/lang-javascript';
|
||||
import { autocompletion } from '@codemirror/autocomplete';
|
||||
import { h } from './html';
|
||||
//TODO: fix tonal scale import
|
||||
// import { Scale } from '@tonaljs/tonal';
|
||||
// import { soundMap } from '@strudel/webaudio';
|
||||
let soundMap = undefined;
|
||||
import { complex } from '@strudel/tonal';
|
||||
|
||||
function plaintext(str) {
|
||||
const escapeHtml = (str) => {
|
||||
const div = document.createElement('div');
|
||||
div.innerText = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
};
|
||||
|
||||
const getDocLabel = (doc) => doc.name || doc.longname;
|
||||
const getInnerText = (html) => {
|
||||
var div = document.createElement('div');
|
||||
const stripHtml = (html) => {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
return div.textContent || div.innerText || '';
|
||||
};
|
||||
|
||||
export function Autocomplete({ doc, label }) {
|
||||
return h`<div class="prose dark:prose-invert max-h-[400px] overflow-auto p-2">
|
||||
<h1 class="pt-0 mt-0">${label || getDocLabel(doc)}</h1>
|
||||
${doc.description}
|
||||
<ul>
|
||||
${doc.params?.map(
|
||||
({ name, type, description }) =>
|
||||
`<li>${name} : ${type.names?.join(' | ')} ${description ? ` - ${getInnerText(description)}` : ''}</li>`,
|
||||
)}
|
||||
</ul>
|
||||
<div>
|
||||
${doc.examples?.map((example) => `<div><pre>${plaintext(example)}</pre></div>`)}
|
||||
</div>
|
||||
</div>`[0];
|
||||
/*
|
||||
<pre
|
||||
className="cursor-pointer"
|
||||
onMouseDown={(e) => {
|
||||
console.log('ola!');
|
||||
navigator.clipboard.writeText(example);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{example}
|
||||
</pre>
|
||||
*/
|
||||
const getDocLabel = (doc) => doc.name || doc.longname;
|
||||
|
||||
const buildParamsList = (params) =>
|
||||
params?.length
|
||||
? `
|
||||
<div class="autocomplete-info-params-section">
|
||||
<h4 class="autocomplete-info-section-title">Parameters</h4>
|
||||
<ul class="autocomplete-info-params-list">
|
||||
${params
|
||||
.map(
|
||||
({ name, type, description }) => `
|
||||
<li class="autocomplete-info-param-item">
|
||||
<span class="autocomplete-info-param-name">${name}</span>
|
||||
<span class="autocomplete-info-param-type">${type.names?.join(' | ')}</span>
|
||||
${description ? `<div class="autocomplete-info-param-desc">${stripHtml(description)}</div>` : ''}
|
||||
</li>
|
||||
`,
|
||||
)
|
||||
.join('')}
|
||||
</ul>
|
||||
</div>
|
||||
`
|
||||
: '';
|
||||
|
||||
const buildExamples = (examples) =>
|
||||
examples?.length
|
||||
? `
|
||||
<div class="autocomplete-info-examples-section">
|
||||
<h4 class="autocomplete-info-section-title">Examples</h4>
|
||||
${examples
|
||||
.map(
|
||||
(example) => `
|
||||
<pre class="autocomplete-info-example-code">${escapeHtml(example)}</pre>
|
||||
`,
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
`
|
||||
: '';
|
||||
|
||||
export const Autocomplete = (doc) =>
|
||||
h`
|
||||
<div class="autocomplete-info-container">
|
||||
<div class="autocomplete-info-tooltip">
|
||||
<h3 class="autocomplete-info-function-name">${getDocLabel(doc)}</h3>
|
||||
${doc.synonyms_text ? `<div class="autocomplete-info-function-synonyms">Synonyms: ${doc.synonyms_text}</div>` : ''}
|
||||
${doc.description ? `<div class="autocomplete-info-function-description">${doc.description}</div>` : ''}
|
||||
${buildParamsList(doc.params)}
|
||||
${buildExamples(doc.examples)}
|
||||
</div>
|
||||
</div>
|
||||
`[0];
|
||||
|
||||
const isValidDoc = (doc) => {
|
||||
const label = getDocLabel(doc);
|
||||
return label && !label.startsWith('_') && !['package'].includes(doc.kind);
|
||||
};
|
||||
|
||||
const hasExcludedTags = (doc) =>
|
||||
['superdirtOnly', 'noAutocomplete'].some((tag) => doc.tags?.find((t) => t.originalTitle === tag));
|
||||
|
||||
export function bankCompletions() {
|
||||
// TODO: FIX IMPORT
|
||||
const soundDict = soundMap?.get() ?? {};
|
||||
|
||||
const banks = new Set();
|
||||
for (const key of Object.keys(soundDict)) {
|
||||
const [bank, suffix] = key.split('_');
|
||||
if (suffix && bank) banks.add(bank);
|
||||
}
|
||||
return Array.from(banks)
|
||||
.sort()
|
||||
.map((name) => ({ label: name, type: 'bank' }));
|
||||
}
|
||||
|
||||
const jsdocCompletions = jsdoc.docs
|
||||
.filter(
|
||||
(doc) =>
|
||||
getDocLabel(doc) &&
|
||||
!getDocLabel(doc).startsWith('_') &&
|
||||
!['package'].includes(doc.kind) &&
|
||||
!['superdirtOnly', 'noAutocomplete'].some((tag) => doc.tags?.find((t) => t.originalTitle === tag)),
|
||||
)
|
||||
// https://codemirror.net/docs/ref/#autocomplete.Completion
|
||||
.map((doc) /*: Completion */ => ({
|
||||
label: getDocLabel(doc),
|
||||
// detail: 'xxx', // An optional short piece of information to show (with a different style) after the label.
|
||||
info: () => Autocomplete({ doc }),
|
||||
type: 'function', // https://codemirror.net/docs/ref/#autocomplete.Completion.type
|
||||
}));
|
||||
// Attempt to get all scale names from Tonal TODO: FIX IMPORT
|
||||
let scaleCompletions = [];
|
||||
// try {
|
||||
// scaleCompletions = (Scale.names ? Scale.names() : []).map((name) => ({ label: name, type: 'scale' }));
|
||||
// } catch (e) {
|
||||
// console.warn('[autocomplete] Could not load scale names from Tonal:', e);
|
||||
// }
|
||||
|
||||
export const strudelAutocomplete = (context /* : CompletionContext */) => {
|
||||
let word = context.matchBefore(/\w*/);
|
||||
if (word.from == word.to && !context.explicit) return null;
|
||||
// Valid mode values for voicing
|
||||
const modeCompletions = [
|
||||
{ label: 'below', type: 'mode' },
|
||||
{ label: 'above', type: 'mode' },
|
||||
{ label: 'duck', type: 'mode' },
|
||||
{ label: 'root', type: 'mode' },
|
||||
];
|
||||
|
||||
// Valid chord symbols from ireal dictionary plus empty string for major triads
|
||||
const chordSymbols = ['', ...Object.keys(complex)].sort();
|
||||
const chordSymbolCompletions = chordSymbols.map((symbol) => {
|
||||
if (symbol === '') {
|
||||
return {
|
||||
label: 'major',
|
||||
apply: '',
|
||||
type: 'chord-symbol',
|
||||
};
|
||||
}
|
||||
return {
|
||||
from: word.from,
|
||||
options: jsdocCompletions,
|
||||
/* options: [
|
||||
{ label: 'match', type: 'keyword' },
|
||||
{ label: 'hello', type: 'variable', info: '(World)' },
|
||||
{ label: 'magic', type: 'text', apply: '⠁⭒*.✩.*⭒⠁', detail: 'macro' },
|
||||
], */
|
||||
label: symbol,
|
||||
apply: symbol,
|
||||
type: 'chord-symbol',
|
||||
};
|
||||
});
|
||||
|
||||
export const getSynonymDoc = (doc, synonym) => {
|
||||
const synonyms = doc.synonyms || [];
|
||||
const docLabel = getDocLabel(doc);
|
||||
// Swap `doc.name` in for `s` in the list of synonyms
|
||||
const synonymsWithDoc = [docLabel, ...synonyms].filter((x) => x && x !== synonym);
|
||||
return {
|
||||
...doc,
|
||||
name: synonym,
|
||||
longname: synonym,
|
||||
synonyms: synonymsWithDoc,
|
||||
synonyms_text: synonymsWithDoc.join(', '),
|
||||
};
|
||||
};
|
||||
|
||||
export function isAutoCompletionEnabled(on) {
|
||||
return on
|
||||
? [
|
||||
autocompletion({ override: [strudelAutocomplete] }),
|
||||
//javascriptLanguage.data.of({ autocomplete: strudelAutocomplete }),
|
||||
]
|
||||
: []; // autocompletion({ override: [] })
|
||||
const jsdocCompletions = (() => {
|
||||
const seen = new Set(); // avoid repetition
|
||||
const completions = [];
|
||||
for (const doc of jsdoc.docs) {
|
||||
if (!isValidDoc(doc) || hasExcludedTags(doc)) continue;
|
||||
const docLabel = getDocLabel(doc);
|
||||
// Remove duplicates
|
||||
const synonyms = doc.synonyms || [];
|
||||
let labels = [docLabel, ...synonyms];
|
||||
for (const label of labels) {
|
||||
// https://codemirror.net/docs/ref/#autocomplete.Completion
|
||||
if (label && !seen.has(label)) {
|
||||
seen.add(label);
|
||||
completions.push({
|
||||
label,
|
||||
info: () => Autocomplete(getSynonymDoc(doc, label)),
|
||||
type: 'function', // https://codemirror.net/docs/ref/#autocomplete.Completion.type
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return completions;
|
||||
})();
|
||||
|
||||
// --- Handler functions for each context ---
|
||||
const pitchNames = [
|
||||
'C',
|
||||
'C#',
|
||||
'Db',
|
||||
'D',
|
||||
'D#',
|
||||
'Eb',
|
||||
'E',
|
||||
'E#',
|
||||
'Fb',
|
||||
'F',
|
||||
'F#',
|
||||
'Gb',
|
||||
'G',
|
||||
'G#',
|
||||
'Ab',
|
||||
'A',
|
||||
'A#',
|
||||
'Bb',
|
||||
'B',
|
||||
'B#',
|
||||
'Cb',
|
||||
];
|
||||
|
||||
// Cached regex patterns for scaleHandler
|
||||
const SCALE_NO_QUOTES_REGEX = /scale\(\s*$/;
|
||||
const SCALE_AFTER_COLON_REGEX = /scale\(\s*['"][^'"]*:[^'"]*$/;
|
||||
const SCALE_PRE_COLON_REGEX = /scale\(\s*['"][^'"]*$/;
|
||||
const SCALE_PITCH_MATCH_REGEX = /([A-Ga-g][#b]*)?$/;
|
||||
const SCALE_SPACES_TO_COLON_REGEX = /\s+/g;
|
||||
|
||||
function scaleHandler(context) {
|
||||
// First check for scale context without quotes - block with empty completions
|
||||
let scaleNoQuotesContext = context.matchBefore(SCALE_NO_QUOTES_REGEX);
|
||||
if (scaleNoQuotesContext) {
|
||||
return {
|
||||
from: scaleNoQuotesContext.to,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Check for after-colon context first (more specific)
|
||||
let scaleAfterColonContext = context.matchBefore(SCALE_AFTER_COLON_REGEX);
|
||||
if (scaleAfterColonContext) {
|
||||
const text = scaleAfterColonContext.text;
|
||||
const colonIdx = text.lastIndexOf(':');
|
||||
if (colonIdx !== -1) {
|
||||
const fragment = text.slice(colonIdx + 1);
|
||||
const filteredScales = scaleCompletions.filter((s) => s.label.startsWith(fragment));
|
||||
const options = filteredScales.map((s) => ({
|
||||
...s,
|
||||
apply: s.label.replace(SCALE_SPACES_TO_COLON_REGEX, ':'),
|
||||
}));
|
||||
const from = scaleAfterColonContext.from + colonIdx + 1;
|
||||
return {
|
||||
from,
|
||||
options,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Then check for pre-colon context
|
||||
let scalePreColonContext = context.matchBefore(SCALE_PRE_COLON_REGEX);
|
||||
if (scalePreColonContext) {
|
||||
if (!scalePreColonContext.text.includes(':')) {
|
||||
if (context.explicit) {
|
||||
const text = scalePreColonContext.text;
|
||||
const match = text.match(SCALE_PITCH_MATCH_REGEX);
|
||||
const fragment = match ? match[0] : '';
|
||||
const filtered = pitchNames.filter((p) => p.toLowerCase().startsWith(fragment.toLowerCase()));
|
||||
const from = scalePreColonContext.to - fragment.length;
|
||||
const options = filtered.map((p) => ({ label: p, type: 'pitch' }));
|
||||
return { from, options };
|
||||
} else {
|
||||
return { from: scalePreColonContext.to, options: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cached regex patterns for soundHandler
|
||||
const SOUND_NO_QUOTES_REGEX = /(s|sound)\(\s*$/;
|
||||
const SOUND_WITH_QUOTES_REGEX = /(s|sound)\(\s*['"][^'"]*$/;
|
||||
const SOUND_FRAGMENT_MATCH_REGEX = /(?:[\s[{(<])([\w]*)$/;
|
||||
|
||||
function soundHandler(context) {
|
||||
// First check for sound context without quotes - block with empty completions
|
||||
let soundNoQuotesContext = context.matchBefore(SOUND_NO_QUOTES_REGEX);
|
||||
if (soundNoQuotesContext) {
|
||||
return {
|
||||
from: soundNoQuotesContext.to,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Then check for sound context with quotes - provide completions
|
||||
let soundContext = context.matchBefore(SOUND_WITH_QUOTES_REGEX);
|
||||
if (!soundContext) return null;
|
||||
|
||||
const text = soundContext.text;
|
||||
const quoteIdx = Math.max(text.lastIndexOf('"'), text.lastIndexOf("'"));
|
||||
if (quoteIdx === -1) return null;
|
||||
const inside = text.slice(quoteIdx + 1);
|
||||
const fragMatch = inside.match(SOUND_FRAGMENT_MATCH_REGEX);
|
||||
const fragment = fragMatch ? fragMatch[1] : inside;
|
||||
const soundNames = Object.keys(soundMap?.get() ?? {}).sort();
|
||||
const filteredSounds = soundNames.filter((name) => name.includes(fragment));
|
||||
let options = filteredSounds.map((name) => ({ label: name, type: 'sound' }));
|
||||
const from = soundContext.to - fragment.length;
|
||||
return {
|
||||
from,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
// Cached regex patterns for bankHandler
|
||||
const BANK_NO_QUOTES_REGEX = /bank\(\s*$/;
|
||||
const BANK_WITH_QUOTES_REGEX = /bank\(\s*['"][^'"]*$/;
|
||||
|
||||
function bankHandler(context) {
|
||||
// First check for bank context without quotes - block with empty completions
|
||||
let bankNoQuotesContext = context.matchBefore(BANK_NO_QUOTES_REGEX);
|
||||
if (bankNoQuotesContext) {
|
||||
return {
|
||||
from: bankNoQuotesContext.to,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Then check for bank context with quotes - provide completions
|
||||
let bankMatch = context.matchBefore(BANK_WITH_QUOTES_REGEX);
|
||||
if (!bankMatch) return null;
|
||||
|
||||
const text = bankMatch.text;
|
||||
const quoteIdx = Math.max(text.lastIndexOf('"'), text.lastIndexOf("'"));
|
||||
if (quoteIdx === -1) return null;
|
||||
const inside = text.slice(quoteIdx + 1);
|
||||
const fragment = inside;
|
||||
let banks = bankCompletions();
|
||||
const filteredBanks = banks.filter((b) => b.label.startsWith(fragment));
|
||||
const from = bankMatch.to - fragment.length;
|
||||
return {
|
||||
from,
|
||||
options: filteredBanks,
|
||||
};
|
||||
}
|
||||
|
||||
// Cached regex patterns for modeHandler
|
||||
const MODE_NO_QUOTES_REGEX = /mode\(\s*$/;
|
||||
const MODE_AFTER_COLON_REGEX = /mode\(\s*['"][^'"]*:[^'"]*$/;
|
||||
const MODE_PRE_COLON_REGEX = /mode\(\s*['"][^'"]*$/;
|
||||
const MODE_FRAGMENT_MATCH_REGEX = /(?:[\s[{(<])([\w:]*)$/;
|
||||
|
||||
function modeHandler(context) {
|
||||
// First check for mode context without quotes - block with empty completions
|
||||
let modeNoQuotesContext = context.matchBefore(MODE_NO_QUOTES_REGEX);
|
||||
if (modeNoQuotesContext) {
|
||||
return {
|
||||
from: modeNoQuotesContext.to,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Check for after-colon context first (more specific)
|
||||
let modeAfterColonContext = context.matchBefore(MODE_AFTER_COLON_REGEX);
|
||||
if (modeAfterColonContext) {
|
||||
const text = modeAfterColonContext.text;
|
||||
const colonIdx = text.lastIndexOf(':');
|
||||
if (colonIdx !== -1) {
|
||||
const fragment = text.slice(colonIdx + 1);
|
||||
// For anchor after colon, we can suggest pitch names
|
||||
const filtered = pitchNames.filter((p) => p.toLowerCase().startsWith(fragment.toLowerCase()));
|
||||
const options = filtered.map((p) => ({ label: p, type: 'pitch' }));
|
||||
const from = modeAfterColonContext.from + colonIdx + 1;
|
||||
return {
|
||||
from,
|
||||
options,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Then check for pre-colon context
|
||||
let modeContext = context.matchBefore(MODE_PRE_COLON_REGEX);
|
||||
if (!modeContext) return null;
|
||||
|
||||
const text = modeContext.text;
|
||||
const quoteIdx = Math.max(text.lastIndexOf('"'), text.lastIndexOf("'"));
|
||||
if (quoteIdx === -1) return null;
|
||||
const inside = text.slice(quoteIdx + 1);
|
||||
const fragMatch = inside.match(MODE_FRAGMENT_MATCH_REGEX);
|
||||
const fragment = fragMatch ? fragMatch[1] : inside;
|
||||
const filteredModes = modeCompletions.filter((m) => m.label.startsWith(fragment));
|
||||
const from = modeContext.to - fragment.length;
|
||||
return {
|
||||
from,
|
||||
options: filteredModes,
|
||||
};
|
||||
}
|
||||
|
||||
// Cached regex patterns for chordHandler
|
||||
const CHORD_NO_QUOTES_REGEX = /chord\(\s*$/;
|
||||
const CHORD_WITH_QUOTES_REGEX = /chord\(\s*['"][^'"]*$/;
|
||||
const CHORD_FRAGMENT_MATCH_REGEX = /(?:[\s[{(<])([\w#b+^:-]*)$/;
|
||||
|
||||
function chordHandler(context) {
|
||||
// First check for chord context without quotes - block with empty completions
|
||||
let chordNoQuotesContext = context.matchBefore(CHORD_NO_QUOTES_REGEX);
|
||||
if (chordNoQuotesContext) {
|
||||
return {
|
||||
from: chordNoQuotesContext.to,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Then check for chord context with quotes - provide completions
|
||||
let chordContext = context.matchBefore(CHORD_WITH_QUOTES_REGEX);
|
||||
if (!chordContext) return null;
|
||||
|
||||
const text = chordContext.text;
|
||||
const quoteIdx = Math.max(text.lastIndexOf('"'), text.lastIndexOf("'"));
|
||||
if (quoteIdx === -1) return null;
|
||||
const inside = text.slice(quoteIdx + 1);
|
||||
|
||||
// Use same fragment matching as sound/mode for expressions like "<G Am>"
|
||||
const fragMatch = inside.match(CHORD_FRAGMENT_MATCH_REGEX);
|
||||
const fragment = fragMatch ? fragMatch[1] : inside;
|
||||
|
||||
// Check if fragment contains any pitch name at start (for root + symbol)
|
||||
let rootMatch = null;
|
||||
let symbolFragment = fragment;
|
||||
for (const pitch of pitchNames) {
|
||||
if (fragment.toLowerCase().startsWith(pitch.toLowerCase())) {
|
||||
rootMatch = pitch;
|
||||
symbolFragment = fragment.slice(pitch.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (rootMatch) {
|
||||
// We have a root, now complete chord symbols
|
||||
const filteredSymbols = chordSymbolCompletions.filter((s) =>
|
||||
s.label.toLowerCase().startsWith(symbolFragment.toLowerCase()),
|
||||
);
|
||||
|
||||
// Create completions that replace the entire chord, not just the symbol part
|
||||
const options = filteredSymbols;
|
||||
|
||||
const from = chordContext.to - symbolFragment.length;
|
||||
return { from, options };
|
||||
} else {
|
||||
// No root yet, complete with pitch names
|
||||
const filteredPitches = pitchNames.filter((p) => p.toLowerCase().startsWith(fragment.toLowerCase()));
|
||||
const options = filteredPitches.map((p) => ({ label: p, type: 'pitch' }));
|
||||
const from = chordContext.to - fragment.length;
|
||||
return { from, options };
|
||||
}
|
||||
}
|
||||
|
||||
// Cached regex patterns for fallbackHandler
|
||||
const FALLBACK_WORD_REGEX = /\w*/;
|
||||
|
||||
function fallbackHandler(context) {
|
||||
const word = context.matchBefore(FALLBACK_WORD_REGEX);
|
||||
if (word && word.from === word.to && !context.explicit) return null;
|
||||
if (word) {
|
||||
return {
|
||||
from: word.from,
|
||||
options: jsdocCompletions,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const handlers = [
|
||||
soundHandler,
|
||||
bankHandler,
|
||||
chordHandler,
|
||||
scaleHandler,
|
||||
modeHandler,
|
||||
// this handler *must* be last
|
||||
fallbackHandler,
|
||||
];
|
||||
|
||||
export const strudelAutocomplete = (context) => {
|
||||
for (const handler of handlers) {
|
||||
const result = handler(context);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const isAutoCompletionEnabled = (enabled) =>
|
||||
enabled ? [autocompletion({ override: [strudelAutocomplete], closeOnBlur: false })] : [];
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
keymap,
|
||||
highlightSpecialChars,
|
||||
drawSelection,
|
||||
highlightActiveLine,
|
||||
dropCursor,
|
||||
rectangularSelection,
|
||||
crosshairCursor,
|
||||
lineNumbers,
|
||||
highlightActiveLineGutter,
|
||||
} from '@codemirror/view';
|
||||
import {
|
||||
defaultHighlightStyle,
|
||||
syntaxHighlighting,
|
||||
bracketMatching,
|
||||
foldGutter,
|
||||
foldKeymap,
|
||||
} from '@codemirror/language';
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||||
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
||||
import { completionKeymap, closeBracketsKeymap } from '@codemirror/autocomplete';
|
||||
|
||||
// Taken + slightly modified from https://github.com/codemirror/basic-setup/blob/main/src/codemirror.ts
|
||||
|
||||
export const basicSetup = (() => [
|
||||
// lineNumbers(),
|
||||
// highlightActiveLineGutter(),
|
||||
highlightSpecialChars(),
|
||||
history(),
|
||||
// foldGutter(),
|
||||
// drawSelection(),
|
||||
dropCursor(),
|
||||
// EditorState.allowMultipleSelections.of(true),
|
||||
// indentOnInput(),
|
||||
// syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
// autocompletion(),
|
||||
rectangularSelection(),
|
||||
crosshairCursor(),
|
||||
// highlightActiveLine(),
|
||||
// highlightSelectionMatches(),
|
||||
keymap.of([
|
||||
...closeBracketsKeymap,
|
||||
...defaultKeymap,
|
||||
// ...searchKeymap,
|
||||
...historyKeymap,
|
||||
// ...foldKeymap,
|
||||
// ...completionKeymap,
|
||||
]),
|
||||
])();
|
||||
|
||||
/// A minimal set of extensions to create a functional editor. Only
|
||||
/// includes [the default keymap](#commands.defaultKeymap), [undo
|
||||
/// history](#commands.history), [special character
|
||||
/// highlighting](#view.highlightSpecialChars), [custom selection
|
||||
/// drawing](#view.drawSelection), and [default highlight
|
||||
/// style](#language.defaultHighlightStyle).
|
||||
export const minimalSetup = (() => [
|
||||
highlightSpecialChars(),
|
||||
history(),
|
||||
drawSelection(),
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||
])();
|
||||
@@ -1,29 +1,31 @@
|
||||
import { closeBrackets } from '@codemirror/autocomplete';
|
||||
export { toggleComment, toggleBlockComment, toggleLineComment, toggleBlockCommentByLine } from '@codemirror/commands';
|
||||
// import { search, highlightSelectionMatches } from '@codemirror/search';
|
||||
import { history } from '@codemirror/commands';
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { defaultHighlightStyle, syntaxHighlighting, bracketMatching } from '@codemirror/language';
|
||||
import { indentWithTab, toggleLineComment } from '@codemirror/commands';
|
||||
import { javascript, javascriptLanguage } from '@codemirror/lang-javascript';
|
||||
import { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||
import { Compartment, EditorState, Prec } from '@codemirror/state';
|
||||
import {
|
||||
drawSelection,
|
||||
EditorView,
|
||||
highlightActiveLineGutter,
|
||||
highlightActiveLine,
|
||||
highlightActiveLineGutter,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
drawSelection,
|
||||
} from '@codemirror/view';
|
||||
import { repl, registerControl } from '@strudel/core';
|
||||
import { Drawer, cleanupDraw } from '@strudel/draw';
|
||||
import { persistentAtom } from '@nanostores/persistent';
|
||||
import { logger, registerControl, repl } from '@strudel/core';
|
||||
import { cleanupDraw, Drawer } from '@strudel/draw';
|
||||
|
||||
import { isAutoCompletionEnabled } from './autocomplete.mjs';
|
||||
import { isTooltipEnabled } from './tooltip.mjs';
|
||||
import { basicSetup } from './basicSetup.mjs';
|
||||
import { flash, isFlashEnabled } from './flash.mjs';
|
||||
import { highlightMiniLocations, isPatternHighlightingEnabled, updateMiniLocations } from './highlight.mjs';
|
||||
import { keybindings } from './keybindings.mjs';
|
||||
import { initTheme, activateTheme, theme } from './themes.mjs';
|
||||
import { sliderPlugin, updateSliderWidgets } from './slider.mjs';
|
||||
import { widgetPlugin, updateWidgets } from './widget.mjs';
|
||||
import { persistentAtom } from '@nanostores/persistent';
|
||||
import { activateTheme, initTheme, theme } from './themes.mjs';
|
||||
import { isTooltipEnabled } from './tooltip.mjs';
|
||||
import { updateWidgets, widgetPlugin } from './widget.mjs';
|
||||
|
||||
export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands';
|
||||
|
||||
const extensions = {
|
||||
isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []),
|
||||
@@ -37,6 +39,14 @@ const extensions = {
|
||||
isActiveLineHighlighted: (on) => (on ? [highlightActiveLine(), highlightActiveLineGutter()] : []),
|
||||
isFlashEnabled,
|
||||
keybindings,
|
||||
isTabIndentationEnabled: (on) => (on ? keymap.of([indentWithTab]) : []),
|
||||
isMultiCursorEnabled: (on) =>
|
||||
on
|
||||
? [
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
EditorView.clickAddsSelectionRange.of((ev) => ev.metaKey || ev.ctrlKey),
|
||||
]
|
||||
: [],
|
||||
};
|
||||
const compartments = Object.fromEntries(Object.keys(extensions).map((key) => [key, new Compartment()]));
|
||||
|
||||
@@ -51,6 +61,8 @@ export const defaultSettings = {
|
||||
isFlashEnabled: true,
|
||||
isTooltipEnabled: false,
|
||||
isLineWrappingEnabled: false,
|
||||
isTabIndentationEnabled: false,
|
||||
isMultiCursorEnabled: false,
|
||||
theme: 'strudelTheme',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 18,
|
||||
@@ -62,7 +74,7 @@ export const codemirrorSettings = persistentAtom('codemirror-settings', defaultS
|
||||
});
|
||||
|
||||
// https://codemirror.net/docs/guide/
|
||||
export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, root }) {
|
||||
export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, root, mondo }) {
|
||||
const settings = codemirrorSettings.get();
|
||||
const initialSettings = Object.keys(compartments).map((key) =>
|
||||
compartments[key].of(extensions[key](parseBooleans(settings[key]))),
|
||||
@@ -75,13 +87,17 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
|
||||
/* search(),
|
||||
highlightSelectionMatches(), */
|
||||
...initialSettings,
|
||||
javascript(),
|
||||
basicSetup,
|
||||
mondo ? [] : javascript(),
|
||||
javascriptLanguage.data.of({
|
||||
closeBrackets: { brackets: ['(', '[', '{', "'", '"', '<'] },
|
||||
bracketMatching: { brackets: ['(', '[', '{', "'", '"', '<'] },
|
||||
}),
|
||||
sliderPlugin,
|
||||
widgetPlugin,
|
||||
// indentOnInput(), // works without. already brought with javascript extension?
|
||||
// bracketMatching(), // does not do anything
|
||||
// indentOnInput(), // works without. already brought with javascript
|
||||
// extension? bracketMatching(), // does not do anything
|
||||
syntaxHighlighting(defaultHighlightStyle),
|
||||
history(),
|
||||
EditorView.updateListener.of((v) => onChange(v)),
|
||||
drawSelection({ cursorBlinkRate: 0 }),
|
||||
Prec.highest(
|
||||
@@ -104,13 +120,13 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
|
||||
run: () => onStop?.(),
|
||||
},
|
||||
/* {
|
||||
key: 'Ctrl-Shift-.',
|
||||
run: () => (onPanic ? onPanic() : onStop?.()),
|
||||
},
|
||||
{
|
||||
key: 'Ctrl-Shift-Enter',
|
||||
run: () => (onReEvaluate ? onReEvaluate() : onEvaluate?.()),
|
||||
}, */
|
||||
key: 'Ctrl-Shift-.',
|
||||
run: () => (onPanic ? onPanic() : onStop?.()),
|
||||
},
|
||||
{
|
||||
key: 'Ctrl-Shift-Enter',
|
||||
run: () => (onReEvaluate ? onReEvaluate() : onEvaluate?.()),
|
||||
}, */
|
||||
]),
|
||||
),
|
||||
],
|
||||
@@ -191,7 +207,8 @@ export class StrudelMirror {
|
||||
updateWidgets(this.editor, widgets);
|
||||
updateMiniLocations(this.editor, this.miniLocations);
|
||||
replOptions?.afterEval?.(options);
|
||||
// if no painters are set (.onPaint was not called), then we only need the present moment (for highlighting)
|
||||
// if no painters are set (.onPaint was not called), then we only need
|
||||
// the present moment (for highlighting)
|
||||
const drawTime = options.pattern.getPainters().length ? this.drawTime : [0, 0];
|
||||
this.drawer.setDrawTime(drawTime);
|
||||
// invalidate drawer after we've set the appropriate drawTime
|
||||
@@ -209,6 +226,7 @@ export class StrudelMirror {
|
||||
},
|
||||
onEvaluate: () => this.evaluate(),
|
||||
onStop: () => this.stop(),
|
||||
mondo: replOptions.mondo,
|
||||
});
|
||||
const cmEditor = this.root.querySelector('.cm-editor');
|
||||
if (cmEditor) {
|
||||
@@ -229,6 +247,33 @@ export class StrudelMirror {
|
||||
}
|
||||
};
|
||||
document.addEventListener('start-repl', this.onStartRepl);
|
||||
|
||||
// Handle global evaluation requests (e.g., from Vim :w)
|
||||
this.onEvaluateRequest = (e) => {
|
||||
try {
|
||||
// Evaluate current editor on repl-evaluate
|
||||
logger('[repl] evaluate via event');
|
||||
this.evaluate();
|
||||
e?.cancelable && e.preventDefault?.();
|
||||
} catch (err) {
|
||||
console.error('Error handling repl-evaluate event', err);
|
||||
}
|
||||
};
|
||||
document.addEventListener('repl-evaluate', this.onEvaluateRequest);
|
||||
document.addEventListener('repl-stop', this.onStopRequest);
|
||||
|
||||
// Toggle comments requested from Vim (gc)
|
||||
this.onToggleComment = (e) => {
|
||||
try {
|
||||
// Honor selections; toggleLineComment handles both selections and
|
||||
// single line
|
||||
toggleLineComment(this.editor);
|
||||
e?.cancelable && e.preventDefault?.();
|
||||
} catch (err) {
|
||||
console.error('Error handling repl-toggle-comment event', err);
|
||||
}
|
||||
};
|
||||
document.addEventListener('repl-toggle-comment', this.onToggleComment);
|
||||
}
|
||||
draw(haps, time, painters) {
|
||||
painters?.forEach((painter) => painter(this.drawContext, time, haps, this.drawTime));
|
||||
@@ -255,6 +300,16 @@ export class StrudelMirror {
|
||||
async stop() {
|
||||
this.repl.scheduler.stop();
|
||||
}
|
||||
|
||||
// Listen for global stop requests (e.g., from Vim :q)
|
||||
onStopRequest = (e) => {
|
||||
try {
|
||||
this.stop();
|
||||
e?.cancelable && e.preventDefault?.();
|
||||
} catch (err) {
|
||||
console.error('Error handling repl-stop event', err);
|
||||
}
|
||||
};
|
||||
async toggle() {
|
||||
if (this.repl.scheduler.started) {
|
||||
this.repl.stop();
|
||||
@@ -330,11 +385,18 @@ export class StrudelMirror {
|
||||
}
|
||||
}
|
||||
setCode(code) {
|
||||
const changes = { from: 0, to: this.editor.state.doc.length, insert: code };
|
||||
const changes = {
|
||||
from: 0,
|
||||
to: this.editor.state.doc.length,
|
||||
insert: code,
|
||||
};
|
||||
this.editor.dispatch({ changes });
|
||||
}
|
||||
clear() {
|
||||
this.onStartRepl && document.removeEventListener('start-repl', this.onStartRepl);
|
||||
this.onEvaluateRequest && document.removeEventListener('repl-evaluate', this.onEvaluateRequest);
|
||||
this.onStopRequest && document.removeEventListener('repl-stop', this.onStopRequest);
|
||||
this.onToggleComment && document.removeEventListener('repl-toggle-comment', this.onToggleComment);
|
||||
}
|
||||
getCursorLocation() {
|
||||
return this.editor.state.selection.main.head;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RangeSetBuilder, StateEffect, StateField } from '@codemirror/state';
|
||||
import { RangeSetBuilder, StateEffect, StateField, Prec } from '@codemirror/state';
|
||||
import { Decoration, EditorView } from '@codemirror/view';
|
||||
|
||||
export const setMiniLocations = StateEffect.define();
|
||||
@@ -134,5 +134,5 @@ export const isPatternHighlightingEnabled = (on, config) => {
|
||||
setTimeout(() => {
|
||||
updateMiniLocations(config.editor, config.miniLocations);
|
||||
}, 100);
|
||||
return on ? highlightExtension : [];
|
||||
return on ? Prec.highest(highlightExtension) : [];
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const parser = typeof DOMParser !== 'undefined' ? new DOMParser() : null;
|
||||
export let html = (string) => {
|
||||
return parser?.parseFromString(string, 'text/html').querySelectorAll('*');
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = string.trim();
|
||||
return template.content.childNodes;
|
||||
};
|
||||
let parseChunk = (chunk) => {
|
||||
if (Array.isArray(chunk)) return chunk.flat().join('');
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { defaultKeymap } from '@codemirror/commands';
|
||||
import { Prec } from '@codemirror/state';
|
||||
import { keymap, ViewPlugin } from '@codemirror/view';
|
||||
// import { searchKeymap } from '@codemirror/search';
|
||||
import { emacs } from '@replit/codemirror-emacs';
|
||||
import { vim } from '@replit/codemirror-vim';
|
||||
import { vim, Vim } from '@replit/codemirror-vim';
|
||||
// import { vim } from './vim_test.mjs';
|
||||
import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
|
||||
import { defaultKeymap, historyKeymap } from '@codemirror/commands';
|
||||
import { logger } from '@strudel/core';
|
||||
|
||||
const vscodePlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
@@ -18,14 +20,118 @@ const vscodePlugin = ViewPlugin.fromClass(
|
||||
);
|
||||
const vscodeExtension = (options) => [vscodePlugin].concat(options ?? []);
|
||||
|
||||
// Map Vim :w to trigger the same action as evaluation. We dispatch a custom
|
||||
// event 'repl-evaluate' that the editor listens for, and also simulate
|
||||
// Ctrl+Enter/Alt+Enter as a fallback. We log to the Strudel logger so it
|
||||
// appears in the Console panel.
|
||||
try {
|
||||
if (Vim && typeof Vim.defineEx === 'function') {
|
||||
// Map gc to toggle line comments by dispatching a custom event that our
|
||||
// CodeMirror integration listens to. This avoids depending on Vim's
|
||||
// internal actions and works with current selections/visual mode.
|
||||
try {
|
||||
Vim.defineAction('strudelToggleComment', (cm) => {
|
||||
const view = cm?.view || cm;
|
||||
try {
|
||||
const ev = new CustomEvent('repl-toggle-comment', { detail: { source: 'vim', view }, cancelable: true });
|
||||
document.dispatchEvent(ev);
|
||||
} catch (e) {
|
||||
console.error('strudelToggleComment dispatch failed', e);
|
||||
}
|
||||
});
|
||||
Vim.mapCommand('gc', 'action', 'strudelToggleComment', {}, { context: 'normal' });
|
||||
Vim.mapCommand('gc', 'action', 'strudelToggleComment', {}, { context: 'visual' });
|
||||
} catch (e) {
|
||||
console.error('Vim gc mapping failed', e);
|
||||
}
|
||||
|
||||
// :q to pause/stop
|
||||
Vim.defineEx('quit', 'q', (cm) => {
|
||||
try {
|
||||
const view = cm?.view || cm;
|
||||
// First try dispatching our custom stop event, then fallback to Alt+.
|
||||
let handled = false;
|
||||
try {
|
||||
const ev = new CustomEvent('repl-stop', { detail: { source: 'vim', view }, cancelable: true });
|
||||
handled = document.dispatchEvent(ev) === false;
|
||||
} catch (e) {
|
||||
console.error('Error dispatching repl-stop event', e);
|
||||
}
|
||||
if (!handled) {
|
||||
const altDot = new KeyboardEvent('keydown', {
|
||||
key: '.',
|
||||
code: 'Period',
|
||||
altKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
view?.dom?.dispatchEvent?.(altDot);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error dispatching :q stop event', e);
|
||||
}
|
||||
});
|
||||
|
||||
// :w to evaluate
|
||||
Vim.defineEx('write', 'w', (cm) => {
|
||||
const view = cm?.view || cm; // CM6 Vim passes either an object with view or the view itself
|
||||
try {
|
||||
view?.focus?.();
|
||||
// Let the app know this came from Vim :w
|
||||
try {
|
||||
logger('[vim] :w — evaluating code');
|
||||
} catch (e) {
|
||||
console.error('Error logging Vim :w evaluation', e);
|
||||
}
|
||||
// Dispatch a dedicated evaluate event first
|
||||
let handled = false;
|
||||
try {
|
||||
const ev = new CustomEvent('repl-evaluate', { detail: { source: 'vim', view }, cancelable: true });
|
||||
handled = document.dispatchEvent(ev) === false; // false means preventDefault was called
|
||||
} catch (e) {
|
||||
console.error('Error dispatching repl-evaluate event', e);
|
||||
}
|
||||
if (handled) {
|
||||
return;
|
||||
}
|
||||
// Try Ctrl+Enter first if not handled by custom event
|
||||
const ctrlEnter = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
view?.dom?.dispatchEvent?.(ctrlEnter);
|
||||
// If not handled (no handler called preventDefault), try Alt+Enter as
|
||||
// fallback
|
||||
if (!ctrlEnter.defaultPrevented) {
|
||||
const altEnter = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
altKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
view?.dom?.dispatchEvent?.(altEnter);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error dispatching :w evaluation event', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Vim ex command setup failed (defineEx missing or Vim unavailable)', e);
|
||||
}
|
||||
|
||||
const keymaps = {
|
||||
vim,
|
||||
emacs,
|
||||
codemirror: () => keymap.of(defaultKeymap),
|
||||
vscode: vscodeExtension,
|
||||
};
|
||||
|
||||
export function keybindings(name) {
|
||||
const active = keymaps[name];
|
||||
return [keymap.of(defaultKeymap), keymap.of(historyKeymap), active ? active() : []];
|
||||
// keymap.of(searchKeymap),
|
||||
return [active ? Prec.high(active()) : []];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strudel/codemirror",
|
||||
"version": "1.2.2",
|
||||
"version": "1.2.6",
|
||||
"description": "Codemirror Extensions for Strudel",
|
||||
"main": "index.mjs",
|
||||
"publishConfig": {
|
||||
@@ -42,14 +42,20 @@
|
||||
"@lezer/highlight": "^1.2.1",
|
||||
"@nanostores/persistent": "^0.10.2",
|
||||
"@replit/codemirror-emacs": "^6.1.0",
|
||||
"@replit/codemirror-vim": "^6.2.1",
|
||||
"@replit/codemirror-vim": "^6.3.0",
|
||||
"@replit/codemirror-vscode-keymap": "^6.0.2",
|
||||
"@strudel/core": "workspace:*",
|
||||
"@strudel/draw": "workspace:*",
|
||||
"@strudel/tonal": "workspace:*",
|
||||
"@strudel/transpiler": "workspace:*",
|
||||
"nanostores": "^0.11.3"
|
||||
"@tonaljs/tonal": "^4.10.0",
|
||||
"nanostores": "^0.11.3",
|
||||
"superdough": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { hoverTooltip } from '@codemirror/view';
|
||||
import jsdoc from '../../doc.json';
|
||||
import { Autocomplete } from './autocomplete.mjs';
|
||||
import { Autocomplete, getSynonymDoc } from './autocomplete.mjs';
|
||||
|
||||
const getDocLabel = (doc) => doc.name || doc.longname;
|
||||
|
||||
@@ -52,10 +52,11 @@ export const strudelTooltip = hoverTooltip(
|
||||
let entry = jsdoc.docs.filter((doc) => getDocLabel(doc) === word)[0];
|
||||
if (!entry) {
|
||||
// Try for synonyms
|
||||
entry = jsdoc.docs.filter((doc) => doc.synonyms && doc.synonyms.includes(word))[0];
|
||||
if (!entry) {
|
||||
const doc = jsdoc.docs.filter((doc) => doc.synonyms && doc.synonyms.includes(word))[0];
|
||||
if (!doc) {
|
||||
return null;
|
||||
}
|
||||
entry = getSynonymDoc(doc, word);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -66,7 +67,7 @@ export const strudelTooltip = hoverTooltip(
|
||||
create(view) {
|
||||
let dom = document.createElement('div');
|
||||
dom.className = 'strudel-tooltip';
|
||||
const ac = Autocomplete({ doc: entry, label: word });
|
||||
const ac = Autocomplete(entry);
|
||||
dom.appendChild(ac);
|
||||
return { dom };
|
||||
},
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, bench } from 'vitest';
|
||||
|
||||
import { calculateTactus, sequence, stack } from '../index.mjs';
|
||||
import { calculateSteps, sequence, stack } from '../index.mjs';
|
||||
|
||||
const pat64 = sequence(...Array(64).keys());
|
||||
|
||||
describe('steps', () => {
|
||||
calculateTactus(true);
|
||||
calculateSteps(true);
|
||||
bench(
|
||||
'+tactus',
|
||||
() => {
|
||||
@@ -14,7 +14,7 @@ describe('steps', () => {
|
||||
{ time: 1000 },
|
||||
);
|
||||
|
||||
calculateTactus(false);
|
||||
calculateSteps(false);
|
||||
bench(
|
||||
'-tactus',
|
||||
() => {
|
||||
@@ -25,7 +25,7 @@ describe('steps', () => {
|
||||
});
|
||||
|
||||
describe('stack', () => {
|
||||
calculateTactus(true);
|
||||
calculateSteps(true);
|
||||
bench(
|
||||
'+tactus',
|
||||
() => {
|
||||
@@ -34,7 +34,7 @@ describe('stack', () => {
|
||||
{ time: 1000 },
|
||||
);
|
||||
|
||||
calculateTactus(false);
|
||||
calculateSteps(false);
|
||||
bench(
|
||||
'-tactus',
|
||||
() => {
|
||||
@@ -43,4 +43,4 @@ describe('stack', () => {
|
||||
{ time: 1000 },
|
||||
);
|
||||
});
|
||||
calculateTactus(true);
|
||||
calculateSteps(true);
|
||||
|
||||
+811
-52
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ This program is free software: you can redistribute it and/or modify it under th
|
||||
*/
|
||||
|
||||
import createClock from './zyklus.mjs';
|
||||
import { logger } from './logger.mjs';
|
||||
import { errorLogger, logger } from './logger.mjs';
|
||||
|
||||
export class Cyclist {
|
||||
constructor({
|
||||
@@ -57,7 +57,7 @@ export class Cyclist {
|
||||
}
|
||||
|
||||
// query the pattern for events
|
||||
const haps = this.pattern.queryArc(begin, end, { _cps: this.cps });
|
||||
const haps = this.pattern.queryArc(begin, end, { _cps: this.cps, cyclist: 'cyclist' });
|
||||
|
||||
haps.forEach((hap) => {
|
||||
if (hap.hasOnset()) {
|
||||
@@ -67,6 +67,7 @@ export class Cyclist {
|
||||
// the following line is dumb and only here for backwards compatibility
|
||||
// see https://codeberg.org/uzu/strudel/pulls/1004
|
||||
const deadline = targetTime - phase;
|
||||
// this onTrigger has another signature
|
||||
onTrigger?.(hap, deadline, duration, this.cps, targetTime);
|
||||
if (hap.value.cps !== undefined && this.cps != hap.value.cps) {
|
||||
this.cps = hap.value.cps;
|
||||
@@ -75,7 +76,7 @@ export class Cyclist {
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
logger(`[cyclist] error: ${e.message}`);
|
||||
errorLogger(e);
|
||||
onError?.(e);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ https://rohandrape.net/?t=hmt
|
||||
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 { timeCat, register, silence } from './pattern.mjs';
|
||||
import { timeCat, register, silence, stack, pure, _morph } from './pattern.mjs';
|
||||
import { rotate, flatten, splitAt, zipWith } from './util.mjs';
|
||||
import Fraction, { lcm } from './fraction.mjs';
|
||||
|
||||
@@ -35,18 +35,18 @@ const right = function (n, x) {
|
||||
return result;
|
||||
};
|
||||
|
||||
const _bjork = function (n, x) {
|
||||
const _bjorklund = function (n, x) {
|
||||
const [ons, offs] = n;
|
||||
return Math.min(ons, offs) <= 1 ? [n, x] : _bjork(...(ons > offs ? left(n, x) : right(n, x)));
|
||||
return Math.min(ons, offs) <= 1 ? [n, x] : _bjorklund(...(ons > offs ? left(n, x) : right(n, x)));
|
||||
};
|
||||
|
||||
export const bjork = function (ons, steps) {
|
||||
export const bjorklund = function (ons, steps) {
|
||||
const inverted = ons < 0;
|
||||
const absOns = Math.abs(ons);
|
||||
const offs = steps - absOns;
|
||||
const ones = Array(absOns).fill([1]);
|
||||
const zeros = Array(offs).fill([0]);
|
||||
const result = _bjork([absOns, offs], [ones, zeros]);
|
||||
const result = _bjorklund([absOns, offs], [ones, zeros]);
|
||||
const pattern = flatten(result[1][0]).concat(flatten(result[1][1]));
|
||||
return inverted ? pattern.map((x) => 1 - x) : pattern;
|
||||
};
|
||||
@@ -128,7 +128,7 @@ export const bjork = function (ons, steps) {
|
||||
*/
|
||||
|
||||
const _euclidRot = function (pulses, steps, rotation) {
|
||||
const b = bjork(pulses, steps);
|
||||
const b = bjorklund(pulses, steps);
|
||||
if (rotation) {
|
||||
return rotate(b, -rotation);
|
||||
}
|
||||
@@ -139,6 +139,14 @@ export const euclid = register('euclid', function (pulses, steps, pat) {
|
||||
return pat.struct(_euclidRot(pulses, steps, 0));
|
||||
});
|
||||
|
||||
export const bjork = register('bjork', function (euc, pat) {
|
||||
if (!Array.isArray(euc)) {
|
||||
euc = [euc];
|
||||
}
|
||||
const [pulses, steps = pulses, rot = 0] = euc;
|
||||
return pat.struct(_euclidRot(pulses, steps, rot));
|
||||
});
|
||||
|
||||
export const { euclidrot, euclidRot } = register(['euclidrot', 'euclidRot'], function (pulses, steps, rotation, pat) {
|
||||
return pat.struct(_euclidRot(pulses, steps, rotation));
|
||||
});
|
||||
@@ -188,3 +196,26 @@ export const euclidLegato = register(['euclidLegato'], function (pulses, steps,
|
||||
export const euclidLegatoRot = register(['euclidLegatoRot'], function (pulses, steps, rotation, pat) {
|
||||
return _euclidLegato(pulses, steps, rotation, pat);
|
||||
});
|
||||
|
||||
/**
|
||||
* A 'euclid' variant with an additional parameter that morphs the resulting
|
||||
* rhythm from 0 (no morphing) to 1 (completely 'even'). For example
|
||||
* `sound("bd").euclidish(3,8,0)` would be the same as
|
||||
* `sound("bd").euclid(3,8)`, and `sound("bd").euclidish(3,8,1)` would be the
|
||||
* same as `sound("bd bd bd")`. `sound("bd").euclidish(3,8,0.5)` would have a
|
||||
* groove somewhere between.
|
||||
* Inspired by the work of Malcom Braff.
|
||||
* @name euclidish
|
||||
* @synonyms eish
|
||||
* @memberof Pattern
|
||||
* @param {number} pulses the number of onsets
|
||||
* @param {number} steps the number of steps to fill
|
||||
* @param {number} groove exists between the extremes of 0 (straight euclidian) and 1 (straight pulse)
|
||||
* @example
|
||||
* sound("hh").euclidish(7,12,sine.slow(8))
|
||||
* .pan(sine.slow(8))
|
||||
*/
|
||||
export const { euclidish, eish } = register(['euclidish', 'eish'], function (pulses, steps, perc, pat) {
|
||||
const morphed = _morph(bjorklund(pulses, steps), new Array(pulses).fill(1), perc);
|
||||
return pat.struct(morphed).setSteps(steps);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export const strudelScope = {};
|
||||
|
||||
export const evalScope = async (...args) => {
|
||||
const results = await Promise.allSettled(args);
|
||||
const modules = results.filter((result) => result.status === 'fulfilled').map((r) => r.value);
|
||||
@@ -18,6 +20,7 @@ export const evalScope = async (...args) => {
|
||||
modules.forEach((module) => {
|
||||
Object.entries(module).forEach(([name, value]) => {
|
||||
globalThis[name] = value;
|
||||
strudelScope[name] = value;
|
||||
});
|
||||
});
|
||||
return modules;
|
||||
|
||||
@@ -126,6 +126,8 @@ export const lcm = (...fractions) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const isFraction = (x) => x instanceof Fraction;
|
||||
|
||||
fraction._original = Fraction;
|
||||
|
||||
export default fraction;
|
||||
|
||||
@@ -4,6 +4,7 @@ Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import Fraction from './fraction.mjs';
|
||||
import { stringifyValues } from './util.mjs';
|
||||
|
||||
export class Hap {
|
||||
/*
|
||||
@@ -148,13 +149,7 @@ export class Hap {
|
||||
}
|
||||
|
||||
showWhole(compact = false) {
|
||||
return `${this.whole == undefined ? '~' : this.whole.show()}: ${
|
||||
typeof this.value === 'object'
|
||||
? compact
|
||||
? JSON.stringify(this.value).slice(1, -1).replaceAll('"', '').replaceAll(',', ' ')
|
||||
: JSON.stringify(this.value)
|
||||
: this.value
|
||||
}`;
|
||||
return `${this.whole == undefined ? '~' : this.whole.show()}: ${stringifyValues(this.value, compact)}`;
|
||||
}
|
||||
|
||||
combineContext(b) {
|
||||
|
||||
@@ -4,6 +4,13 @@ let debounce = 1000,
|
||||
lastMessage,
|
||||
lastTime;
|
||||
|
||||
export function errorLogger(e, origin = 'cyclist') {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error(e);
|
||||
}
|
||||
logger(`[${origin}] error: ${e.message}`);
|
||||
}
|
||||
|
||||
export function logger(message, type, data = {}) {
|
||||
let t = performance.now();
|
||||
if (lastMessage === message && t - lastTime < debounce) {
|
||||
|
||||
@@ -11,7 +11,6 @@ export class NeoCyclist {
|
||||
constructor({ onTrigger, onToggle, getTime }) {
|
||||
this.started = false;
|
||||
this.cps = 0.5;
|
||||
this.lastTick = 0; // absolute time when last tick (clock callback) happened
|
||||
this.getTime = getTime; // get absolute time
|
||||
this.time_at_last_tick_message = 0;
|
||||
// the clock of the worker and the audio context clock can drift apart over time
|
||||
@@ -39,8 +38,7 @@ export class NeoCyclist {
|
||||
if (this.started === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const haps = this.pattern.queryArc(begin, end, { _cps: this.cps });
|
||||
const haps = this.pattern.queryArc(begin, end, { _cps: this.cps, cyclist: 'neocyclist' });
|
||||
haps.forEach((hap) => {
|
||||
if (hap.hasOnset()) {
|
||||
const timeUntilTrigger = cycleToSeconds(hap.whole.begin - this.cycle, this.cps);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strudel/core",
|
||||
"version": "1.2.2",
|
||||
"version": "1.2.5",
|
||||
"description": "Port of Tidal Cycles to JavaScript",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
@@ -37,5 +37,8 @@
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.11",
|
||||
"vitest": "^3.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
+375
-29
@@ -5,7 +5,7 @@ This program is free software: you can redistribute it and/or modify it under th
|
||||
*/
|
||||
|
||||
import TimeSpan from './timespan.mjs';
|
||||
import Fraction, { lcm } from './fraction.mjs';
|
||||
import Fraction, { isFraction, lcm } from './fraction.mjs';
|
||||
import Hap from './hap.mjs';
|
||||
import State from './state.mjs';
|
||||
import { unionWithObj } from './value.mjs';
|
||||
@@ -21,9 +21,11 @@ import {
|
||||
numeralArgs,
|
||||
parseNumeral,
|
||||
pairs,
|
||||
zipWith,
|
||||
stringifyValues,
|
||||
} from './util.mjs';
|
||||
import drawLine from './drawLine.mjs';
|
||||
import { logger } from './logger.mjs';
|
||||
import { errorLogger, logger } from './logger.mjs';
|
||||
|
||||
let stringParser;
|
||||
|
||||
@@ -96,10 +98,7 @@ export class Pattern {
|
||||
|
||||
// runs func on query state
|
||||
withState(func) {
|
||||
return this.withHaps((haps, state) => {
|
||||
func(state);
|
||||
return haps;
|
||||
});
|
||||
return new Pattern((state) => this.query(func(state)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -415,7 +414,7 @@ export class Pattern {
|
||||
try {
|
||||
return this.query(new State(new TimeSpan(begin, end), controls));
|
||||
} catch (err) {
|
||||
logger(`[query]: ${err.message}`, 'error');
|
||||
errorLogger(err, 'query');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -852,14 +851,29 @@ export class Pattern {
|
||||
);
|
||||
}
|
||||
|
||||
log(func = (_, hap) => `[hap] ${hap.showWhole(true)}`, getData = (_, hap) => ({ hap })) {
|
||||
/**
|
||||
* Writes the content of the current event to the console (visible in the side menu).
|
||||
* @name log
|
||||
* @memberof Pattern
|
||||
* @example
|
||||
* s("bd sd").log()
|
||||
*/
|
||||
log(func = (hap) => `[hap] ${hap.showWhole(true)}`, getData = (hap) => ({ hap })) {
|
||||
return this.onTrigger((...args) => {
|
||||
logger(func(...args), undefined, getData(...args));
|
||||
}, false);
|
||||
}
|
||||
|
||||
logValues(func = id) {
|
||||
return this.log((_, hap) => func(hap.value));
|
||||
/**
|
||||
* A simplified version of `log` which writes all "values" (various configurable parameters)
|
||||
* within the event to the console (visible in the side menu).
|
||||
* @name logValues
|
||||
* @memberof Pattern
|
||||
* @example
|
||||
* s("bd sd").gain("0.25 0.5 1").n("2 1 0").logValues()
|
||||
*/
|
||||
logValues(func = (value) => `[hap] ${stringifyValues(value, true)}`) {
|
||||
return this.log((hap) => func(hap.value));
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
@@ -869,6 +883,31 @@ export class Pattern {
|
||||
console.log(drawLine(this));
|
||||
return this;
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// methods relating to breaking patterns into subcycles
|
||||
|
||||
// Breaks a pattern into a pattern of patterns, according to the structure of the given binary pattern.
|
||||
unjoin(pieces, func = id) {
|
||||
return pieces.withHap((hap) =>
|
||||
hap.withValue((v) => (v ? func(this.ribbon(hap.whole.begin, hap.whole.duration)) : this)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Breaks a pattern into pieces according to the structure of a given pattern.
|
||||
* True values in the given pattern cause the corresponding subcycle of the
|
||||
* source pattern to be looped, and for an (optional) given function to be
|
||||
* applied. False values result in the corresponding part of the source pattern
|
||||
* to be played unchanged.
|
||||
* @name into
|
||||
* @memberof Pattern
|
||||
* @example
|
||||
* sound("bd sd ht lt").into("1 0", hurry(2))
|
||||
*/
|
||||
into(pieces, func) {
|
||||
return this.unjoin(pieces, func).innerJoin();
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
@@ -904,12 +943,13 @@ Pattern.prototype.collect = function () {
|
||||
* note("<[c,eb,g]!2 [c,f,ab] [d,f,ab]>")
|
||||
* .arpWith(haps => haps[2])
|
||||
* */
|
||||
Pattern.prototype.arpWith = function (func) {
|
||||
return this.collect()
|
||||
export const arpWith = register('arpWith', (func, pat) => {
|
||||
return pat
|
||||
.collect()
|
||||
.fmap((v) => reify(func(v)))
|
||||
.innerJoin()
|
||||
.withHap((h) => new Hap(h.whole, h.part, h.value.value, h.combineContext(h.value)));
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Selects indices in in stacked notes.
|
||||
@@ -917,9 +957,11 @@ Pattern.prototype.arpWith = function (func) {
|
||||
* note("<[c,eb,g]!2 [c,f,ab] [d,f,ab]>")
|
||||
* .arp("0 [0,2] 1 [0,2]")
|
||||
* */
|
||||
Pattern.prototype.arp = function (pat) {
|
||||
return this.arpWith((haps) => pat.fmap((i) => haps[i % haps.length]));
|
||||
};
|
||||
export const arp = register(
|
||||
'arp',
|
||||
(indices, pat) => pat.arpWith((haps) => reify(indices).fmap((i) => haps[i % haps.length])),
|
||||
false,
|
||||
);
|
||||
|
||||
/*
|
||||
* Takes a time duration followed by one or more patterns, and shifts the given patterns in time, so they are
|
||||
@@ -954,7 +996,7 @@ addToPrototype('weaveWith', function (t, ...funcs) {
|
||||
// compose matrix functions
|
||||
|
||||
function _nonArrayObject(x) {
|
||||
return !Array.isArray(x) && typeof x === 'object';
|
||||
return !Array.isArray(x) && typeof x === 'object' && !isFraction(x);
|
||||
}
|
||||
function _composeOp(a, b, func) {
|
||||
if (_nonArrayObject(a) || _nonArrayObject(b)) {
|
||||
@@ -1201,7 +1243,8 @@ export const silence = gap(1);
|
||||
/* Like silence, but with a 'steps' (relative duration) of 0 */
|
||||
export const nothing = gap(0);
|
||||
|
||||
/** A discrete value that repeats once per cycle.
|
||||
/**
|
||||
* A discrete value that repeats once per cycle.
|
||||
*
|
||||
* @returns {Pattern}
|
||||
* @example
|
||||
@@ -1254,13 +1297,14 @@ export function sequenceP(pats) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The given items are played at the same time at the same length.
|
||||
/**
|
||||
* The given items are played at the same time at the same length.
|
||||
*
|
||||
* @return {Pattern}
|
||||
* @synonyms polyrhythm, pr
|
||||
* @example
|
||||
* stack("g3", "b3", ["e4", "d4"]).note()
|
||||
* // "g3,b3,[e4,d4]".note()
|
||||
* // "g3,b3,[e4 d4]".note()
|
||||
*
|
||||
* @example
|
||||
* // As a chained function:
|
||||
@@ -1337,11 +1381,11 @@ export function stackBy(by, ...pats) {
|
||||
.setSteps(steps);
|
||||
}
|
||||
|
||||
/** Concatenation: combines a list of patterns, switching between them successively, one per cycle:
|
||||
*
|
||||
* synonyms: `cat`
|
||||
/**
|
||||
* Concatenation: combines a list of patterns, switching between them successively, one per cycle.
|
||||
*
|
||||
* @return {Pattern}
|
||||
* @synonyms cat
|
||||
* @example
|
||||
* slowcat("e5", "b4", ["d5", "c5"])
|
||||
*
|
||||
@@ -1541,7 +1585,7 @@ export const func = curry((a, b) => reify(b).func(a));
|
||||
/**
|
||||
* Registers a new pattern method. The method is added to the Pattern class + the standalone function is returned from register.
|
||||
*
|
||||
* @param {string} name name of the function
|
||||
* @param {string | string[]} name name of the function, or an array of names to be used as synonyms
|
||||
* @param {function} func function with 1 or more params, where last is the current pattern
|
||||
* @noAutocomplete
|
||||
*
|
||||
@@ -2352,6 +2396,57 @@ export const stut = register('stut', function (times, feedback, time, pat) {
|
||||
return pat._echoWith(times, time, (pat, i) => pat.gain(Math.pow(feedback, i)));
|
||||
});
|
||||
|
||||
export const applyN = register('applyN', function (n, func, p) {
|
||||
let result = p;
|
||||
for (let i = 0; i < n; i++) {
|
||||
result = func(result);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
/**
|
||||
* The plyWith function repeats each event the given number of times, applying the given function to each event.\n
|
||||
* @name plyWith
|
||||
* @synonyms plywith
|
||||
* @param {number} factor how many times to repeat
|
||||
* @param {function} func function to apply, given the pattern
|
||||
* @example
|
||||
* "<0 [2 4]>"
|
||||
* .plyWith(4, (p) => p.add(2))
|
||||
* .scale("C:minor").note()
|
||||
*/
|
||||
export const plyWith = register(['plyWith', 'plywith'], function (factor, func, pat) {
|
||||
const result = pat
|
||||
.fmap((x) => cat(...listRange(0, factor - 1).map((i) => applyN(i, func, x)))._fast(factor))
|
||||
.squeezeJoin();
|
||||
if (__steps) {
|
||||
result._steps = Fraction(factor).mulmaybe(pat._steps);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
/**
|
||||
* The plyForEach function repeats each event the given number of times, applying the given function to each event.
|
||||
* This version of ply uses the iteration index as an argument to the function, similar to echoWith.
|
||||
* @name plyForEach
|
||||
* @synonyms plyforeach
|
||||
* @param {number} factor how many times to repeat
|
||||
* @param {function} func function to apply, given the pattern and the iteration index
|
||||
* @example
|
||||
* "<0 [2 4]>"
|
||||
* .plyForEach(4, (p,n) => p.add(n*2))
|
||||
* .scale("C:minor").note()
|
||||
*/
|
||||
export const plyForEach = register(['plyForEach', 'plyforeach'], function (factor, func, pat) {
|
||||
const result = pat
|
||||
.fmap((x) => cat(cat(pure(x), ...listRange(1, factor - 1).map((i) => func(pure(x), i))))._fast(factor))
|
||||
.squeezeJoin();
|
||||
if (__steps) {
|
||||
result._steps = Fraction(factor).mulmaybe(pat._steps);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
/**
|
||||
* Divides a pattern into a given number of subdivisions, plays the subdivisions in order, but increments the starting subdivision each cycle. The pattern wraps to the first subdivision after the last subdivision is played.
|
||||
* @name iter
|
||||
@@ -2479,8 +2574,8 @@ export const { chunkBack, chunkback } = register(
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* "<0 8> 1 2 3 4 5 6 7"
|
||||
* .fastChunk(4, x => x.color('red')).slow(2)
|
||||
* .scale("C2:major").note()
|
||||
* .fastChunk(4, x => x.color('red')).slow(2)
|
||||
*/
|
||||
export const { fastchunk, fastChunk } = register(
|
||||
['fastchunk', 'fastChunk'],
|
||||
@@ -2491,6 +2586,37 @@ export const { fastchunk, fastChunk } = register(
|
||||
true,
|
||||
);
|
||||
|
||||
/**
|
||||
* Like `chunk`, but the function is applied to a looped subcycle of the source pattern.
|
||||
* @name chunkInto
|
||||
* @synonyms chunkinto
|
||||
* @memberof Pattern
|
||||
* @example
|
||||
* sound("bd sd ht lt bd - cp lt").chunkInto(4, hurry(2))
|
||||
* .bank("tr909")
|
||||
*/
|
||||
export const { chunkinto, chunkInto } = register(['chunkinto', 'chunkInto'], function (n, func, pat) {
|
||||
return pat.into(fastcat(true, ...Array(n - 1).fill(false))._iterback(n), func);
|
||||
});
|
||||
|
||||
/**
|
||||
* Like `chunkInto`, but moves backwards through the chunks.
|
||||
* @name chunkBackInto
|
||||
* @synonyms chunkbackinto
|
||||
* @memberof Pattern
|
||||
* @example
|
||||
* sound("bd sd ht lt bd - cp lt").chunkInto(4, hurry(2))
|
||||
* .bank("tr909")
|
||||
*/
|
||||
export const { chunkbackinto, chunkBackInto } = register(['chunkbackinto', 'chunkBackInto'], function (n, func, pat) {
|
||||
return pat.into(
|
||||
fastcat(true, ...Array(n - 1).fill(false))
|
||||
._iter(n)
|
||||
._early(1),
|
||||
func,
|
||||
);
|
||||
});
|
||||
|
||||
// TODO - redefine elsewhere in terms of mask
|
||||
export const bypass = register(
|
||||
'bypass',
|
||||
@@ -2506,7 +2632,7 @@ export const bypass = register(
|
||||
* Loops the pattern inside an `offset` for `cycles`.
|
||||
* If you think of the entire span of time in cycles as a ribbon, you can cut a single piece and loop it.
|
||||
* @name ribbon
|
||||
* @synonym rib
|
||||
* @synonyms rib
|
||||
* @param {number} offset start point of loop in cycles
|
||||
* @param {number} cycles loop length in cycles
|
||||
* @example
|
||||
@@ -2720,7 +2846,7 @@ export function stepcat(...timepats) {
|
||||
if (timepats.length === 0) {
|
||||
return nothing;
|
||||
}
|
||||
const findsteps = (x) => (Array.isArray(x) ? x : [x._steps, x]);
|
||||
const findsteps = (x) => (Array.isArray(x) ? x : [x._steps ?? 1, x]);
|
||||
timepats = timepats.map(findsteps);
|
||||
if (timepats.find((x) => x[0] === undefined)) {
|
||||
const times = timepats.map((a) => a[0]).filter((x) => x !== undefined);
|
||||
@@ -2872,6 +2998,24 @@ export const extend = stepRegister('extend', function (factor, pat) {
|
||||
return pat.fast(factor).expand(factor);
|
||||
});
|
||||
|
||||
/**
|
||||
* *Experimental*
|
||||
*
|
||||
* `replicate` is similar to `fast` in that it increases its density, but it also increases the step count
|
||||
* accordingly. So `stepcat("a b".replicate(2), "c d")` would be the same as `"a b a b c d"`, whereas
|
||||
* `stepcat("a b".fast(2), "c d")` would be the same as `"[a b] [a b] c d"`.
|
||||
*
|
||||
* TODO: find out how this function differs from extend
|
||||
* @example
|
||||
* stepcat(
|
||||
* sound("bd bd - cp").replicate(2),
|
||||
* sound("bd - sd -")
|
||||
* ).pace(8)
|
||||
*/
|
||||
export const replicate = stepRegister('replicate', function (factor, pat) {
|
||||
return pat.repeatCycles(factor).fast(factor).expand(factor);
|
||||
});
|
||||
|
||||
/**
|
||||
* *Experimental*
|
||||
*
|
||||
@@ -3201,10 +3345,10 @@ export const slice = register(
|
||||
* @memberof Pattern
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* s("bd!8").onTriggerTime((hap) => {console.info(hap)})
|
||||
* s("bd!8").onTriggerTime((hap) => {console.log(hap)})
|
||||
*/
|
||||
Pattern.prototype.onTriggerTime = function (func) {
|
||||
return this.onTrigger((t_deprecate, hap, currentTime, cps = 1, targetTime) => {
|
||||
return this.onTrigger((hap, currentTime, _cps, targetTime) => {
|
||||
const diff = targetTime - currentTime;
|
||||
window.setTimeout(() => {
|
||||
func(hap);
|
||||
@@ -3341,3 +3485,205 @@ export const { beat } = register(
|
||||
['beat'],
|
||||
__beat((x) => x.innerJoin()),
|
||||
);
|
||||
|
||||
export const _morph = (from, to, by) => {
|
||||
by = Fraction(by);
|
||||
const dur = Fraction(1).div(from.length);
|
||||
const positions = (list) => {
|
||||
const result = [];
|
||||
for (const [pos, value] of list.entries()) {
|
||||
if (value) {
|
||||
result.push([Fraction(pos).div(list.length), value]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const arcs = zipWith(
|
||||
([posa, valuea], [posb, valueb]) => {
|
||||
const b = by.mul(posb - posa).add(posa);
|
||||
const e = b.add(dur);
|
||||
return new TimeSpan(b, e);
|
||||
},
|
||||
positions(from),
|
||||
positions(to),
|
||||
);
|
||||
function query(state) {
|
||||
const cycle = state.span.begin.sam();
|
||||
const cycleArc = state.span.cycleArc();
|
||||
const result = [];
|
||||
for (const whole of arcs) {
|
||||
const part = whole.intersection(cycleArc);
|
||||
if (part !== undefined) {
|
||||
result.push(
|
||||
new Hap(
|
||||
whole.withTime((x) => x.add(cycle)),
|
||||
part.withTime((x) => x.add(cycle)),
|
||||
true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return new Pattern(query).splitQueries();
|
||||
};
|
||||
|
||||
/**
|
||||
* Takes two binary rhythms represented as lists of 1s and 0s, and a number
|
||||
* between 0 and 1 that morphs between them. The two lists should contain the same
|
||||
* number of true values.
|
||||
* @example
|
||||
* sound("hh").struct(morph([1,0,1,0,1,0,1,0], // straight rhythm
|
||||
* [1,1,0,1,0,1,0], // wonky rhythm
|
||||
* 0.25 // creates a slightly wonky rhythm
|
||||
* )
|
||||
* )
|
||||
* @example
|
||||
* sound("hh").struct(morph("1:0:1:0:1:0:1:0", // straight rhythm
|
||||
* "1:1:0:1:0:1:0", // wonky rhythm
|
||||
* sine.slow(8) // slowly morph between the rhythms
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
export const morph = (frompat, topat, bypat) => {
|
||||
frompat = reify(frompat);
|
||||
topat = reify(topat);
|
||||
bypat = reify(bypat);
|
||||
return frompat.innerBind((from) => topat.innerBind((to) => bypat.innerBind((by) => _morph(from, to, by))));
|
||||
};
|
||||
|
||||
/**
|
||||
* Soft-clipping distortion
|
||||
*
|
||||
* @name soft
|
||||
* @param {number | Pattern} distortion amount of distortion to apply
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
*/
|
||||
/**
|
||||
* Hard-clipping distortion
|
||||
*
|
||||
* @name hard
|
||||
* @param {number | Pattern} distortion amount of distortion to apply
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
*/
|
||||
/**
|
||||
* Cubic polynomial distortion
|
||||
*
|
||||
* @name cubic
|
||||
* @param {number | Pattern} distortion amount of distortion to apply
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
*/
|
||||
/**
|
||||
* Diode-emulating distortion
|
||||
*
|
||||
* @name diode
|
||||
* @param {number | Pattern} distortion amount of distortion to apply
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
*/
|
||||
/**
|
||||
* Asymmetrical diode distortion
|
||||
*
|
||||
* @name asym
|
||||
* @param {number | Pattern} distortion amount of distortion to apply
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
*/
|
||||
/**
|
||||
* Wavefolding distortion
|
||||
*
|
||||
* @name fold
|
||||
* @param {number | Pattern} distortion amount of distortion to apply
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
*/
|
||||
/**
|
||||
* Wavefolding distortion composed with sinusoid
|
||||
*
|
||||
* @name sinefold
|
||||
* @param {number | Pattern} distortion amount of distortion to apply
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
*/
|
||||
/**
|
||||
* Distortion via Chebyshev polynomials
|
||||
*
|
||||
* @name chebyshev
|
||||
* @param {number | Pattern} distortion amount of distortion to apply
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
*/
|
||||
const distAlgoNames = ['scurve', 'soft', 'hard', 'cubic', 'diode', 'asym', 'fold', 'sinefold', 'chebyshev'];
|
||||
for (const name of distAlgoNames) {
|
||||
// Add aliases for distortion algorithms
|
||||
Pattern.prototype[name] = function (args) {
|
||||
const argsPat = reify(args).fmap((v) => (Array.isArray(v) ? [...v, name] : [v, 1, name]));
|
||||
return this.distort(argsPat);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a list of patterns into a single pattern which outputs list-values
|
||||
*
|
||||
* @name parray
|
||||
* @returns Pattern
|
||||
*/
|
||||
export const parray = (pats) => {
|
||||
const pack = (...xs) => xs;
|
||||
let acc = pure(curry(pack, null, pats.length));
|
||||
for (const p of pats) acc = acc.appBoth(reify(p));
|
||||
return acc;
|
||||
};
|
||||
|
||||
const _ensureListPattern = (list) => {
|
||||
if (Array.isArray(list)) {
|
||||
return parray(list);
|
||||
}
|
||||
return reify(list);
|
||||
};
|
||||
|
||||
/**
|
||||
* Scale the magnitude of the harmonics of one of the core synths ('sine', 'tri', 'saw', ..)
|
||||
*
|
||||
* Can also be used to create a new synth via `s('user').partials(...)`
|
||||
*
|
||||
* @name partials
|
||||
* @param {number[] | Pattern} magnitudes List of [0, 1] magnitudes for partials. 0th entry is the fundamental harmonic (i.e. DC offset is skipped)
|
||||
* @example
|
||||
* s("user").seg(16).n(irand(8)).scale("A:major")
|
||||
* .partials([1, 0, 1, 0, 0, 1])
|
||||
* @example
|
||||
* s("saw").seg(8).n(irand(12)).scale("G#:minor")
|
||||
* .partials(binaryL(irand(256).add("1")))
|
||||
*/
|
||||
Pattern.prototype.partials = function (list) {
|
||||
return this.withValue((v) => (l) => ({ ...v, partials: l })).appLeft(_ensureListPattern(list));
|
||||
};
|
||||
|
||||
// Also create a top-level function
|
||||
export const partials = (list) => {
|
||||
return _ensureListPattern(list).as('partials');
|
||||
};
|
||||
|
||||
/**
|
||||
* Rotates the harmonics of one of the core synths ('sine', 'tri', 'saw', 'user', ..) by a list of phases
|
||||
*
|
||||
* @name phases
|
||||
* @param {number[] | Pattern} phases List of [0, 1) phases for partials. 0th entry is the fundamental phase (i.e. DC offset is skipped)
|
||||
* @example
|
||||
* // Phase cancellation
|
||||
* s("saw").seg(8).n(irand(12)).scale("G#1:minor")
|
||||
* .partials(partials([1, 1, 1]))
|
||||
* .superimpose(x => x.phases([0.5, 0.5, 0.5]))
|
||||
*/
|
||||
Pattern.prototype.phases = function (list) {
|
||||
return this.withValue((v) => (l) => ({ ...v, phases: l })).appLeft(_ensureListPattern(list));
|
||||
};
|
||||
|
||||
// Also create a top-level function
|
||||
export const phases = (list) => {
|
||||
return _ensureListPattern(list).as('phases');
|
||||
};
|
||||
|
||||
+51
-13
@@ -1,7 +1,7 @@
|
||||
import { NeoCyclist } from './neocyclist.mjs';
|
||||
import { Cyclist } from './cyclist.mjs';
|
||||
import { evaluate as _evaluate } from './evaluate.mjs';
|
||||
import { logger } from './logger.mjs';
|
||||
import { errorLogger, logger } from './logger.mjs';
|
||||
import { setTime } from './time.mjs';
|
||||
import { evalScope } from './evaluate.mjs';
|
||||
import { register, Pattern, isPattern, silence, stack } from './pattern.mjs';
|
||||
@@ -21,6 +21,7 @@ export function repl({
|
||||
setInterval,
|
||||
clearInterval,
|
||||
id,
|
||||
mondo = false,
|
||||
}) {
|
||||
const state = {
|
||||
schedulerError: undefined,
|
||||
@@ -73,6 +74,14 @@ export function repl({
|
||||
return silence;
|
||||
};
|
||||
|
||||
// helper to get a patternified pure value out
|
||||
function unpure(pat) {
|
||||
if (pat._Pattern) {
|
||||
return pat.__pure;
|
||||
}
|
||||
return pat;
|
||||
}
|
||||
|
||||
const setPattern = async (pattern, autostart = true) => {
|
||||
pattern = editPattern?.(pattern) || pattern;
|
||||
await scheduler.setPattern(pattern, autostart);
|
||||
@@ -84,7 +93,10 @@ export function repl({
|
||||
const start = () => scheduler.start();
|
||||
const pause = () => scheduler.pause();
|
||||
const toggle = () => scheduler.toggle();
|
||||
const setCps = (cps) => scheduler.setCps(cps);
|
||||
const setCps = (cps) => {
|
||||
scheduler.setCps(unpure(cps));
|
||||
return silence;
|
||||
};
|
||||
|
||||
/**
|
||||
* Changes the global tempo to the given cycles per minute
|
||||
@@ -96,7 +108,10 @@ export function repl({
|
||||
* setcpm(140/4) // =140 bpm in 4/4
|
||||
* $: s("bd*4,[- sd]*2").bank('tr707')
|
||||
*/
|
||||
const setCpm = (cpm) => scheduler.setCps(cpm / 60);
|
||||
const setCpm = (cpm) => {
|
||||
scheduler.setCps(unpure(cpm) / 60);
|
||||
return silence;
|
||||
};
|
||||
|
||||
// TODO - not documented as jsdoc examples as the test framework doesn't simulate enough context for `each` and `all`..
|
||||
|
||||
@@ -113,8 +128,9 @@ export function repl({
|
||||
* all(x => x.pianoroll())
|
||||
* ```
|
||||
*/
|
||||
let allTransforms = [];
|
||||
const all = function (transform) {
|
||||
allTransform = transform;
|
||||
allTransforms.push(transform);
|
||||
return silence;
|
||||
};
|
||||
/** Applies a function to each of the running patterns separately. This is intended for future use with upcoming 'stepwise' features. See `all` for a version that applies the function to all the patterns stacked together into a single pattern.
|
||||
@@ -136,9 +152,9 @@ export function repl({
|
||||
// allows muting a pattern x with x_ or _x
|
||||
return silence;
|
||||
}
|
||||
if (id === '$') {
|
||||
if (id.includes('$')) {
|
||||
// allows adding anonymous patterns with $:
|
||||
id = `$${anonymousIndex}`;
|
||||
id = `${id}${anonymousIndex}`;
|
||||
anonymousIndex++;
|
||||
}
|
||||
pPatterns[id] = this;
|
||||
@@ -190,10 +206,29 @@ export function repl({
|
||||
await injectPatternMethods();
|
||||
setTime(() => scheduler.now()); // TODO: refactor?
|
||||
await beforeEval?.({ code });
|
||||
allTransforms = []; // reset all transforms
|
||||
shouldHush && hush();
|
||||
|
||||
if (mondo) {
|
||||
code = `mondolang\`${code}\``;
|
||||
}
|
||||
let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptions);
|
||||
if (Object.keys(pPatterns).length) {
|
||||
let patterns = Object.values(pPatterns);
|
||||
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));
|
||||
@@ -202,12 +237,14 @@ export function repl({
|
||||
} else if (eachTransform) {
|
||||
pattern = eachTransform(pattern);
|
||||
}
|
||||
if (allTransform) {
|
||||
pattern = allTransform(pattern);
|
||||
if (allTransforms.length) {
|
||||
for (const transform of allTransforms) {
|
||||
pattern = transform(pattern);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPattern(pattern)) {
|
||||
const message = `got "${typeof evaluated}" instead of pattern`;
|
||||
throw new Error(message + (typeof evaluated === 'function' ? ', did you forget to call a function?' : '.'));
|
||||
pattern = silence;
|
||||
}
|
||||
logger(`[eval] code updated`);
|
||||
pattern = await setPattern(pattern, autostart);
|
||||
@@ -236,6 +273,7 @@ export function repl({
|
||||
export const getTrigger =
|
||||
({ getTime, defaultOutput }) =>
|
||||
async (hap, deadline, duration, cps, t) => {
|
||||
// ^ this signature is different from hap.context.onTrigger, as set by Pattern.onTrigger(onTrigger)
|
||||
// TODO: get rid of deadline after https://codeberg.org/uzu/strudel/pulls/1004
|
||||
try {
|
||||
if (!hap.context.onTrigger || !hap.context.dominantTrigger) {
|
||||
@@ -243,9 +281,9 @@ export const getTrigger =
|
||||
}
|
||||
if (hap.context.onTrigger) {
|
||||
// call signature of output / onTrigger is different...
|
||||
await hap.context.onTrigger(getTime() + deadline, hap, getTime(), cps, t);
|
||||
await hap.context.onTrigger(hap, getTime(), cps, t);
|
||||
}
|
||||
} catch (err) {
|
||||
logger(`[cyclist] error: ${err.message}`, 'error');
|
||||
errorLogger(err, 'getTrigger');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -71,7 +71,6 @@ export const sine2 = signal((t) => Math.sin(Math.PI * 2 * t));
|
||||
|
||||
/**
|
||||
* A sine signal between 0 and 1.
|
||||
*
|
||||
* @return {Pattern}
|
||||
* @example
|
||||
* n(sine.segment(16).range(0,15))
|
||||
@@ -100,7 +99,6 @@ export const cosine2 = sine2._early(Fraction(1).div(4));
|
||||
|
||||
/**
|
||||
* A square signal between 0 and 1.
|
||||
*
|
||||
* @return {Pattern}
|
||||
* @example
|
||||
* n(square.segment(4).range(0,7)).scale("C:minor")
|
||||
@@ -230,7 +228,7 @@ const timeToRands = (t, n) => timeToRandsPrime(timeToIntSeed(t), n);
|
||||
export const run = (n) => saw.range(0, n).round().segment(n);
|
||||
|
||||
/**
|
||||
* Creates a pattern from a binary number.
|
||||
* Creates a binary pattern from a number.
|
||||
*
|
||||
* @name binary
|
||||
* @param {number} n - input number to convert to binary
|
||||
@@ -244,7 +242,7 @@ export const binary = (n) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a pattern from a binary number, padded to n bits long.
|
||||
* Creates a binary pattern from a number, padded to n bits long.
|
||||
*
|
||||
* @name binaryN
|
||||
* @param {number} n - input number to convert to binary
|
||||
@@ -260,13 +258,58 @@ export const binaryN = (n, nBits = 16) => {
|
||||
return reify(n).segment(nBits).brshift(bitPos).band(pure(1));
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a binary list pattern from a number.
|
||||
*
|
||||
* @name binaryL
|
||||
* @param {number} n - input number to convert to binary
|
||||
* s("saw").seg(8)
|
||||
* .partials(binaryL(irand(4096).add(1)))
|
||||
*/
|
||||
export const binaryL = (n) => {
|
||||
const nBits = reify(n).log2(0).floor().add(1);
|
||||
return binaryNL(n, nBits);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a binary list pattern from a number, padded to n bits long.
|
||||
*
|
||||
* @name binaryNL
|
||||
* @param {number} n - input number to convert to binary
|
||||
* @param {number} nBits - pattern length, defaults to 16
|
||||
*/
|
||||
export const binaryNL = (n, nBits = 16) => {
|
||||
return reify(n)
|
||||
.withValue((v) => (bits) => {
|
||||
const bList = [];
|
||||
for (let i = bits - 1; i >= 0; i--) {
|
||||
bList.push((v >> i) & 1);
|
||||
}
|
||||
return bList;
|
||||
})
|
||||
.appLeft(reify(nBits));
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a list of random numbers of the given length
|
||||
*
|
||||
* @name randL
|
||||
* @param {number} n Number of random numbers to sample
|
||||
* @example
|
||||
* s("saw").seg(16).n(irand(12)).scale("F1:minor")
|
||||
* .partials(randL(8))
|
||||
*/
|
||||
export const randL = (n) => {
|
||||
return signal((t) => (nVal) => timeToRands(t, nVal).map(Math.abs)).appLeft(reify(n));
|
||||
};
|
||||
|
||||
export const randrun = (n) => {
|
||||
return signal((t) => {
|
||||
// Without adding 0.5, the first cycle is always 0,1,2,3,...
|
||||
const rands = timeToRands(t.floor().add(0.5), n);
|
||||
const nums = rands
|
||||
.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]))
|
||||
.map((x) => x[1]);
|
||||
const i = t.cyclePos().mul(n).floor() % n;
|
||||
return nums[i];
|
||||
@@ -285,7 +328,7 @@ const _rearrangeWith = (ipat, n, pat) => {
|
||||
* @example
|
||||
* note("c d e f").sound("piano").shuffle(4)
|
||||
* @example
|
||||
* note("c d e f".shuffle(4), "g").sound("piano")
|
||||
* seq("c d e f".shuffle(4), "g").note().sound("piano")
|
||||
*/
|
||||
export const shuffle = register('shuffle', (n, pat) => {
|
||||
return _rearrangeWith(randrun(n), n, pat);
|
||||
@@ -298,7 +341,7 @@ export const shuffle = register('shuffle', (n, pat) => {
|
||||
* @example
|
||||
* note("c d e f").sound("piano").scramble(4)
|
||||
* @example
|
||||
* note("c d e f".scramble(4), "g").sound("piano")
|
||||
* seq("c d e f".scramble(4), "g").note().sound("piano")
|
||||
*/
|
||||
export const scramble = register('scramble', (n, pat) => {
|
||||
return _rearrangeWith(_irand(n)._segment(n), n, pat);
|
||||
@@ -398,6 +441,10 @@ export const chooseInWith = (pat, xs) => {
|
||||
*/
|
||||
export const choose = (...xs) => chooseWith(rand, xs);
|
||||
|
||||
// todo: doc
|
||||
export const chooseIn = (...xs) => chooseInWith(rand, xs);
|
||||
export const chooseOut = choose;
|
||||
|
||||
/**
|
||||
* Chooses from the given list of values (or patterns of values), according
|
||||
* to the pattern that the method is called on. The pattern should be in
|
||||
@@ -477,7 +524,7 @@ export const wchoose = (...pairs) => wchooseWith(rand, ...pairs);
|
||||
* @example
|
||||
* wchooseCycles(["bd",10], ["hh",1], ["sd",1]).s().fast(8)
|
||||
* @example
|
||||
* wchooseCycles(["bd bd bd",5], ["hh hh hh",3], ["sd sd sd",1]).fast(4).s()
|
||||
* wchooseCycles(["c c c",5], ["a a a",3], ["f f f",1]).fast(4).note()
|
||||
* @example
|
||||
* // The probability can itself be a pattern
|
||||
* wchooseCycles(["bd(3,8)","<5 0>"], ["hh hh hh",3]).fast(4).s()
|
||||
|
||||
@@ -32,7 +32,7 @@ function triggerSpeech(words, lang, voice) {
|
||||
}
|
||||
|
||||
export const speak = register('speak', function (lang, voice, pat) {
|
||||
return pat.onTrigger((_, hap) => {
|
||||
return pat.onTrigger((hap) => {
|
||||
triggerSpeech(hap.value, lang, voice);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,9 +19,9 @@ export class State {
|
||||
return this.setSpan(func(this.span));
|
||||
}
|
||||
|
||||
// Returns new State with different controls
|
||||
// Returns new State with added controls.
|
||||
setControls(controls) {
|
||||
return new State(this.span, controls);
|
||||
return new State(this.span, { ...this.controls, ...controls });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { bjork } from '../euclid.mjs';
|
||||
import { bjorklund } from '../euclid.mjs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fastcat } from '../pattern.mjs';
|
||||
|
||||
describe('bjork', () => {
|
||||
it('should apply bjorklund to ons and steps', () => {
|
||||
expect(bjork(3, 8)).toStrictEqual([1, 0, 0, 1, 0, 0, 1, 0]);
|
||||
expect(bjork(-3, 8)).toStrictEqual([0, 1, 1, 0, 1, 1, 0, 1]);
|
||||
expect(bjork(8, 8)).toStrictEqual([1, 1, 1, 1, 1, 1, 1, 1]);
|
||||
expect(bjork(-8, 8)).toStrictEqual([0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
expect(bjork(5, 8)).toStrictEqual([1, 0, 1, 1, 0, 1, 1, 0]);
|
||||
describe('bjorklund', () => {
|
||||
it('should apply bjorklundlund to ons and steps', () => {
|
||||
expect(bjorklund(3, 8)).toStrictEqual([1, 0, 0, 1, 0, 0, 1, 0]);
|
||||
expect(bjorklund(-3, 8)).toStrictEqual([0, 1, 1, 0, 1, 1, 0, 1]);
|
||||
expect(bjorklund(8, 8)).toStrictEqual([1, 1, 1, 1, 1, 1, 1, 1]);
|
||||
expect(bjorklund(-8, 8)).toStrictEqual([0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
expect(bjorklund(5, 8)).toStrictEqual([1, 0, 1, 1, 0, 1, 1, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ This program is free software: you can redistribute it and/or modify it under th
|
||||
|
||||
import Fraction from 'fraction.js';
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
TimeSpan,
|
||||
@@ -52,8 +52,11 @@ import {
|
||||
stackCentre,
|
||||
stepcat,
|
||||
sometimes,
|
||||
expand,
|
||||
} from '../index.mjs';
|
||||
|
||||
import { log, logValues } from '../pattern.mjs';
|
||||
|
||||
import { steady } from '../signal.mjs';
|
||||
|
||||
import { n, s } from '../controls.mjs';
|
||||
@@ -1001,7 +1004,7 @@ describe('Pattern', () => {
|
||||
});
|
||||
describe('hurry', () => {
|
||||
it('Can speed up patterns and sounds', () => {
|
||||
sameFirst(s('a', 'b').hurry(2), s('a', 'b').fast(2).speed(2));
|
||||
sameFirst(s(sequence('a', 'b')).hurry(2), s(sequence('a', 'b')).fast(2).speed(2));
|
||||
});
|
||||
});
|
||||
/*describe('composable functions', () => {
|
||||
@@ -1179,6 +1182,9 @@ describe('Pattern', () => {
|
||||
it('calculates undefined steps as the average', () => {
|
||||
expect(sameFirst(stepcat(pure(1), pure(2), pure(3).setSteps(undefined)), fastcat(1, 2, 3)));
|
||||
});
|
||||
it('works with auto-reified values', () => {
|
||||
expect(sameFirst(stepcat(expand(3, 'bd'), 'rim'), stepcat(expand(3, 'bd'), pure('rim'))));
|
||||
});
|
||||
});
|
||||
describe('shrink', () => {
|
||||
it('can shrink', () => {
|
||||
@@ -1267,4 +1273,75 @@ describe('Pattern', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('unjoin', () => {
|
||||
it('destructures a pattern into subcycles', () => {
|
||||
sameFirst(
|
||||
fastcat('a', 'b', 'c', 'd')
|
||||
.unjoin(fastcat(true, fastcat(true, true)))
|
||||
.fmap(fast(2))
|
||||
.join(),
|
||||
fastcat('a', 'b', 'a', 'b', 'c', 'c', 'd', 'd'),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('into', () => {
|
||||
it('applies a function to subcycles of a pattern', () => {
|
||||
sameFirst(
|
||||
fastcat('a', 'b', 'c', 'd').into(fastcat(fastcat('true', 'true'), 'true'), fast(2)),
|
||||
fastcat('a', 'a', 'b', 'b', 'c', 'd', 'c', 'd'),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('chunkinto', () => {
|
||||
it('chunks into subcycles', () => {
|
||||
sameFirst(
|
||||
fastcat('a', 'b', 'c').chunkInto(3, fast(2)).fast(3),
|
||||
fastcat(fastcat('a', 'a'), 'b', 'c', 'a', fastcat('b', 'b'), 'c', 'a', 'b', fastcat('c', 'c')),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('chunkbackinto', () => {
|
||||
it('chunks into subcycles backwards', () => {
|
||||
sameFirst(
|
||||
fastcat('a', 'b', 'c').chunkBackInto(3, fast(2)).fast(3),
|
||||
fastcat('a', 'b', fastcat('c', 'c'), 'a', fastcat('b', 'b'), 'c', fastcat('a', 'a'), 'b', 'c'),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('log', () => {
|
||||
it('logs to console', () => {
|
||||
const mockConsoleLog = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const pattern = pure('a').log();
|
||||
const haps = pattern.queryArc(0, 1);
|
||||
|
||||
// Force a trigger
|
||||
haps.forEach((hap) => {
|
||||
hap.context?.onTrigger?.(hap);
|
||||
});
|
||||
|
||||
expect(mockConsoleLog).toHaveBeenCalledWith(
|
||||
'%c[hap] 0/1 → 1/1: a',
|
||||
'background-color: black;color:white;border-radius:15px',
|
||||
);
|
||||
mockConsoleLog.mockRestore();
|
||||
});
|
||||
});
|
||||
describe('logValues', () => {
|
||||
it('logs values to console', () => {
|
||||
const mockConsoleLog = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const pattern = pure('a').note('c#').logValues();
|
||||
const haps = pattern.queryArc(0, 1);
|
||||
|
||||
// Force a trigger
|
||||
haps.forEach((hap) => {
|
||||
hap.context?.onTrigger?.(hap);
|
||||
});
|
||||
|
||||
expect(mockConsoleLog).toHaveBeenCalledWith(
|
||||
'%c[hap] value:a note:c#',
|
||||
'background-color: black;color:white;border-radius:15px',
|
||||
);
|
||||
mockConsoleLog.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,7 +72,7 @@ export class TimeSpan {
|
||||
}
|
||||
|
||||
intersection(other) {
|
||||
// Intersection of two timespans, returns None if they don't intersect.
|
||||
// Intersection of two timespans, returns undefined if they don't intersect.
|
||||
const intersect_begin = this.begin.max(other.begin);
|
||||
const intersect_end = this.end.min(other.end);
|
||||
|
||||
|
||||
+18
-4
@@ -7,13 +7,13 @@ This program is free software: you can redistribute it and/or modify it under th
|
||||
import { logger } from './logger.mjs';
|
||||
|
||||
// returns true if the given string is a note
|
||||
export const isNoteWithOctave = (name) => /^[a-gA-G][#bs]*[0-9]$/.test(name);
|
||||
export const isNote = (name) => /^[a-gA-G][#bsf]*[0-9]?$/.test(name);
|
||||
export const isNoteWithOctave = (name) => /^[a-gA-G][#bsf]*[0-9]*$/.test(name);
|
||||
export const isNote = (name) => /^[a-gA-G][#bsf]*-?[0-9]*$/.test(name);
|
||||
export const tokenizeNote = (note) => {
|
||||
if (typeof note !== 'string') {
|
||||
return [];
|
||||
}
|
||||
const [pc, acc = '', oct] = note.match(/^([a-gA-G])([#bsf]*)([0-9]*)$/)?.slice(1) || [];
|
||||
const [pc, acc = '', oct] = note.match(/^([a-gA-G])([#bsf]*)(-?[0-9]*)$/)?.slice(1) || [];
|
||||
if (!pc) {
|
||||
return [];
|
||||
}
|
||||
@@ -23,6 +23,10 @@ export const tokenizeNote = (note) => {
|
||||
const chromas = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
|
||||
const accs = { '#': 1, b: -1, s: 1, f: -1 };
|
||||
|
||||
export const getAccidentalsOffset = (accidentals) => {
|
||||
return accidentals?.split('').reduce((o, char) => o + accs[char], 0) || 0;
|
||||
};
|
||||
|
||||
// turns the given note into its midi number representation
|
||||
export const noteToMidi = (note, defaultOctave = 3) => {
|
||||
const [pc, acc, oct = defaultOctave] = tokenizeNote(note);
|
||||
@@ -30,7 +34,7 @@ export const noteToMidi = (note, defaultOctave = 3) => {
|
||||
throw new Error('not a note: "' + note + '"');
|
||||
}
|
||||
const chroma = chromas[pc.toLowerCase()];
|
||||
const offset = acc?.split('').reduce((o, char) => o + accs[char], 0) || 0;
|
||||
const offset = getAccidentalsOffset(acc);
|
||||
return (Number(oct) + 1) * 12 + chroma + offset;
|
||||
};
|
||||
export const midiToFreq = (n) => {
|
||||
@@ -487,3 +491,13 @@ export function getCurrentKeyboardState() {
|
||||
// }
|
||||
// return lcm((x * y) / gcd(x, y), ...z);
|
||||
// };
|
||||
|
||||
// Takes values -- typically derived from events, i.e. `hap`s -- and renders them
|
||||
// into a readable format
|
||||
export function stringifyValues(value, compact = false) {
|
||||
return typeof value === 'object'
|
||||
? compact
|
||||
? JSON.stringify(value).slice(1, -1).replaceAll('"', '').replaceAll(',', ' ')
|
||||
: JSON.stringify(value)
|
||||
: value;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export const csound = register('csound', (instrument, pat) => {
|
||||
instrument = instrument || 'triangle';
|
||||
init(); // not async to support csound inside other patterns + to be able to call pattern methods after it
|
||||
// TODO: find a alternative way to wait for csound to load (to wait with first time playback)
|
||||
return pat.onTrigger((time_deprecate, hap, currentTime, _cps, targetTime) => {
|
||||
return pat.onTrigger((hap, currentTime, _cps, targetTime) => {
|
||||
if (!_csound) {
|
||||
logger('[csound] not loaded yet', 'warning');
|
||||
return;
|
||||
@@ -142,7 +142,7 @@ export const csoundm = register('csoundm', (instrument, pat) => {
|
||||
p1 = `"${instrument}"`;
|
||||
}
|
||||
init(); // not async to support csound inside other patterns + to be able to call pattern methods after it
|
||||
return pat.onTrigger((tidal_time, hap) => {
|
||||
return pat.onTrigger((hap, currentTime, _cps, targetTime) => {
|
||||
if (!_csound) {
|
||||
logger('[csound] not loaded yet', 'warning');
|
||||
return;
|
||||
@@ -151,7 +151,7 @@ export const csoundm = register('csoundm', (instrument, pat) => {
|
||||
throw new Error('csound only support objects as hap values');
|
||||
}
|
||||
// Time in seconds counting from now.
|
||||
const p2 = tidal_time - getAudioContext().currentTime;
|
||||
const p2 = targetTime - currentTime;
|
||||
const p3 = hap.duration.valueOf() + 0;
|
||||
const frequency = getFrequency(hap);
|
||||
let { gain = 1, velocity = 0.9 } = hap.value;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strudel/csound",
|
||||
"version": "1.2.3",
|
||||
"version": "1.2.6",
|
||||
"description": "csound bindings for strudel",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
@@ -38,5 +38,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ const OFF_MESSAGE = 0x80;
|
||||
const CC_MESSAGE = 0xb0;
|
||||
|
||||
Pattern.prototype.midi = function (output) {
|
||||
return this.onTrigger((time_deprecate, hap, currentTime, cps, targetTime) => {
|
||||
return this.onTrigger((hap, currentTime, cps, targetTime) => {
|
||||
let { note, nrpnn, nrpv, ccn, ccv, velocity = 0.9, gain = 1 } = hap.value;
|
||||
//magic number to get audio engine to line up, can probably be calculated somehow
|
||||
const latencyMs = 34;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Invoke } from './utils.mjs';
|
||||
|
||||
const collator = new ClockCollator({});
|
||||
|
||||
export async function oscTriggerTauri(t_deprecate, hap, currentTime, cps = 1, targetTime) {
|
||||
export async function oscTriggerTauri(hap, currentTime, cps = 1, targetTime) {
|
||||
const controls = parseControlsFromHap(hap, cps);
|
||||
const params = [];
|
||||
const timestamp = collator.calculateTimestamp(currentTime, targetTime);
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
"@strudel/core": "workspace:*",
|
||||
"@tauri-apps/api": "^2.2.0"
|
||||
},
|
||||
"homepage": "https://codeberg.org/uzu/strudel#readme"
|
||||
"homepage": "https://codeberg.org/uzu/strudel#readme",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
+11
-2
@@ -84,9 +84,18 @@ Pattern.prototype.onPaint = function (painter) {
|
||||
state.controls.painters = [];
|
||||
}
|
||||
state.controls.painters.push(painter);
|
||||
return state;
|
||||
});
|
||||
};
|
||||
|
||||
// TODO - Why isn't this pure deep copy not working?
|
||||
// Pattern.prototype.onPaint = function (painter) {
|
||||
// return this.withState((state) => {
|
||||
// const painters = state.controls.painters ? [...state.controls.painters, painter] : [painter];
|
||||
// return new State(state.span, { ...state.controls, painters });
|
||||
// });
|
||||
// };
|
||||
|
||||
Pattern.prototype.getPainters = function () {
|
||||
let painters = [];
|
||||
this.queryArc(0, 0, { painters });
|
||||
@@ -150,7 +159,7 @@ export class Drawer {
|
||||
this.lastFrame = phase;
|
||||
this.visibleHaps = (this.visibleHaps || [])
|
||||
// filter out haps that are too far in the past (think left edge of screen for pianoroll)
|
||||
.filter((h) => h.endClipped >= phase - lookbehind - lookahead)
|
||||
.filter((h) => h.whole && h.endClipped >= phase - lookbehind - lookahead)
|
||||
// add new haps with onset (think right edge bars scrolling in)
|
||||
.concat(haps.filter((h) => h.hasOnset()));
|
||||
const time = phase - lookahead;
|
||||
@@ -175,7 +184,7 @@ export class Drawer {
|
||||
// +0.1 = workaround for weird holes in query..
|
||||
const [begin, end] = [Math.max(t, 0), t + lookahead + 0.1];
|
||||
// remove all future haps
|
||||
this.visibleHaps = this.visibleHaps.filter((h) => h.whole.begin < t);
|
||||
this.visibleHaps = this.visibleHaps.filter((h) => h.whole?.begin < t);
|
||||
this.painters = []; // will get populated by .onPaint calls attached to the pattern
|
||||
// query future haps
|
||||
const futureHaps = scheduler.pattern.queryArc(begin, end, { painters: this.painters });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strudel/draw",
|
||||
"version": "1.2.2",
|
||||
"version": "1.2.5",
|
||||
"description": "Helpers for drawing with Strudel",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
@@ -33,5 +33,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strudel/embed",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"description": "Embeddable Web Component to load a Strudel REPL into an iframe",
|
||||
"main": "embed.js",
|
||||
"type": "module",
|
||||
@@ -20,5 +20,8 @@
|
||||
"bugs": {
|
||||
"url": "https://codeberg.org/uzu/strudel/issues"
|
||||
},
|
||||
"homepage": "https://codeberg.org/uzu/strudel#readme"
|
||||
"homepage": "https://codeberg.org/uzu/strudel#readme",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,12 @@ const pattern = sequence([
|
||||
- D-Pad
|
||||
- `up`, `down`, `left`, `right` (or `u`, `d`, `l`, `r` or uppercase)
|
||||
- Toggle versions: `tglUp`, `tglDown`, `tglLeft`, `tglRight`(or `tglU`, `tglD`, `tglL`, `tglR`)
|
||||
- Stick Buttons
|
||||
- `l3`, `r3` (or `ls`, `rs`)
|
||||
- Toggle versions: `tglL3`, `tglR3` (or `tglLS`, `tglRS`)
|
||||
- System Buttons
|
||||
- `start`, `back` (or uppercase `START`, `BACK`)
|
||||
- Toggle versions: `tglStart`, `tglBack` (or `tglSTART`, `tglBACK`)
|
||||
|
||||
### Analog Sticks
|
||||
- Left Stick
|
||||
|
||||
@@ -29,6 +29,10 @@ The gamepad module provides access to buttons and analog sticks as normalized si
|
||||
| | Toggle versions: `tglLB`, `tglRB`, `tglLT`, `tglRT` |
|
||||
| D-Pad | `up`, `down`, `left`, `right` (or `u`, `d`, `l`, `r` or uppercase) |
|
||||
| | Toggle versions: `tglUp`, `tglDown`, `tglLeft`, `tglRight` (or `tglU`, `tglD`, `tglL`, `tglR`) |
|
||||
| Stick Buttons | `l3`, 'r3' (or `ls`, `rs`) |
|
||||
| | Toggle versions: `tglL3`, 'tglR3' (or `tglLs`, `tglRs`) |
|
||||
| System Buttons | `start`, `back` (or uppercase `START`, `BACK`) |
|
||||
| | Toggle versions: `tglStart`, `tglBack` (or `tglSTART`, `tglBACK`) |
|
||||
|
||||
### Analog Sticks
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@ export const buttonMap = {
|
||||
rt: 7,
|
||||
back: 8,
|
||||
start: 9,
|
||||
l3: 10,
|
||||
ls: 10,
|
||||
r3: 11,
|
||||
rs: 11,
|
||||
u: 12,
|
||||
up: 12,
|
||||
d: 13,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strudel/gamepad",
|
||||
"version": "1.2.2",
|
||||
"version": "1.2.5",
|
||||
"description": "Gamepad Inputs for strudel",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
@@ -33,5 +33,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,5 +34,8 @@
|
||||
"devDependencies": {
|
||||
"tree-sitter-haskell": "^0.23.1",
|
||||
"vite": "^6.0.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export async function initHydra(options = {}) {
|
||||
hydra.synth.s0.init({ src: canvas });
|
||||
}
|
||||
}
|
||||
return hydra;
|
||||
}
|
||||
|
||||
export function clearHydra() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strudel/hydra",
|
||||
"version": "1.2.2",
|
||||
"version": "1.2.5",
|
||||
"description": "Hydra integration for strudel",
|
||||
"main": "hydra.mjs",
|
||||
"type": "module",
|
||||
@@ -40,5 +40,8 @@
|
||||
"devDependencies": {
|
||||
"pkg": "^5.8.1",
|
||||
"vite": "^6.0.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
+62
-42
@@ -5,9 +5,10 @@ This program is free software: you can redistribute it and/or modify it under th
|
||||
*/
|
||||
|
||||
import * as _WebMidi from 'webmidi';
|
||||
import { Pattern, getEventOffsetMs, isPattern, logger, ref } from '@strudel/core';
|
||||
import { Pattern, isPattern, logger, ref } from '@strudel/core';
|
||||
import { noteToMidi, getControlName } from '@strudel/core';
|
||||
import { Note } from 'webmidi';
|
||||
import { scheduleAtTime } from '../superdough/helpers.mjs';
|
||||
|
||||
// if you use WebMidi from outside of this package, make sure to import that instance:
|
||||
export const { WebMidi } = _WebMidi;
|
||||
@@ -190,7 +191,7 @@ function mapCC(mapping, value) {
|
||||
}
|
||||
|
||||
// sends a cc message to the given device on the given channel
|
||||
function sendCC(ccn, ccv, device, midichan, timeOffsetString) {
|
||||
function sendCC(ccn, ccv, device, midichan, targetTime) {
|
||||
if (typeof ccv !== 'number' || ccv < 0 || ccv > 1) {
|
||||
throw new Error('expected ccv to be a number between 0 and 1');
|
||||
}
|
||||
@@ -198,19 +199,23 @@ function sendCC(ccn, ccv, device, midichan, timeOffsetString) {
|
||||
throw new Error('expected ccn to be a number or a string');
|
||||
}
|
||||
const scaled = Math.round(ccv * 127);
|
||||
device.sendControlChange(ccn, scaled, midichan, { time: timeOffsetString });
|
||||
scheduleAtTime(() => {
|
||||
device.sendControlChange(ccn, scaled, midichan);
|
||||
}, targetTime);
|
||||
}
|
||||
|
||||
// sends a program change message to the given device on the given channel
|
||||
function sendProgramChange(progNum, device, midichan, timeOffsetString) {
|
||||
function sendProgramChange(progNum, device, midichan, targetTime) {
|
||||
if (typeof progNum !== 'number' || progNum < 0 || progNum > 127) {
|
||||
throw new Error('expected progNum (program change) to be a number between 0 and 127');
|
||||
}
|
||||
device.sendProgramChange(progNum, midichan, { time: timeOffsetString });
|
||||
scheduleAtTime(() => {
|
||||
device.sendProgramChange(progNum, midichan);
|
||||
}, targetTime);
|
||||
}
|
||||
|
||||
// sends a sysex message to the given device on the given channel
|
||||
function sendSysex(sysexid, sysexdata, device, timeOffsetString) {
|
||||
function sendSysex(sysexid, sysexdata, device, targetTime) {
|
||||
if (Array.isArray(sysexid)) {
|
||||
if (!sysexid.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
||||
throw new Error('all sysexid bytes must be integers between 0 and 255');
|
||||
@@ -225,11 +230,13 @@ function sendSysex(sysexid, sysexdata, device, timeOffsetString) {
|
||||
if (!sysexdata.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
||||
throw new Error('all sysex bytes must be integers between 0 and 255');
|
||||
}
|
||||
device.sendSysex(sysexid, sysexdata, { time: timeOffsetString });
|
||||
scheduleAtTime(() => {
|
||||
device.sendSysex(sysexid, sysexdata);
|
||||
}, targetTime);
|
||||
}
|
||||
|
||||
// sends a NRPN message to the given device on the given channel
|
||||
function sendNRPN(nrpnn, nrpv, device, midichan, timeOffsetString) {
|
||||
function sendNRPN(nrpnn, nrpv, device, midichan, targetTime) {
|
||||
if (Array.isArray(nrpnn)) {
|
||||
if (!nrpnn.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
||||
throw new Error('all nrpnn bytes must be integers between 0 and 255');
|
||||
@@ -237,28 +244,34 @@ function sendNRPN(nrpnn, nrpv, device, midichan, timeOffsetString) {
|
||||
} else if (!Number.isInteger(nrpv) || nrpv < 0 || nrpv > 255) {
|
||||
throw new Error('A:sysexid must be an number between 0 and 255 or an array of such integers');
|
||||
}
|
||||
|
||||
device.sendNRPN(nrpnn, nrpv, midichan, { time: timeOffsetString });
|
||||
scheduleAtTime(() => {
|
||||
device.sendNRPN(nrpnn, nrpv, midichan);
|
||||
}, targetTime);
|
||||
}
|
||||
|
||||
// sends a pitch bend message to the given device on the given channel
|
||||
function sendPitchBend(midibend, device, midichan, timeOffsetString) {
|
||||
function sendPitchBend(midibend, device, midichan, targetTime) {
|
||||
if (typeof midibend !== 'number' || midibend < -1 || midibend > 1) {
|
||||
throw new Error('expected midibend to be a number between -1 and 1');
|
||||
}
|
||||
device.sendPitchBend(midibend, midichan, { time: timeOffsetString });
|
||||
scheduleAtTime(() => {
|
||||
device.sendPitchBend(midibend, midichan);
|
||||
}, targetTime);
|
||||
}
|
||||
|
||||
// sends a channel aftertouch message to the given device on the given channel
|
||||
function sendAftertouch(miditouch, device, midichan, timeOffsetString) {
|
||||
function sendAftertouch(miditouch, device, midichan, targetTime) {
|
||||
if (typeof miditouch !== 'number' || miditouch < 0 || miditouch > 1) {
|
||||
throw new Error('expected miditouch to be a number between 0 and 1');
|
||||
}
|
||||
device.sendChannelAftertouch(miditouch, midichan, { time: timeOffsetString });
|
||||
|
||||
scheduleAtTime(() => {
|
||||
device.sendChannelAftertouch(miditouch, midichan);
|
||||
}, targetTime);
|
||||
}
|
||||
|
||||
// sends a note message to the given device on the given channel
|
||||
function sendNote(note, velocity, duration, device, midichan, timeOffsetString) {
|
||||
function sendNote(note, velocity, duration, device, midichan, targetTime) {
|
||||
if (note == null || note === '') {
|
||||
throw new Error('note cannot be null or empty');
|
||||
}
|
||||
@@ -268,12 +281,12 @@ function sendNote(note, velocity, duration, device, midichan, timeOffsetString)
|
||||
if (duration != null && (typeof duration !== 'number' || duration < 0)) {
|
||||
throw new Error('duration must be a positive number');
|
||||
}
|
||||
|
||||
const midiNumber = typeof note === 'number' ? note : noteToMidi(note);
|
||||
const midiNote = new Note(midiNumber, { attack: velocity, duration });
|
||||
device.playNote(midiNote, midichan, {
|
||||
time: timeOffsetString,
|
||||
});
|
||||
|
||||
scheduleAtTime(() => {
|
||||
device.playNote(midiNote, midichan);
|
||||
}, targetTime);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -309,7 +322,6 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
||||
let midiConfig = {
|
||||
// Default configuration values
|
||||
isController: false, // Disable sending notes for midi controllers
|
||||
latencyMs: 34, // Default latency to get audio engine to line up in ms
|
||||
noteOffsetMs: 10, // Default note-off offset to prevent glitching in ms
|
||||
midichannel: 1, // Default MIDI channel
|
||||
velocity: 0.9, // Default velocity
|
||||
@@ -333,18 +345,13 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
||||
logger(`Midi device disconnected! Available: ${getMidiDeviceNamesString(outputs)}`),
|
||||
});
|
||||
|
||||
return this.onTrigger((time_deprecate, hap, currentTime, cps, targetTime) => {
|
||||
return this.onTrigger((hap, _currentTime, cps, targetTime) => {
|
||||
if (!WebMidi.enabled) {
|
||||
logger('Midi not enabled');
|
||||
return;
|
||||
}
|
||||
hap.ensureObjectValue();
|
||||
|
||||
//magic number to get audio engine to line up, can probably be calculated somehow
|
||||
const latencyMs = midiConfig.latencyMs;
|
||||
// passing a string with a +num into the webmidi api adds an offset to the current time https://webmidijs.org/api/classes/Output
|
||||
const timeOffsetString = `+${getEventOffsetMs(targetTime, currentTime) + latencyMs}`;
|
||||
|
||||
// midi event values from hap with configurable defaults
|
||||
let {
|
||||
note,
|
||||
@@ -380,7 +387,7 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
||||
// if midimap is set, send a cc messages from defined controls
|
||||
if (midicontrolMap.has(midimap)) {
|
||||
const ccs = mapCC(midicontrolMap.get(midimap), hap.value);
|
||||
ccs.forEach(({ ccn, ccv }) => sendCC(ccn, ccv, device, midichan, timeOffsetString));
|
||||
ccs.forEach(({ ccn, ccv }) => sendCC(ccn, ccv, device, midichan, targetTime));
|
||||
} else if (midimap !== 'default') {
|
||||
// Add warning when a non-existent midimap is specified
|
||||
logger(`[midi] midimap "${midimap}" not found! Available maps: ${[...midicontrolMap.keys()].join(', ')}`);
|
||||
@@ -392,12 +399,12 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
||||
// try to prevent glitching by subtracting noteOffsetMs from the duration length
|
||||
const duration = (hap.duration.valueOf() / cps) * 1000 - midiConfig.noteOffsetMs;
|
||||
|
||||
sendNote(note, velocity, duration, device, midichan, timeOffsetString);
|
||||
sendNote(note, velocity, duration, device, midichan, targetTime);
|
||||
}
|
||||
|
||||
// Handle program change
|
||||
if (progNum !== undefined) {
|
||||
sendProgramChange(progNum, device, midichan, timeOffsetString);
|
||||
sendProgramChange(progNum, device, midichan, targetTime);
|
||||
}
|
||||
|
||||
// Handle sysex
|
||||
@@ -407,53 +414,63 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
||||
// if sysexid is an array the first byte is 0x00
|
||||
|
||||
if (sysexid !== undefined && sysexdata !== undefined) {
|
||||
sendSysex(sysexid, sysexdata, device, timeOffsetString);
|
||||
sendSysex(sysexid, sysexdata, device, targetTime);
|
||||
}
|
||||
|
||||
// Handle control change
|
||||
if (ccv !== undefined && ccn !== undefined) {
|
||||
sendCC(ccn, ccv, device, midichan, timeOffsetString);
|
||||
sendCC(ccn, ccv, device, midichan, targetTime);
|
||||
}
|
||||
|
||||
// Handle NRPN non-registered parameter number
|
||||
if (nrpnn !== undefined && nrpv !== undefined) {
|
||||
sendNRPN(nrpnn, nrpv, device, midichan, timeOffsetString);
|
||||
sendNRPN(nrpnn, nrpv, device, midichan, targetTime);
|
||||
}
|
||||
|
||||
// Handle midibend
|
||||
if (midibend !== undefined) {
|
||||
sendPitchBend(midibend, device, midichan, timeOffsetString);
|
||||
sendPitchBend(midibend, device, midichan, targetTime);
|
||||
}
|
||||
|
||||
// Handle miditouch
|
||||
if (miditouch !== undefined) {
|
||||
sendAftertouch(miditouch, device, midichan, timeOffsetString);
|
||||
sendAftertouch(miditouch, device, midichan, targetTime);
|
||||
}
|
||||
|
||||
// Handle midicmd
|
||||
if (hap.whole.begin + 0 === 0) {
|
||||
// we need to start here because we have the timing info
|
||||
device.sendStart({ time: timeOffsetString });
|
||||
scheduleAtTime(() => {
|
||||
device.sendStart();
|
||||
}, targetTime);
|
||||
}
|
||||
if (['clock', 'midiClock'].includes(midicmd)) {
|
||||
device.sendClock({ time: timeOffsetString });
|
||||
scheduleAtTime(() => {
|
||||
device.sendClock();
|
||||
}, targetTime);
|
||||
} else if (['start'].includes(midicmd)) {
|
||||
device.sendStart({ time: timeOffsetString });
|
||||
scheduleAtTime(() => {
|
||||
device.sendStart();
|
||||
}, targetTime);
|
||||
} else if (['stop'].includes(midicmd)) {
|
||||
device.sendStop({ time: timeOffsetString });
|
||||
scheduleAtTime(() => {
|
||||
device.sendStop();
|
||||
}, targetTime);
|
||||
} else if (['continue'].includes(midicmd)) {
|
||||
device.sendContinue({ time: timeOffsetString });
|
||||
scheduleAtTime(() => {
|
||||
device.sendContinue();
|
||||
}, targetTime);
|
||||
} else if (Array.isArray(midicmd)) {
|
||||
if (midicmd[0] === 'progNum') {
|
||||
sendProgramChange(midicmd[1], device, midichan, timeOffsetString);
|
||||
sendProgramChange(midicmd[1], device, midichan, targetTime);
|
||||
} else if (midicmd[0] === 'cc') {
|
||||
if (midicmd.length === 2) {
|
||||
sendCC(midicmd[0], midicmd[1] / 127, device, midichan, timeOffsetString);
|
||||
sendCC(midicmd[0], midicmd[1] / 127, device, midichan, targetTime);
|
||||
}
|
||||
} else if (midicmd[0] === 'sysex') {
|
||||
if (midicmd.length === 3) {
|
||||
const [_, id, data] = midicmd;
|
||||
sendSysex(id, data, device, timeOffsetString);
|
||||
sendSysex(id, data, device, targetTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -493,6 +510,9 @@ export async function midin(input) {
|
||||
otherInputs?.length ? `Also available: ${getMidiDeviceNamesString(otherInputs)}` : ''
|
||||
}`,
|
||||
);
|
||||
}
|
||||
// ensure refs for this input are initialized
|
||||
if (!refs[input]) {
|
||||
refs[input] = {};
|
||||
}
|
||||
const cc = (cc) => ref(() => refs[input][cc] || 0);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strudel/midi",
|
||||
"version": "1.2.3",
|
||||
"version": "1.2.6",
|
||||
"description": "Midi API for strudel",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
@@ -35,5 +35,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, bench } from 'vitest';
|
||||
|
||||
import { calculateTactus } from '../../core/index.mjs';
|
||||
import { calculateSteps } from '../../core/index.mjs';
|
||||
import { mini } from '../index.mjs';
|
||||
|
||||
describe('mini', () => {
|
||||
calculateTactus(true);
|
||||
calculateSteps(true);
|
||||
bench(
|
||||
'+tactus',
|
||||
() => {
|
||||
@@ -13,7 +13,7 @@ describe('mini', () => {
|
||||
{ time: 1000 },
|
||||
);
|
||||
|
||||
calculateTactus(false);
|
||||
calculateSteps(false);
|
||||
bench(
|
||||
'-tactus',
|
||||
() => {
|
||||
@@ -21,5 +21,5 @@ describe('mini', () => {
|
||||
},
|
||||
{ time: 1000 },
|
||||
);
|
||||
calculateTactus(true);
|
||||
calculateSteps(true);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strudel/mini",
|
||||
"version": "1.2.2",
|
||||
"version": "1.2.5",
|
||||
"description": "Mini notation for strudel",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
@@ -38,5 +38,8 @@
|
||||
"peggy": "^4.2.0",
|
||||
"vite": "^6.0.11",
|
||||
"vitest": "^3.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# mondo
|
||||
|
||||
a lisp-based language intended to be used as a custom dsl for patterns that can stand on its own feet.
|
||||
see the `test` folder for usage examples
|
||||
|
||||
more info:
|
||||
|
||||
- [uzulang I](https://garten.salat.dev/uzu/uzulang1.html)
|
||||
- [uzulang II](https://garten.salat.dev/uzu/uzulang2.html)
|
||||
|
||||
## Example Usage
|
||||
|
||||
```js
|
||||
import { MondoRunner } from 'mondolang'
|
||||
// define our library of functions and variables
|
||||
let lib = {
|
||||
add: (a, b) => a + b,
|
||||
mul: (a, b) => a * b,
|
||||
PI: Math.PI,
|
||||
};
|
||||
// this function will evaluate nodes in the syntax tree
|
||||
function evaluator(node) {
|
||||
// check if node is a leaf node (!= list)
|
||||
if (node.type !== 'list') {
|
||||
// check lib if we find a match in the lib, otherwise return value
|
||||
return lib[node.value] ?? node.value;
|
||||
}
|
||||
// now it can only be a list..
|
||||
const [fn, ...args] = node.children;
|
||||
// children in a list will already be evaluated
|
||||
// the first child is expected to be a function
|
||||
if (typeof fn !== 'function') {
|
||||
throw new Error(`"${fn}" is not a function`);
|
||||
}
|
||||
return fn(...args);
|
||||
}
|
||||
const runner = new MondoRunner({ evaluator });
|
||||
const pat = runner.run('add 1 (mul 2 PI)') // 7.283185307179586
|
||||
```
|
||||
@@ -0,0 +1,487 @@
|
||||
/*
|
||||
mondo.mjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/packages/mini/test/mini.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/>.
|
||||
*/
|
||||
|
||||
// evolved from https://garten.salat.dev/lisp/parser.html
|
||||
export class MondoParser {
|
||||
// these are the tokens we expect
|
||||
token_types = {
|
||||
comment: /^\/\/(.*?)(?=\n|$)/,
|
||||
quotes_double: /^"(.*?)"/,
|
||||
quotes_single: /^'(.*?)'/,
|
||||
open_list: /^\(/,
|
||||
close_list: /^\)/,
|
||||
open_angle: /^</,
|
||||
close_angle: /^>/,
|
||||
open_square: /^\[/,
|
||||
close_square: /^\]/,
|
||||
open_curly: /^\{/,
|
||||
close_curly: /^\}/,
|
||||
number: /^-?[0-9]*\.?[0-9]+/, // before pipe!
|
||||
// TODO: better error handling when "-" is used as rest, e.g "s [- bd]"
|
||||
op: /^[*/:!@%?+\-&]|^\.{2}/, // * / : ! @ % ? ..
|
||||
// dollar: /^\$/,
|
||||
pipe: /^#/,
|
||||
stack: /^[,$]/,
|
||||
or: /^[|]/,
|
||||
plain: /^[a-zA-Z0-9-~_^#]+/,
|
||||
};
|
||||
op_precedence = [['*', '/', ':', '!', '@', '%', '?', '+', '-', '..'], ['&']];
|
||||
// matches next token
|
||||
next_token(code, offset = 0) {
|
||||
for (let type in this.token_types) {
|
||||
const match = code.match(this.token_types[type]);
|
||||
if (match) {
|
||||
let token = { type, value: match[0] };
|
||||
if (offset !== -1) {
|
||||
// add location
|
||||
token.loc = [offset, offset + match[0].length];
|
||||
}
|
||||
return token;
|
||||
}
|
||||
}
|
||||
throw new Error(`mondo: could not match '${code}'`);
|
||||
}
|
||||
// takes code string, returns list of matched tokens (if valid)
|
||||
tokenize(code, offset = 0) {
|
||||
let tokens = [];
|
||||
let locEnabled = offset !== -1;
|
||||
let trim = () => {
|
||||
// trim whitespace at start, update offset
|
||||
offset += code.length - code.trimStart().length;
|
||||
// trim start and end to not confuse parser
|
||||
return code.trim();
|
||||
};
|
||||
code = trim();
|
||||
while (code.length > 0) {
|
||||
code = trim();
|
||||
const token = this.next_token(code, locEnabled ? offset : -1);
|
||||
code = code.slice(token.value.length);
|
||||
offset += token.value.length;
|
||||
tokens.push(token);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
// take code, return abstract syntax tree
|
||||
parse(code, offset) {
|
||||
this.code = code;
|
||||
this.offset = offset;
|
||||
this.tokens = this.tokenize(code, offset);
|
||||
const expressions = [];
|
||||
while (this.tokens.length) {
|
||||
expressions.push(this.parse_expr());
|
||||
}
|
||||
if (expressions.length === 0) {
|
||||
// empty case
|
||||
return { type: 'list', children: [] };
|
||||
}
|
||||
// do we have multiple top level expressions or a single non list?
|
||||
if (expressions.length > 1 || expressions[0].type !== 'list') {
|
||||
return {
|
||||
type: 'list',
|
||||
children: this.desugar(expressions),
|
||||
};
|
||||
}
|
||||
// we have a single list
|
||||
return expressions[0];
|
||||
}
|
||||
// parses any valid expression
|
||||
parse_expr() {
|
||||
if (!this.tokens[0]) {
|
||||
throw new Error(`unexpected end of file`);
|
||||
// TODO: could we allow that? like (((((((( s bd
|
||||
// return { type: 'list', children: [] };
|
||||
}
|
||||
let next = this.tokens[0]?.type;
|
||||
if (next === 'open_list') {
|
||||
return this.parse_list();
|
||||
}
|
||||
if (next === 'open_angle') {
|
||||
return this.parse_angle();
|
||||
}
|
||||
if (next === 'open_square') {
|
||||
return this.parse_square();
|
||||
}
|
||||
if (next === 'open_curly') {
|
||||
return this.parse_curly();
|
||||
}
|
||||
return this.consume(next);
|
||||
}
|
||||
// Token[] => Token[][], e.g. (x , y z) => [['x'],['y','z']]
|
||||
split_children(children, split_type) {
|
||||
const chunks = [];
|
||||
while (true) {
|
||||
let splitIndex = children.findIndex((child) => child.type === split_type);
|
||||
if (splitIndex === -1) break;
|
||||
const chunk = children.slice(0, splitIndex);
|
||||
chunks.push(chunk);
|
||||
children = children.slice(splitIndex + 1);
|
||||
}
|
||||
chunks.push(children);
|
||||
return chunks;
|
||||
}
|
||||
desugar_split(children, split_type, next) {
|
||||
const chunks = this.split_children(children, split_type);
|
||||
if (chunks.length === 1) {
|
||||
return next(children);
|
||||
}
|
||||
// collect args of stack function
|
||||
const args = chunks
|
||||
.map((chunk) => {
|
||||
if (!chunk.length) {
|
||||
return; // useful for things like "$ s bd $ s hh*8" (first chunk is empty)
|
||||
}
|
||||
if (chunk.length === 1) {
|
||||
// chunks of one element can be added to the stack as is
|
||||
return chunk[0];
|
||||
}
|
||||
// chunks of multiple args
|
||||
chunk = next(chunk);
|
||||
return { type: 'list', children: chunk };
|
||||
})
|
||||
.filter(Boolean); // ignore empty chunks
|
||||
return [{ type: 'plain', value: split_type }, ...args];
|
||||
}
|
||||
// prevents to get a list, e.g. ((x y)) => (x y)
|
||||
unwrap_children(children) {
|
||||
if (children.length === 1) {
|
||||
return children[0].children;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
desugar_ops(children, types) {
|
||||
while (true) {
|
||||
let opIndex = children.findIndex((child) => child.type === 'op' && types.includes(child.value));
|
||||
if (opIndex === -1) break;
|
||||
const op = { type: 'plain', value: children[opIndex].value };
|
||||
if (opIndex === children.length - 1) {
|
||||
//throw new Error(`cannot use operator as last child.`);
|
||||
children[opIndex] = op; // ignore operator if last child.. e.g. "note [c -]"
|
||||
continue;
|
||||
}
|
||||
if (opIndex === 0) {
|
||||
// regular function call (assuming each operator exists as function)
|
||||
children[opIndex] = op;
|
||||
continue;
|
||||
}
|
||||
// convert infix to prefix notation
|
||||
const left = children[opIndex - 1];
|
||||
const right = children[opIndex + 1];
|
||||
if (left.type === 'pipe') {
|
||||
// "x !* 2" => (* 2 x)
|
||||
children[opIndex] = op;
|
||||
continue;
|
||||
}
|
||||
// some careful error handling
|
||||
if (left.type === 'op') {
|
||||
throw new Error(`got 2 ops in a row: "${left.value}${op.value}"`);
|
||||
}
|
||||
if (right.type === 'op') {
|
||||
let err = `got 2 ops in a row: "${op.value}${right.value}"`;
|
||||
if (op.value === '-') {
|
||||
// yes i know this file is not supposed to know about rests x.X
|
||||
err += '. you probably want a rest, which is "_" in mondo!';
|
||||
}
|
||||
throw new Error(err);
|
||||
}
|
||||
const call = { type: 'list', children: [op, right, left] };
|
||||
// insert call while keeping other siblings
|
||||
children = [...children.slice(0, opIndex - 1), call, ...children.slice(opIndex + 2)];
|
||||
children = this.unwrap_children(children);
|
||||
}
|
||||
return children;
|
||||
}
|
||||
get_lambda(args, children) {
|
||||
// (.fast 2) = (fn (_) (fast _ 2))
|
||||
children = this.desugar(children);
|
||||
const body = children.length === 1 ? children[0] : { type: 'list', children };
|
||||
return [{ type: 'plain', value: 'fn' }, { type: 'list', children: args }, body];
|
||||
}
|
||||
// returns location range of given ast (even if desugared)
|
||||
get_range(ast, range = [Infinity, 0]) {
|
||||
let union = (a, b) => [Math.min(a[0], b[0]), Math.max(a[1], b[1])];
|
||||
if (ast.loc) {
|
||||
return union(range, ast.loc);
|
||||
}
|
||||
if (ast.type !== 'list') {
|
||||
return range;
|
||||
}
|
||||
return ast.children.reduce((range, child) => {
|
||||
const childrange = this.get_range(child, range);
|
||||
return union(range, childrange);
|
||||
}, range);
|
||||
}
|
||||
errorhead(ast) {
|
||||
return `[mondo ${this.get_range(ast)?.join(':') || '?'}]`;
|
||||
}
|
||||
// returns original user code where the given ast originates (even if desugared)
|
||||
get_code_snippet(ast) {
|
||||
const [min, max] = this.get_range(ast);
|
||||
return this.code.slice(min - this.offset, max - this.offset);
|
||||
}
|
||||
desugar_pipes(children) {
|
||||
let chunks = this.split_children(children, 'pipe');
|
||||
while (chunks.length > 1) {
|
||||
let [left, right, ...rest] = chunks;
|
||||
|
||||
if (!left.length) {
|
||||
const arg = { type: 'plain', value: '_' };
|
||||
return this.get_lambda([arg], [arg, ...children]);
|
||||
}
|
||||
// s jazz hh.fast 2 => (fast 2 (s jazz hh))
|
||||
const call = left.length > 1 ? { type: 'list', children: left } : left[0];
|
||||
chunks = [[...right, call], ...rest];
|
||||
}
|
||||
// return next(chunks[0]);
|
||||
return chunks[0];
|
||||
}
|
||||
parse_pair(open_type, close_type) {
|
||||
const begin = this.tokens[0].loc?.[0];
|
||||
this.consume(open_type);
|
||||
const children = [];
|
||||
while (this.tokens[0]?.type !== close_type) {
|
||||
children.push(this.parse_expr());
|
||||
}
|
||||
const end = this.tokens[0].loc?.[1];
|
||||
this.consume(close_type);
|
||||
const node = { type: 'list', children };
|
||||
if (begin !== undefined) {
|
||||
node.loc = [begin, end];
|
||||
node.raw = this.code.slice(begin, end);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
desugar(children, type) {
|
||||
// if type is given, the first element is expected to contain it as plain value
|
||||
// e.g. with (square a b, c), we want to split (a b, c) and ignore "square"
|
||||
children = type ? children.slice(1) : children;
|
||||
children = this.desugar_split(children, 'stack', (children) =>
|
||||
this.desugar_split(children, 'or', (children) => {
|
||||
// chunks of multiple args
|
||||
if (type) {
|
||||
// the type we've removed before splitting needs to be added back
|
||||
children = [{ type: 'plain', value: type }, ...children];
|
||||
}
|
||||
// for each precendence group, call desugar_ops once
|
||||
this.op_precedence.forEach((ops) => {
|
||||
children = this.desugar_ops(children, ops);
|
||||
});
|
||||
children = this.desugar_pipes(children);
|
||||
return children;
|
||||
}),
|
||||
);
|
||||
return children;
|
||||
}
|
||||
parse_list() {
|
||||
let node = this.parse_pair('open_list', 'close_list');
|
||||
node.children = this.desugar(node.children);
|
||||
return node;
|
||||
}
|
||||
parse_angle() {
|
||||
let node = this.parse_pair('open_angle', 'close_angle');
|
||||
node.children.unshift({ type: 'plain', value: 'angle' });
|
||||
node.children = this.desugar(node.children, 'angle');
|
||||
return node;
|
||||
}
|
||||
parse_square() {
|
||||
let node = this.parse_pair('open_square', 'close_square');
|
||||
node.children.unshift({ type: 'plain', value: 'square' });
|
||||
node.children = this.desugar(node.children, 'square');
|
||||
return node;
|
||||
}
|
||||
parse_curly() {
|
||||
let node = this.parse_pair('open_curly', 'close_curly');
|
||||
node.children.unshift({ type: 'plain', value: 'curly' });
|
||||
node.children = this.desugar(node.children, 'curly');
|
||||
return node;
|
||||
}
|
||||
consume(type) {
|
||||
// shift removes first element and returns it
|
||||
const token = this.tokens.shift();
|
||||
if (token.type !== type) {
|
||||
throw new Error(`expected token type ${type}, got ${token.type}`);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
get_locations(code, offset = 0) {
|
||||
let walk = (ast, locations = []) => {
|
||||
if (ast.type === 'list') {
|
||||
return ast.children.forEach((child) => walk(child, locations));
|
||||
}
|
||||
if (ast.loc) {
|
||||
locations.push(ast.loc);
|
||||
}
|
||||
};
|
||||
const ast = this.parse(code, offset);
|
||||
let locations = [];
|
||||
walk(ast, locations);
|
||||
return locations;
|
||||
}
|
||||
}
|
||||
|
||||
export function printAst(ast, compact = false, lvl = 0) {
|
||||
const br = compact ? '' : '\n';
|
||||
const spaces = compact ? '' : Array(lvl).fill(' ').join('');
|
||||
if (ast.type === 'list') {
|
||||
return `${lvl ? br : ''}${spaces}(${ast.children.map((child) => printAst(child, compact, lvl + 1)).join(' ')}${
|
||||
ast.children.find((child) => child.type === 'list') ? `${br}${spaces})` : ')'
|
||||
}`;
|
||||
}
|
||||
return `${ast.value}`;
|
||||
}
|
||||
|
||||
// lisp runner
|
||||
export class MondoRunner {
|
||||
constructor({ evaluator } = {}) {
|
||||
this.parser = new MondoParser();
|
||||
this.evaluator = evaluator;
|
||||
this.assert(typeof evaluator === 'function', `expected an evaluator function to be passed to new MondoRunner`);
|
||||
}
|
||||
// a helper to check conditions and throw if they are not met
|
||||
assert(condition, error) {
|
||||
if (!condition) {
|
||||
throw new Error(error);
|
||||
}
|
||||
}
|
||||
run(code, scope, offset = 0) {
|
||||
const ast = this.parser.parse(code, offset);
|
||||
//console.log(printAst(ast));
|
||||
return this.evaluate(ast, scope);
|
||||
}
|
||||
evaluate_let(ast, scope) {
|
||||
// (let ((x 3) (y 4)) ...body)
|
||||
// = ((fn (x y) ...body) 3 4)
|
||||
const defs = ast.children[1].children;
|
||||
const args = defs.map((pair) => pair.children[0]);
|
||||
const vals = defs.map((pair) => pair.children[1]);
|
||||
const body = ast.children.slice(2);
|
||||
const lambda = {
|
||||
type: 'list',
|
||||
children: [{ type: 'plain', value: 'fn' }, { type: 'list', children: args }, ...body],
|
||||
};
|
||||
return this.evaluate({ type: 'list', children: [lambda, ...vals] }, scope);
|
||||
}
|
||||
evaluate_def(ast, scope) {
|
||||
// function definition special form?
|
||||
if (ast.children[1].type === 'list') {
|
||||
// (def (add a b) (+ a b))
|
||||
// => (def add (fn (a b) (+ a b)) )
|
||||
const args = ast.children[1].children.slice(1);
|
||||
const lambda = {
|
||||
// lambda
|
||||
type: 'list',
|
||||
children: [
|
||||
{ type: 'plain', value: 'fn' },
|
||||
{ type: 'list', children: args },
|
||||
...ast.children.slice(2), // body
|
||||
],
|
||||
};
|
||||
// we mutate to make sure the old ast wont make a mess later
|
||||
ast.children[1] = ast.children[1].children[0];
|
||||
ast.children[2] = lambda;
|
||||
ast.children = ast.children.slice(0, 3); // throw away rest
|
||||
}
|
||||
// (def name body)
|
||||
if (ast.children.length !== 3) {
|
||||
throw new Error(`expected "def" to have 3 children, but got ${ast.children.length}`);
|
||||
}
|
||||
const name = ast.children[1].value;
|
||||
const body = this.evaluate(ast.children[2], scope);
|
||||
scope[name] = body;
|
||||
// def with fall through
|
||||
}
|
||||
evaluate_match(ast, scope) {
|
||||
// (match (p1 e1) (p2 e2) ... (pn en))
|
||||
// = cond in lisp
|
||||
if (ast.children.length < 2) {
|
||||
return;
|
||||
}
|
||||
const [_, ...body] = ast.children;
|
||||
for (let i = 0; i < body.length; ++i) {
|
||||
const [predicate, exp] = body[i].children;
|
||||
if (predicate.value === 'else') {
|
||||
return this.evaluate(exp, scope);
|
||||
}
|
||||
const outcome = this.evaluate(predicate, scope);
|
||||
if (outcome) {
|
||||
return this.evaluate(exp, scope);
|
||||
}
|
||||
}
|
||||
return undefined; // nothing was matched
|
||||
}
|
||||
evaluate_if(ast, scope) {
|
||||
// if is a special case of match
|
||||
if (ast.children.length !== 4) {
|
||||
return;
|
||||
}
|
||||
// (if predicate consequent alternative)
|
||||
const [_, predicate, consequent, alternative] = ast.children;
|
||||
// (match (predicate consequent) (else alternative))
|
||||
const matcher = {
|
||||
type: 'list',
|
||||
children: [
|
||||
{ type: 'plain', value: 'match' },
|
||||
{ type: 'list', children: [predicate, consequent] },
|
||||
{ type: 'list', children: [{ type: 'plain', value: 'else' }, alternative] },
|
||||
],
|
||||
};
|
||||
return this.evaluate_match(matcher, scope);
|
||||
}
|
||||
evaluate_lambda(ast, scope) {
|
||||
// (fn (_) (ply 2 _)
|
||||
// ^args ^ body
|
||||
const [_, formalArgs, ...body] = ast.children;
|
||||
return (...args) => {
|
||||
const params = Object.fromEntries(formalArgs.children.map((arg, i) => [arg.value, args[i]]));
|
||||
const closure = {
|
||||
...scope,
|
||||
...params,
|
||||
};
|
||||
// body can have multiple expressions
|
||||
const res = body.map((exp) => this.evaluate(exp, closure));
|
||||
// last expression is the return value
|
||||
return res[res.length - 1];
|
||||
};
|
||||
}
|
||||
evaluate_list(ast, scope) {
|
||||
// evaluate all children before evaluating list (dont mutate!!!)
|
||||
const args = ast.children
|
||||
.filter((child) => child.type !== 'comment') // ignore comments
|
||||
.map((arg) => this.evaluate(arg, scope));
|
||||
const node = { type: 'list', children: args };
|
||||
return this.evaluator(node, scope);
|
||||
}
|
||||
evaluate_leaf(ast, scope) {
|
||||
if (ast.type === 'number') {
|
||||
ast.value = Number(ast.value);
|
||||
} else if (['quotes_double', 'quotes_single'].includes(ast.type)) {
|
||||
ast.value = ast.value.slice(1, -1);
|
||||
ast.type = 'string';
|
||||
}
|
||||
return this.evaluator(ast, scope);
|
||||
}
|
||||
evaluate(ast, scope = {}) {
|
||||
if (ast.type !== 'list') {
|
||||
return this.evaluate_leaf(ast, scope);
|
||||
}
|
||||
const name = ast.children[0]?.value;
|
||||
if (name === 'fn') {
|
||||
return this.evaluate_lambda(ast, scope);
|
||||
}
|
||||
if (name === 'match') {
|
||||
return this.evaluate_match(ast, scope);
|
||||
}
|
||||
if (name === 'if') {
|
||||
return this.evaluate_if(ast, scope);
|
||||
}
|
||||
if (name === 'let') {
|
||||
return this.evaluate_let(ast, scope);
|
||||
}
|
||||
if (name === 'def') {
|
||||
this.evaluate_def(ast, scope);
|
||||
}
|
||||
return this.evaluate_list(ast, scope);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "mondolang",
|
||||
"version": "1.1.1",
|
||||
"description": "a language for functional composition that translates to js",
|
||||
"main": "mondo.mjs",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"main": "dist/mondo.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"bench": "vitest bench",
|
||||
"build": "vite build",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/tidalcycles/strudel.git"
|
||||
},
|
||||
"keywords": [
|
||||
"tidalcycles",
|
||||
"strudel",
|
||||
"pattern",
|
||||
"livecoding",
|
||||
"algorave"
|
||||
],
|
||||
"author": "Felix Roos <flix91@gmail.com>",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"bugs": {
|
||||
"url": "https://github.com/tidalcycles/strudel/issues"
|
||||
},
|
||||
"homepage": "https://github.com/tidalcycles/strudel/blob/main/packages/mondo/README.md",
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.11",
|
||||
"vitest": "^3.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,979 @@
|
||||
/*
|
||||
mondo.test.mjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/packages/mini/test/mini.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 { describe, expect, it } from 'vitest';
|
||||
import { MondoParser, printAst, MondoRunner } from '../mondo.mjs';
|
||||
|
||||
const parser = new MondoParser();
|
||||
const p = (code) => parser.parse(code, -1);
|
||||
|
||||
describe('mondo tokenizer', () => {
|
||||
const parser = new MondoParser();
|
||||
it('should tokenize with locations', () =>
|
||||
expect(
|
||||
parser
|
||||
.tokenize('(one two three)')
|
||||
.map((t) => t.value + '=' + t.loc.join('-'))
|
||||
.join(' '),
|
||||
).toEqual('(=0-1 one=1-4 two=5-8 three=9-14 )=14-15'));
|
||||
// it('should parse with loangleions', () => expect(parser.parse('(one two three)')).toEqual());
|
||||
it('should get loangleions', () =>
|
||||
expect(parser.get_locations('s bd rim')).toEqual([
|
||||
[0, 1],
|
||||
[2, 4],
|
||||
[5, 8],
|
||||
]));
|
||||
});
|
||||
describe('mondo s-expressions parser', () => {
|
||||
it('should parse an empty string', () => expect(p('')).toEqual({ type: 'list', children: [] }));
|
||||
it('should parse a single item', () =>
|
||||
expect(p('a')).toEqual({ type: 'list', children: [{ type: 'plain', value: 'a' }] }));
|
||||
it('should parse an empty list', () => expect(p('()')).toEqual({ type: 'list', children: [] }));
|
||||
it('should parse a list with 1 item', () =>
|
||||
expect(p('(a)')).toEqual({ type: 'list', children: [{ type: 'plain', value: 'a' }] }));
|
||||
it('should parse a list with 2 items', () =>
|
||||
expect(p('(a b)')).toEqual({
|
||||
type: 'list',
|
||||
children: [
|
||||
{ type: 'plain', value: 'a' },
|
||||
{ type: 'plain', value: 'b' },
|
||||
],
|
||||
}));
|
||||
it('should parse a list with 2 items', () =>
|
||||
expect(p('(a (b c))')).toEqual({
|
||||
type: 'list',
|
||||
children: [
|
||||
{ type: 'plain', value: 'a' },
|
||||
{
|
||||
type: 'list',
|
||||
children: [
|
||||
{ type: 'plain', value: 'b' },
|
||||
{ type: 'plain', value: 'c' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}));
|
||||
it('should parse numbers', () =>
|
||||
expect(p('(1 .2 1.2 10 22.3)')).toEqual({
|
||||
type: 'list',
|
||||
children: [
|
||||
{ type: 'number', value: '1' },
|
||||
{ type: 'number', value: '.2' },
|
||||
{ type: 'number', value: '1.2' },
|
||||
{ type: 'number', value: '10' },
|
||||
{ type: 'number', value: '22.3' },
|
||||
],
|
||||
}));
|
||||
it('should parse comments', () =>
|
||||
expect(p('a // hello')).toEqual({
|
||||
type: 'list',
|
||||
children: [
|
||||
{ type: 'plain', value: 'a' },
|
||||
{ type: 'comment', value: '// hello' },
|
||||
],
|
||||
}));
|
||||
});
|
||||
|
||||
let desguar = (a) => {
|
||||
return printAst(parser.parse(a), true);
|
||||
};
|
||||
|
||||
describe('mondo sugar', () => {
|
||||
it('should desugar []', () => expect(desguar('[a b c]')).toEqual('(square a b c)'));
|
||||
it('should desugar [] nested', () => expect(desguar('[a [b c] d]')).toEqual('(square a (square b c) d)'));
|
||||
it('should desugar <>', () => expect(desguar('<a b c>')).toEqual('(angle a b c)'));
|
||||
it('should desugar <> nested', () => expect(desguar('<a <b c> d>')).toEqual('(angle a (angle b c) d)'));
|
||||
it('should desugar mixed [] <>', () => expect(desguar('[a <b c>]')).toEqual('(square a (angle b c))'));
|
||||
it('should desugar mixed <> []', () => expect(desguar('<a [b c]>')).toEqual('(angle a (square b c))'));
|
||||
|
||||
it('should desugar #', () => expect(desguar('s jazz # fast 2')).toEqual('(fast 2 (s jazz))'));
|
||||
it('should desugar # square', () => expect(desguar('[bd cp # fast 2]')).toEqual('(fast 2 (square bd cp))'));
|
||||
it('should desugar # twice', () => expect(desguar('s jazz # fast 2 # slow 2')).toEqual('(slow 2 (fast 2 (s jazz)))'));
|
||||
it('should desugar # nested', () => expect(desguar('(s cp # fast 2)')).toEqual('(fast 2 (s cp))'));
|
||||
it('should desugar # within []', () => expect(desguar('[bd cp # fast 2]')).toEqual('(fast 2 (square bd cp))'));
|
||||
it('should desugar # within , within []', () =>
|
||||
expect(desguar('[bd cp # fast 2, x]')).toEqual('(stack (fast 2 (square bd cp)) x)'));
|
||||
|
||||
// it('should desugar .(.', () => expect(desguar('[jazz hh.(.fast 2)]')).toEqual('(square jazz (fast 2 hh))'));
|
||||
|
||||
it('should desugar , |', () => expect(desguar('[bd, hh | oh]')).toEqual('(stack bd (or hh oh))'));
|
||||
it('should desugar , | of []', () =>
|
||||
expect(desguar('[bd, hh | [oh rim]]')).toEqual('(stack bd (or hh (square oh rim)))'));
|
||||
it('should desugar , square', () => expect(desguar('[bd, hh]')).toEqual('(stack bd hh)'));
|
||||
it('should desugar , square 2', () => expect(desguar('[bd, hh oh]')).toEqual('(stack bd (square hh oh))'));
|
||||
it('should desugar , square 3', () =>
|
||||
expect(desguar('[bd cp, hh oh]')).toEqual('(stack (square bd cp) (square hh oh))'));
|
||||
it('should desugar , angle', () => expect(desguar('<bd, hh>')).toEqual('(stack bd hh)'));
|
||||
it('should desugar , angle 2', () => expect(desguar('<bd, hh oh>')).toEqual('(stack bd (angle hh oh))'));
|
||||
it('should desugar , angle 3', () =>
|
||||
expect(desguar('<bd cp, hh oh>')).toEqual('(stack (angle bd cp) (angle hh oh))'));
|
||||
it('should desugar , ()', () => expect(desguar('(s bd, s cp)')).toEqual('(stack (s bd) (s cp))'));
|
||||
it('should desugar * /', () => expect(desguar('[a b*2 c d/3 e]')).toEqual('(square a (* 2 b) c (/ 3 d) e)'));
|
||||
it('should desugar []*x', () => expect(desguar('[a [b c]*3]')).toEqual('(square a (* 3 (square b c)))'));
|
||||
it('should desugar []*<x y>', () => expect(desguar('[a b*<2 3> c]')).toEqual('(square a (* (angle 2 3) b) c)'));
|
||||
it('should desugar x:y', () => expect(desguar('x:y')).toEqual('(: y x)'));
|
||||
it('should desugar x:y:z', () => expect(desguar('x:y:z')).toEqual('(: z (: y x))'));
|
||||
it('should desugar x:y*x', () => expect(desguar('bd:0*2')).toEqual('(* 2 (: 0 bd))'));
|
||||
it('should desugar x&y:z', () => expect(desguar('bd&3:8')).toEqual('(& (: 8 3) bd)'));
|
||||
it('should desugar a..b', () => expect(desguar('0..2')).toEqual('(.. 2 0)'));
|
||||
/* it('should desugar x $ y', () => expect(desguar('x $ y')).toEqual('(x y)'));
|
||||
it('should desugar x $ y z', () => expect(desguar('x $ y z')).toEqual('(x (y z))'));
|
||||
it('should desugar x $ y . z', () => expect(desguar('x $ y . z')).toEqual('(z (x y))')); */
|
||||
|
||||
it('should desugar README example', () =>
|
||||
expect(desguar('s [bd hh*2 (cp # crush 4) <mt ht lt>] # speed .8')).toEqual(
|
||||
'(speed .8 (s (square bd (* 2 hh) (crush 4 cp) (angle mt ht lt))))',
|
||||
));
|
||||
|
||||
it('should desugar (#)', () => expect(desguar('(#)')).toEqual('(fn (_) _)'));
|
||||
it('should desugar lambda', () => expect(desguar('(# fast 2)')).toEqual('(fn (_) (fast 2 _))'));
|
||||
it('should desugar lambda call', () => expect(desguar('((# mul 2) 2)')).toEqual('((fn (_) (mul 2 _)) 2)'));
|
||||
it('should desugar lambda with pipe', () =>
|
||||
expect(desguar('(# fast 2 # room 1)')).toEqual('(fn (_) (room 1 (fast 2 _)))'));
|
||||
/* const lambda = parser.parse('(lambda (_) (fast 2 _))');
|
||||
const target = { type: 'plain', value: 'xyz' };
|
||||
it('should desugar_lambda', () =>
|
||||
expect(printAst(parser.desugar_lambda(lambda.children, target))).toEqual('(fast 2 xyz)')); */
|
||||
});
|
||||
|
||||
describe('mondo arithmetic', () => {
|
||||
let multi =
|
||||
(op) =>
|
||||
(init, ...rest) =>
|
||||
rest.reduce((acc, arg) => op(acc, arg), init);
|
||||
|
||||
let lib = {
|
||||
'+': multi((a, b) => a + b),
|
||||
add: multi((a, b) => a + b),
|
||||
'-': multi((a, b) => a - b),
|
||||
sub: multi((a, b) => a - b),
|
||||
'*': multi((a, b) => a * b),
|
||||
'/': multi((a, b) => a / b),
|
||||
mod: multi((a, b) => a % b),
|
||||
eq: (a, b) => a === b,
|
||||
lt: (a, b) => a < b,
|
||||
gt: (a, b) => a > b,
|
||||
and: (a, b) => a && b,
|
||||
or: (a, b) => a || b,
|
||||
not: (a) => !a,
|
||||
run: (...args) => args[args.length - 1],
|
||||
def: () => 0,
|
||||
sin: Math.sin,
|
||||
cos: Math.cos,
|
||||
PI: Math.PI,
|
||||
cons: (a, b) => [a, ...(Array.isArray(b) ? b : [b])],
|
||||
car: (pair) => pair[0],
|
||||
cdr: (pair) => pair.slice(1),
|
||||
list: (...items) => items,
|
||||
nil: [],
|
||||
isnull: (items) => items.length === 0,
|
||||
concat: (...msgs) => msgs.join(''),
|
||||
error: (...msgs) => {
|
||||
throw new Error(msgs.join(' '));
|
||||
},
|
||||
};
|
||||
function evaluator(node, scope) {
|
||||
if (node.type !== 'list') {
|
||||
// is leaf
|
||||
return scope[node.value] ?? lib[node.value] ?? node.value;
|
||||
}
|
||||
// is list
|
||||
const [fn, ...args] = node.children;
|
||||
if (typeof fn !== 'function') {
|
||||
throw new Error(`"${fn}": expected function, got ${typeof fn} "${fn}"`);
|
||||
}
|
||||
return fn(...args);
|
||||
}
|
||||
const runner = new MondoRunner({ evaluator });
|
||||
let evaluate = (exp, scope) => runner.run(`run ${exp}`, scope);
|
||||
let pretty = (exp) => printAst(runner.parser.parse(exp), false);
|
||||
//it('should eval nested expression', () => expect(runner.run('add 1 (mul 2 PI)').toFixed(2)).toEqual('7.28'));
|
||||
|
||||
it('eval number', () => expect(evaluate('2')).toEqual(2));
|
||||
it('eval string', () => expect(evaluate('abc')).toEqual('abc'));
|
||||
it('eval list', () => expect(evaluate('(+ 1 2)')).toEqual(3));
|
||||
it('eval nested list', () => expect(evaluate('(+ 1 (+ 2 3))')).toEqual(6));
|
||||
it('def number', () => expect(evaluate('(def a 2) a')).toEqual(2));
|
||||
it('def + ref number', () => expect(evaluate('(def a 2) (* a a)')).toEqual(4));
|
||||
it('def + call lambda', () => expect(evaluate('(def sqr (fn (x) (* x x))) (sqr 3)')).toEqual(9));
|
||||
|
||||
// sicp
|
||||
it('sicp 8.1', () => expect(evaluate('(+ 137 349)')).toEqual(486));
|
||||
it('sicp 8.2', () => expect(evaluate('(- 1000 334)')).toEqual(666));
|
||||
it('sicp 8.3', () => expect(evaluate('(* 5 99)')).toEqual(495));
|
||||
it('sicp 8.4', () => expect(evaluate('(/ 10 5)')).toEqual(2));
|
||||
it('sicp 8.5', () => expect(evaluate('(+ 2.7 10)')).toEqual(12.7));
|
||||
it('sicp 9.1', () => expect(evaluate('(+ 21 35 12 7)')).toEqual(75));
|
||||
it('sicp 9.2', () => expect(evaluate('(* 25 4 12)')).toEqual(1200));
|
||||
it('sicp 9.3', () => expect(evaluate('(+ (* 3 5) (- 10 6))')).toEqual(19));
|
||||
it('sicp 9.4', () =>
|
||||
expect(pretty('(+ (* 3 (+ (* 2 4) (+ 3 5))) (+ (- 10 7) 6))')).toEqual(`(+
|
||||
(* 3
|
||||
(+
|
||||
(* 2 4)
|
||||
(+ 3 5)
|
||||
)
|
||||
)
|
||||
(+
|
||||
(- 10 7) 6
|
||||
)
|
||||
)`)); // this is not exactly pretty printing by convention..
|
||||
|
||||
let scope = {};
|
||||
it('sicp 11.1', () => expect(evaluate('(def size 2) (* 5 size)', scope)).toEqual(10));
|
||||
it('sicp 11.2', () =>
|
||||
expect(evaluate('(def pi 3.14159) (def radius 10) (* pi (* radius radius))', scope)).toEqual(314.159));
|
||||
it('sicp 11.3', () => expect(evaluate('(def circumference (* 2 pi radius))', scope)).toEqual(0));
|
||||
it('sicp 11.4', () => expect(evaluate('circumference', scope)).toEqual(62.8318));
|
||||
it('sicp 13.1', () => expect(evaluate('(* (+ 2 (* 4 6)) (+ 3 5 7))')).toEqual(390));
|
||||
it('sicp 16.1', () => expect(evaluate('(def (square x) (* x x))', scope)).toEqual(0));
|
||||
// it('sicp 16.1', () => expect(evaluate('(def (square x) (* x x))', scope)).toEqual(0));
|
||||
it('sicp 17.1', () => expect(evaluate('(square 21)', scope)).toEqual(441));
|
||||
it('sicp 17.2', () => expect(evaluate('(square (+ 2 5))', scope)).toEqual(49));
|
||||
it('sicp 17.3', () => expect(evaluate('(square (square 3))', scope)).toEqual(81));
|
||||
it('sicp 17.4', () => expect(evaluate(`(def (sumofsquares x y) (+ (square x) (square y)))`, scope)).toEqual(0));
|
||||
it('sicp 17.5', () => expect(evaluate(`(sumofsquares 3 4)`, scope)).toEqual(25));
|
||||
it('sicp 17.6', () => expect(evaluate(`(def (f a) (sumofsquares (+ a 1) (* a 2))) (f 5)`, scope)).toEqual(136));
|
||||
it('sicp 21.1', () => expect(evaluate(`(sumofsquares (+ 5 1) (* 5 2))`, scope)).toEqual(136));
|
||||
|
||||
it('sicp 22.1', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`(def (abs x)
|
||||
(match
|
||||
((gt x 0) x)
|
||||
((eq x 0) 0)
|
||||
((lt x 0) (- 0 x))
|
||||
))`, // sicp was doing (- x), which doesnt work with our -
|
||||
scope,
|
||||
),
|
||||
).toEqual(0));
|
||||
|
||||
it('sicp gt1', () => expect(evaluate(`(gt -12 0)`, scope)).toEqual(false));
|
||||
it('sicp gt2', () => expect(evaluate(`(gt 0 -12)`, scope)).toEqual(true));
|
||||
it('sicp lt1', () => expect(evaluate(`(lt -12 0)`, scope)).toEqual(true));
|
||||
it('sicp lt2', () => expect(evaluate(`(lt 0 -12)`, scope)).toEqual(false));
|
||||
|
||||
it('sicp 24.1', () => expect(evaluate(`(abs (- 3))`, scope)).toEqual(3));
|
||||
it('sicp 24.2', () => expect(evaluate(`(abs (+ 3))`, scope)).toEqual(3));
|
||||
it('sicp 24.3', () => expect(evaluate(`(abs -12)`, scope)).toEqual(12));
|
||||
|
||||
it('sicp 24.4', () => expect(evaluate(`(def (abs x) (if (lt x 0) (- 0 x) x))`, scope)).toEqual(0));
|
||||
it('sicp 24.5', () => expect(evaluate(`(abs -13)`, scope)).toEqual(13));
|
||||
it('sicp 25.1', () => expect(evaluate(`(and (gt 6 5) (lt 6 10))`, scope)).toEqual(true));
|
||||
it('sicp 25.2', () => expect(evaluate(`(and (gt 4 5) (lt 6 10))`, scope)).toEqual(false));
|
||||
|
||||
it('sicp ex1.1.1', () => expect(evaluate(`(def a 3)`, scope)).toEqual(0));
|
||||
it('sicp ex1.1.2', () => expect(evaluate(`(def b (+ a 1))`, scope)).toEqual(0));
|
||||
it('sicp ex1.1.3', () => expect(evaluate(`(+ a b (* a b))`, scope)).toEqual(19));
|
||||
it('sicp ex1.1.4', () => expect(evaluate(`(if (and (gt b a) (lt b (* a b))) b a)`, scope)).toEqual(4));
|
||||
it('sicp ex1.1.5', () => expect(evaluate(`(match ((eq a 4) 6) ((eq b 4) (+ 6 7 a)) (else 25))`, scope)).toEqual(16));
|
||||
it('sicp ex1.1.6', () => expect(evaluate(`(+ 2 (if (gt b a) b a))`, scope)).toEqual(6));
|
||||
it('sicp ex1.1.7', () =>
|
||||
expect(evaluate(`(* (match ((gt a b) a) ((lt a b) b) (else -1)) (+ a 1))`, scope)).toEqual(16));
|
||||
|
||||
// .. cant use "+" and "-" as standalone expressions, because they are parsed as operators...
|
||||
it('sicp ex1.4.1', () => expect(evaluate(`(def (foo a b) ((if (gt b 0) add sub) a b))`, scope)).toEqual(0));
|
||||
it('sicp ex1.4.1', () => expect(evaluate(`(foo 3 1)`, scope)).toEqual(4));
|
||||
it('sicp ex1.4.2', () => expect(evaluate(`(foo 3 -1)`, scope)).toEqual(4));
|
||||
|
||||
// 1.1.7 Example: Square Roots by Newton’s Method
|
||||
it('sicp 30.1', () =>
|
||||
expect(evaluate(`(def (goodenuf guess x) (lt (abs (- (square guess) x)) 0.001))`, scope)).toEqual(0));
|
||||
it('sicp 30.2', () => expect(evaluate(`(goodenuf 1 1.001)`, scope)).toEqual(true));
|
||||
it('sicp 30.3', () => expect(evaluate(`(goodenuf 1 1.002)`, scope)).toEqual(false));
|
||||
it('sicp 30.4', () => expect(evaluate(`(def (average x y) (/ (+ x y) 2))`, scope)).toEqual(0));
|
||||
it('sicp 30.5', () => expect(evaluate(`(average 18 20)`, scope)).toEqual(19));
|
||||
it('sicp 30.6', () => expect(evaluate(`(def (improve guess x) (average guess (/ x guess)))`, scope)).toEqual(0));
|
||||
it('sicp 31.1', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`(def (sqrtiter guess x) (if (goodenuf guess x)
|
||||
guess
|
||||
(sqrtiter (improve guess x) x)))`,
|
||||
scope,
|
||||
),
|
||||
).toEqual(0));
|
||||
it('sicp 31.2', () => expect(evaluate(`(def (sqrt x) (sqrtiter 1.0 x))`, scope)).toEqual(0));
|
||||
it('sicp 31.3', () => expect(evaluate(`(sqrt 9)`, scope)).toEqual(3.00009155413138));
|
||||
it('sicp 31.4', () => expect(evaluate(`(sqrt (+ 100 37))`, scope)).toEqual(11.704699917758145));
|
||||
// eslint-disable-next-line no-loss-of-precision
|
||||
it('sicp 31.5', () => expect(evaluate(`(sqrt (+ (sqrt 2) (sqrt 3)))`, scope)).toEqual(1.77392790232078925));
|
||||
it('sicp 31.6', () => expect(evaluate(`(square (sqrt 1000))`, scope)).toEqual(1000.000369924366));
|
||||
|
||||
// lexical scoping
|
||||
it('sicp 39.1', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (sqrt x)
|
||||
(def (goodenough guess)
|
||||
(lt (abs (- (square guess) x)) 0.001))
|
||||
(def (improve guess)
|
||||
(average guess (/ x guess)))
|
||||
(def (sqrt-iter guess)
|
||||
(if (goodenough guess) guess (sqrt-iter (improve guess))))
|
||||
(sqrtiter 1.0))
|
||||
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual(0));
|
||||
|
||||
// recursive fac
|
||||
it('sicp 41.1', () => expect(evaluate(`(def (fac n) (if (eq n 1) 1 (* n (fac (- n 1)))))`, scope)).toEqual(0));
|
||||
it('sicp 41.2', () => expect(evaluate(`(fac 4)`, scope)).toEqual(24));
|
||||
|
||||
// iterative fac
|
||||
it('sicp 41.3', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (factorial n) (factiter 1 1 n))
|
||||
(def (factiter product counter maxcount)
|
||||
(if (gt counter maxcount)
|
||||
product
|
||||
(factiter (* counter product)
|
||||
(+ counter 1)
|
||||
maxcount)))
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual(0));
|
||||
it('sicp 41.4', () => expect(evaluate(`(fac 4)`, scope)).toEqual(24));
|
||||
|
||||
// 46.1
|
||||
/* (def (+ a b)
|
||||
(if (= a 0) b (inc (+ (dec a) b))))
|
||||
(def (+ a b)
|
||||
(if (= a 0) b (+ (dec a) (inc b)))) */
|
||||
|
||||
// Exercise 1.10
|
||||
// Ackermann’s function
|
||||
it('sicp 47.1', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (A x y) (match ((eq y 0) 0)
|
||||
((eq x 0) (* 2 y))
|
||||
((eq y 1) 2)
|
||||
(else (A (- x 1) (A x (- y 1))))))
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual(0));
|
||||
it('sicp 47.2', () => expect(evaluate(`(A 1 10)`, scope)).toEqual(1024));
|
||||
it('sicp 47.3', () => expect(evaluate(`(A 2 4)`, scope)).toEqual(65536));
|
||||
it('sicp 47.4', () => expect(evaluate(`(A 3 3)`, scope)).toEqual(65536));
|
||||
it('sicp 47.5', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (f n) (A 0 n))
|
||||
(def (g n) (A 1 n)))
|
||||
(def (h n) (A 2 n))
|
||||
(def (k n) (* 5 n n))
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual(0));
|
||||
|
||||
// Tree Recursion
|
||||
// recursive process
|
||||
it('sicp 48.1', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (fib n) (match ((eq n 0) 0) ((eq n 1) 1)
|
||||
(else (+ (fib (- n 1)) (fib (- n 2))))))
|
||||
(fib 7)
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual(13));
|
||||
|
||||
// iterative process
|
||||
it('sicp 48.2', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (fib n) (fibiter 1 0 n))
|
||||
(def (fibiter a b count) (if (eq count 0)
|
||||
b
|
||||
(fibiter (+ a b) a (- count 1))))
|
||||
(fib 7)
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual(13));
|
||||
|
||||
// example: counting change
|
||||
it('sicp 52.2', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (countchange amount) (cc amount 5))
|
||||
(def (cc amount kindsofcoins)
|
||||
(match
|
||||
((eq amount 0) 1)
|
||||
((or (lt amount 0) (eq kindsofcoins 0)) 0)
|
||||
(else (+
|
||||
(cc amount (- kindsofcoins 1))
|
||||
(cc (- amount (firstdenomination kindsofcoins)) kindsofcoins)))))
|
||||
|
||||
(def (firstdenomination kindsofcoins)
|
||||
(match
|
||||
((eq kindsofcoins 1) 1)
|
||||
((eq kindsofcoins 2) 5)
|
||||
((eq kindsofcoins 3) 10)
|
||||
((eq kindsofcoins 4) 25)
|
||||
((eq kindsofcoins 5) 50)))
|
||||
|
||||
(countchange 100)
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual(292));
|
||||
|
||||
// todo: pascals triangle
|
||||
it('sicp 57.1', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (cube x) (* x x x))
|
||||
(def (p x) (sub (* 3 x) (* 4 (cube x))))
|
||||
(def (sine angle)
|
||||
(if (not (gt (abs angle) 0.1)) angle
|
||||
(p (sine (/ angle 3.0)))))
|
||||
|
||||
(sine 12.15)
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual(-0.39980345741334));
|
||||
|
||||
// exponentiation recursive
|
||||
it('sicp 57.2', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (expt b n) (if (eq n 0) 1 (* b (expt b (- n 1)))))
|
||||
(expt 2 4)
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual(16));
|
||||
|
||||
// exponentiation iterative
|
||||
it('sicp 58.1b', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (expt b n) (exptiter b n 1))
|
||||
(def (exptiter b counter product) (if (eq counter 0)
|
||||
product
|
||||
(exptiter b (- counter 1) (* b product))))
|
||||
(expt 2 5)
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual(32));
|
||||
|
||||
// exponentiation fast
|
||||
it('sicp 58.2', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (fastexpt b n) (match ((eq n 0) 1)
|
||||
((iseven n) (square (fastexpt b (/ n 2)))) (else (* b (fastexpt b (- n 1))))))
|
||||
(def (iseven n)
|
||||
(eq (mod n 2) 0))
|
||||
(fastexpt 2 5)
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual(32));
|
||||
|
||||
// * = repeated addition
|
||||
it('sicp 60.1', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`(def (mult a b) (if (eq b 0)
|
||||
0
|
||||
(+ a (* a (- b 1)))))
|
||||
(mult 3 15)
|
||||
`,
|
||||
),
|
||||
).toEqual(45));
|
||||
|
||||
// gcd / euclid
|
||||
it('sicp 63.1', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`(def (gcd a b) (if (eq b 0)
|
||||
a
|
||||
(gcd b (mod a b))))
|
||||
(gcd 20 6)
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual(2));
|
||||
|
||||
// 65 smallest divisor
|
||||
// 67 fermat test
|
||||
// ....
|
||||
|
||||
// higher order procedures
|
||||
|
||||
it('sicp 77.1', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (sum term a next b)
|
||||
(if (gt a b) 0 (+ (term a)
|
||||
(sum term (next a) next b))))
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual(0));
|
||||
|
||||
it('sicp 78.1', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (inc n) (+ n 1))
|
||||
(def (cube a) (* a a a))
|
||||
(def (sumcubes a b)
|
||||
(sum cube a inc b))
|
||||
(sumcubes 1 10)
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual(3025));
|
||||
|
||||
it('sicp 78.2', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (identity x) x)
|
||||
(def (sumintegers a b)
|
||||
(sum identity a inc b))
|
||||
(sumintegers 1 10)
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual(55));
|
||||
|
||||
// pisum
|
||||
it('sicp 79.1', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (pisum a b)
|
||||
(def (piterm x)
|
||||
(/ 1.0 (* x (+ x 2))))
|
||||
(def (pinext x) (+ x 4))
|
||||
(sum piterm a pinext b))
|
||||
(* 8 (pisum 1 1000))
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual(3.139592655589783));
|
||||
|
||||
// integral
|
||||
it('sicp 79.2', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (integral f a b dx)
|
||||
(def (adddx x) (+ x dx))
|
||||
(* (sum f (+ a (/ dx 2.0)) adddx b) dx))
|
||||
(integral cube 0 1 0.01)
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual(0.24998750000000042));
|
||||
// maximum callstack...
|
||||
//it('sicp 79.3', () => expect(evaluate(`(integral cube 0 1 0.001)`, scope)).toEqual(0.249999875000001));
|
||||
|
||||
//lambdas
|
||||
it('sicp 83.1', () => expect(evaluate(`((fn (x) (+ x 4)) 5)`)).toEqual(9));
|
||||
|
||||
it('sicp 83.2', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (pisum a b)
|
||||
(sum (fn (x) (/ 1.0 (* x (+ x 2))))
|
||||
a
|
||||
(fn (x) (+ x 4))
|
||||
b))
|
||||
(* 8 (pisum 1 1000))
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual(3.139592655589783));
|
||||
it('sicp 83.3', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (integral f a b dx)
|
||||
(* (sum f
|
||||
(+ a (/ dx 2.0))
|
||||
(fn (x) (+ x dx))
|
||||
b)
|
||||
dx))
|
||||
(integral cube 0 1 0.01)
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual(0.24998750000000042));
|
||||
it('sicp 84.1', () => expect(evaluate(`((fn (x y z) (+ x y (square z))) 1 2 3)`, scope)).toEqual(12));
|
||||
|
||||
// let expressions
|
||||
it('sicp 87.1', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(+ (let ((x 3))
|
||||
(+ x (* x 10))) x)
|
||||
`,
|
||||
{ x: 5 },
|
||||
),
|
||||
).toEqual(38));
|
||||
it('sicp 87.2', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(let ((x 3)
|
||||
(y (+ x 2)))
|
||||
(* x y))
|
||||
`,
|
||||
{ x: 2 },
|
||||
),
|
||||
).toEqual(12));
|
||||
it('sicp 88.1', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (f g) (g 2))
|
||||
(f square)
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual(4));
|
||||
it('sicp 88.2', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (f g) (g 2))
|
||||
(f (fn (z) (* z (+ z 1))))
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual(6));
|
||||
|
||||
// Finding roots of equations by the half-interval method
|
||||
it('sicp 89.1', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (search f negpoint pospoint)
|
||||
(let ((midpoint (average negpoint pospoint)))
|
||||
(if (closeenough negpoint pospoint)
|
||||
midpoint
|
||||
(let ((testvalue (f midpoint)))
|
||||
(match ((positive testvalue)
|
||||
(search f negpoint midpoint))
|
||||
((negative testvalue)
|
||||
(search f midpoint pospoint))
|
||||
(else midpoint))))))
|
||||
|
||||
(def (closeenough x y) (lt (abs (- x y)) 0.001))
|
||||
|
||||
(def (negative x) (lt x 0))
|
||||
(def (positive x) (gt x 0))
|
||||
|
||||
(def (halfintervalmethod f a b) (let ((avalue (f a))
|
||||
(bvalue (f b)))
|
||||
(match ((and (negative avalue) (positive bvalue))
|
||||
(search f a b))
|
||||
((and (negative bvalue) (positive avalue))
|
||||
(search f b a)) (else
|
||||
(error "Values are not of opposite sign" a b)))))
|
||||
|
||||
(halfintervalmethod sin 2.0 4.0)
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual(3.14111328125));
|
||||
|
||||
it('sicp 89.1', () =>
|
||||
expect(evaluate(`(halfintervalmethod (fn (x) (- (* x x x) (* 2 x) 3)) 1.0 2.0)`, scope)).toEqual(1.89306640625));
|
||||
|
||||
// Finding fixed points of functions
|
||||
it('sicp 92.1', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def tolerance 0.00001)
|
||||
(def (fixedpoint f first-guess)
|
||||
(def (closeenough v1 v2) (lt (abs (- v1 v2)) tolerance))
|
||||
(def (try guess)
|
||||
(let ((next (f guess)))
|
||||
(if (closeenough guess next) next (try next))))
|
||||
(try first-guess))
|
||||
|
||||
(fixedpoint cos 1.0)
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual(0.7390822985224023));
|
||||
it('sicp 93.1', () =>
|
||||
expect(evaluate(`(fixedpoint (fn (y) (+ (sin y) (cos y))) 1.0)`, scope)).toEqual(1.2587315962971173));
|
||||
// Maximum call stack size exceeded (expected)
|
||||
/* it('sicp 93.2', () =>
|
||||
expect(evaluate(`(def (sqrt x) (fixedpoint (fn (y) (/ x y)) 1.0)) (sqrt 4)`, scope)).toEqual(0)); */
|
||||
it('sicp 93.3', () =>
|
||||
expect(evaluate(`(def (sqrt x) (fixedpoint (fn (y) (average y (/ x y))) 1.0)) (sqrt 7)`, scope)).toEqual(
|
||||
2.6457513110645907,
|
||||
));
|
||||
// Procedures as Returned Values
|
||||
it('sicp 97.1', () =>
|
||||
expect(evaluate(`(def (averagedamp f) (fn (x) (average x (f x)))) ((averagedamp square) 10)`, scope)).toEqual(55));
|
||||
it('sicp 98.1', () =>
|
||||
expect(evaluate(`(def (sqrt x) (fixedpoint (averagedamp (fn (y) (/ x y))) 1.0)) (sqrt 7)`, scope)).toEqual(
|
||||
2.6457513110645907,
|
||||
));
|
||||
it('sicp 98.2', () =>
|
||||
expect(
|
||||
evaluate(`(def (cuberoot x) (fixedpoint (averagedamp (fn (y) (/ x (square y)))) 1.0)) (cuberoot 7)`, scope),
|
||||
).toEqual(1.912934258514886));
|
||||
it('sicp 99.1', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (deriv g) (fn (x) (/ (- (g (+ x dx)) (g x)) dx)))
|
||||
(def dx 0.00001)
|
||||
(def (cube x) (* x x x))
|
||||
((deriv cube) 5)
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual(75.00014999664018));
|
||||
// With the aid of deriv, we can express Newton’s method as a fixed-point process:
|
||||
it('sicp 100.1', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (newtontransform g)
|
||||
(fn (x) (- x (/ (g x) ((deriv g) x)))))
|
||||
(def (newtonsmethod g guess) (fixedpoint (newtontransform g) guess))
|
||||
(def (sqrt x) (newtonsmethod (fn (y) (- (square y) x)) 1.0))
|
||||
(sqrt 7)
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual(2.6457513110645907));
|
||||
// whatever this is
|
||||
it('sicp 101.1', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (fixedpointoftransform g transform guess) (fixedpoint (transform g) guess))
|
||||
(def (sqrt x) (fixedpointoftransform
|
||||
(fn (y) (/ x y)) averagedamp 1.0))
|
||||
(sqrt 7)
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual(2.6457513110645907));
|
||||
it('sicp 101.2', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (sqrt x) (fixedpointoftransform
|
||||
(fn (y) (- (square y) x)) newtontransform 1.0))
|
||||
(sqrt 7)
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual(2.6457513110645907));
|
||||
|
||||
// data abstraction
|
||||
|
||||
// rational arithmetic
|
||||
it('sicp 114.1', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (addrat x y)
|
||||
(makerat (+ (*
|
||||
(numer x) (denom y))
|
||||
(* (numer y) (denom x)))
|
||||
(* (denom x) (denom y))))
|
||||
(def (subrat x y)
|
||||
(makerat (- (* (numer x) (denom y))
|
||||
(* (numer y) (denom x)))
|
||||
(* (denom x) (denom y))))
|
||||
(def (mulrat x y)
|
||||
(makerat (* (numer x) (numer y))
|
||||
(* (denom x) (denom y))))
|
||||
(def (divrat x y)
|
||||
(makerat (* (numer x) (denom y))
|
||||
(* (denom x) (numer y))))
|
||||
(def (equalrat x y)
|
||||
(eq (* (numer x) (denom y))
|
||||
(* (numer y) (denom x))))
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual(0));
|
||||
|
||||
// markerat number denom
|
||||
it('sicp 117.1', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (makerat n d) (cons n d))
|
||||
(def (numer x) (car x))
|
||||
(def (denom x) (cdr x))
|
||||
(def (printrat x) (concat (numer x) ':' (denom x)))
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual(0));
|
||||
|
||||
it('sicp 117.1', () => expect(evaluate(`(def onehalf (makerat 1 2)) (printrat onehalf)`, scope)).toEqual('1:2'));
|
||||
it('sicp 117.2', () =>
|
||||
expect(evaluate(`(def onethird (makerat 1 3)) (printrat (addrat onehalf onethird))`, scope)).toEqual('5:6'));
|
||||
it('sicp 117.3', () => expect(evaluate(`(printrat (mulrat onehalf onethird))`, scope)).toEqual('1:6'));
|
||||
it('sicp 117.4', () => expect(evaluate(`(printrat (addrat onethird onethird))`, scope)).toEqual('6:9'));
|
||||
it('sicp 118.1', () =>
|
||||
expect(evaluate(`(def (makerat n d) (let ((g (gcd n d))) (cons (/ n g) (/ d g))))`, scope)).toEqual(0));
|
||||
it('sicp 118.1', () => expect(evaluate(`(printrat (addrat onethird onethird))`, scope)).toEqual('2:3'));
|
||||
|
||||
let lscope = {};
|
||||
// pairs with lambda
|
||||
it('sicp 124.1', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (cons x y)
|
||||
(def (dispatch m)
|
||||
(match
|
||||
((eq m 0) x)
|
||||
((eq m 1) y)
|
||||
(else (error "argument not 0 or 1: CONS" m)))
|
||||
) dispatch)
|
||||
(def (car z) (z 0))
|
||||
(def (cdr z) (z 1))
|
||||
`,
|
||||
lscope,
|
||||
),
|
||||
).toEqual(0));
|
||||
it('sicp 124.1', () => expect(evaluate(`(car (cons first second))`, lscope)).toEqual('first'));
|
||||
it('sicp 124.2', () => expect(evaluate(`(cdr (cons first second))`, lscope)).toEqual('second'));
|
||||
// lists
|
||||
it('sicp 135.1', () => expect(evaluate(`(list 1 2 3 4)`)).toEqual([1, 2, 3, 4]));
|
||||
it('sicp 137.1', () => expect(evaluate(`(car (list 1 2 3 4))`)).toEqual(1));
|
||||
it('sicp 137.2', () => expect(evaluate(`(cdr (list 1 2 3 4))`)).toEqual([2, 3, 4]));
|
||||
it('sicp 137.3', () => expect(evaluate(`(car (cdr (list 1 2 3 4)))`)).toEqual(2));
|
||||
it('sicp 137.4', () => expect(evaluate(`(cons 10 (list 1 2 3 4))`)).toEqual([10, 1, 2, 3, 4]));
|
||||
// listref
|
||||
it('sicp 138.1', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (listref items n) (if (eq n 0) (car items)
|
||||
(listref (cdr items) (- n 1))))
|
||||
(def squares (list 1 4 9 16 25))
|
||||
(listref squares 3)`,
|
||||
scope,
|
||||
),
|
||||
).toEqual(16));
|
||||
// length recursive
|
||||
it('sicp 138.2', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (length items)
|
||||
(if (isnull items) 0
|
||||
(+ 1 (length (cdr items)))))
|
||||
(def odds (list 1 3 5 7))
|
||||
(length odds)`,
|
||||
),
|
||||
).toEqual(4));
|
||||
// length iterative
|
||||
it('sicp 139.1', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (length items)
|
||||
(def (lengthiter a count)
|
||||
(if (isnull a) count
|
||||
(lengthiter (cdr a) (+ 1 count))))
|
||||
(lengthiter items 0))
|
||||
(def odds (list 1 3 5 7))
|
||||
(length odds)
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual(4));
|
||||
// append
|
||||
it('sicp 139.1', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (append list1 list2)
|
||||
(if (isnull list1)
|
||||
list2
|
||||
(cons (car list1) (append (cdr list1) list2))))
|
||||
(append squares odds)
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual([1, 4, 9, 16, 25, 1, 3, 5, 7]));
|
||||
// (define (f x y . z) ⟨body⟩) <- tbd: variable argument count
|
||||
// Mapping over lists
|
||||
|
||||
it('sicp 143.1', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (scalelist items factor) (if (isnull items) nil
|
||||
(cons (* (car items) factor)
|
||||
(scalelist (cdr items) factor))))
|
||||
(scalelist (list 1 2 3 4 5) 10)
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual([10, 20, 30, 40, 50]));
|
||||
|
||||
it('sicp 143.1', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (map proc items) (if (isnull items) nil
|
||||
(cons (proc (car items))
|
||||
(map proc (cdr items)))))
|
||||
(map abs (list -10 2.5 -11.6 17))
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual([10, 2.5, 11.6, 17]));
|
||||
it('sicp 143.1', () => expect(evaluate(`(map (fn (x) (* x x)) (list 1 2 3 4))`, scope)).toEqual([1, 4, 9, 16]));
|
||||
it('sicp 143.1', () =>
|
||||
expect(
|
||||
evaluate(
|
||||
`
|
||||
(def (scalelist items factor) (map (fn (x) (* x factor)) items))
|
||||
(scalelist (list 1 2 3 4 5) 10)
|
||||
`,
|
||||
scope,
|
||||
),
|
||||
).toEqual([10, 20, 30, 40, 50]));
|
||||
});
|
||||
@@ -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, 'mondo.mjs'),
|
||||
formats: ['es'],
|
||||
fileName: (ext) => ({ es: 'mondo.mjs' })[ext],
|
||||
},
|
||||
rollupOptions: {
|
||||
// external: [...Object.keys(dependencies)],
|
||||
},
|
||||
target: 'esnext',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
# @strudel/mondough
|
||||
|
||||
connects mondo to strudel.
|
||||
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
strudelScope,
|
||||
reify,
|
||||
fast,
|
||||
slow,
|
||||
seq,
|
||||
stepcat,
|
||||
replicate,
|
||||
expand,
|
||||
pace,
|
||||
chooseIn,
|
||||
degradeBy,
|
||||
silence,
|
||||
bjork,
|
||||
} from '@strudel/core';
|
||||
import { registerLanguage } from '@strudel/transpiler';
|
||||
import { MondoRunner } from 'mondolang';
|
||||
|
||||
const tail = (friend, pat) => pat.fmap((a) => (b) => (Array.isArray(a) ? [...a, b] : [a, b])).appLeft(friend);
|
||||
|
||||
const arrayRange = (start, stop, step = 1) =>
|
||||
Array.from({ length: Math.abs(stop - start) / step + 1 }, (_, index) =>
|
||||
start < stop ? start + index * step : start - index * step,
|
||||
);
|
||||
const range = (max, min) => min.squeezeBind((a) => max.bind((b) => seq(...arrayRange(a, b))));
|
||||
|
||||
let nope = (...args) => args[args.length - 1];
|
||||
|
||||
let lib = {};
|
||||
lib['nope'] = nope;
|
||||
lib['-'] = (a, b) => b.early(a);
|
||||
lib['+'] = (a, b) => b.late(a);
|
||||
lib['_'] = silence;
|
||||
lib['~'] = silence;
|
||||
lib.curly = stepcat;
|
||||
lib.square = (...args) => stepcat(...args).setSteps(1);
|
||||
lib.angle = (...args) => stepcat(...args).pace(1);
|
||||
lib['*'] = fast;
|
||||
lib['/'] = slow;
|
||||
lib['!'] = replicate;
|
||||
lib['@'] = expand;
|
||||
lib['%'] = pace;
|
||||
lib['?'] = degradeBy; // todo: default 0.5 not working..
|
||||
lib['&'] = bjork;
|
||||
lib[':'] = tail;
|
||||
lib['..'] = range;
|
||||
lib['def'] = () => silence;
|
||||
lib['or'] = (...children) => chooseIn(...children); // always has structure but is cyclewise.. e.g. "s oh*8.dec[.04 | .5]"
|
||||
//lib['or'] = (...children) => chooseOut(...children); // "s oh*8.dec[.04 | .5]" is better but "dec[.04 | .5].s oh*8" has no struct
|
||||
|
||||
function evaluator(node, scope) {
|
||||
const { type } = node;
|
||||
// node is list
|
||||
if (type === 'list') {
|
||||
const { children } = node;
|
||||
const [name, ...args] = children;
|
||||
// some functions wont be reified to make sure they work (e.g. see extend below)
|
||||
if (typeof name === 'function') {
|
||||
return name(...args);
|
||||
}
|
||||
if (name.value === 'def') {
|
||||
return silence;
|
||||
}
|
||||
// name is expected to be a pattern of functions!
|
||||
const first = name.firstCycle(true)[0];
|
||||
const type = typeof first?.value;
|
||||
if (type !== 'function') {
|
||||
throw new Error(`[mondough] expected function, got "${first?.value}"`);
|
||||
}
|
||||
return name
|
||||
.fmap((fn) => {
|
||||
if (typeof fn !== 'function') {
|
||||
throw new Error(`[mondough] "${fn}" is not a function b`);
|
||||
}
|
||||
return fn(...args);
|
||||
})
|
||||
.innerJoin();
|
||||
}
|
||||
// node is leaf
|
||||
let { value } = node;
|
||||
if (type === 'plain' && scope[value]) {
|
||||
return reify(scope[value]); // -> local scope has no location
|
||||
}
|
||||
const variable = lib[value] ?? strudelScope[value];
|
||||
// problem: collisions when we want a string that happens to also be a variable name
|
||||
// example: "s sine" -> sine is also a variable
|
||||
let pat;
|
||||
if (type === 'plain' && typeof variable !== 'undefined') {
|
||||
// some function names are not patternable, so we skip reification here
|
||||
if (['!', 'extend', '@', 'expand', 'square', 'angle', 'all', 'setcpm', 'setcps'].includes(value)) {
|
||||
return variable;
|
||||
}
|
||||
pat = reify(variable);
|
||||
} else {
|
||||
pat = reify(value);
|
||||
}
|
||||
if (node.loc) {
|
||||
pat = pat.withLoc(node.loc[0], node.loc[1]);
|
||||
}
|
||||
return pat;
|
||||
}
|
||||
|
||||
let runner = new MondoRunner({ evaluator });
|
||||
|
||||
export function mondo(code, offset = 0) {
|
||||
if (Array.isArray(code)) {
|
||||
code = code.join('');
|
||||
}
|
||||
const pat = runner.run(code, undefined, offset);
|
||||
return pat.markcss('color: var(--caret,--foreground);text-decoration:underline');
|
||||
}
|
||||
|
||||
export let getLocations = (code, offset) => runner.parser.get_locations(code, offset);
|
||||
|
||||
export const mondi = (str, offset) => {
|
||||
const code = `[${str}]`;
|
||||
return mondo(code, offset);
|
||||
};
|
||||
|
||||
// tell transpiler how to get locations for mondo`` calls
|
||||
registerLanguage('mondo', {
|
||||
getLocations,
|
||||
});
|
||||
|
||||
// this is like mondo, but with a zero offset
|
||||
export const mondolang = (code) => mondo(code, 0);
|
||||
registerLanguage('mondolang', {
|
||||
getLocations: (code) => getLocations(code, 0),
|
||||
});
|
||||
// uncomment the following to use mondo as mini notation language
|
||||
/* registerLanguage('minilang', {
|
||||
name: 'mondi',
|
||||
getLocations,
|
||||
}); */
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@strudel/mondo",
|
||||
"version": "1.1.5",
|
||||
"description": "mondo notation for strudel",
|
||||
"main": "mondough.mjs",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"main": "dist/mondough.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"bench": "vitest bench",
|
||||
"build:parser": "peggy -o krill-parser.js --format es ./krill.pegjs",
|
||||
"build": "vite build",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/tidalcycles/strudel.git"
|
||||
},
|
||||
"keywords": [
|
||||
"tidalcycles",
|
||||
"strudel",
|
||||
"pattern",
|
||||
"livecoding",
|
||||
"algorave"
|
||||
],
|
||||
"author": "Felix Roos <flix91@gmail.com>",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"bugs": {
|
||||
"url": "https://github.com/tidalcycles/strudel/issues"
|
||||
},
|
||||
"homepage": "https://github.com/tidalcycles/strudel#readme",
|
||||
"dependencies": {
|
||||
"@strudel/core": "workspace:*",
|
||||
"@strudel/transpiler": "workspace:*",
|
||||
"mondolang": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"mondo": "*",
|
||||
"vite": "^6.0.11",
|
||||
"vitest": "^3.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -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, 'mondough.mjs'),
|
||||
formats: ['es'],
|
||||
fileName: (ext) => ({ es: 'mondough.mjs' })[ext],
|
||||
},
|
||||
rollupOptions: {
|
||||
external: [...Object.keys(dependencies)],
|
||||
},
|
||||
target: 'esnext',
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strudel/motion",
|
||||
"version": "1.2.2",
|
||||
"version": "1.2.5",
|
||||
"description": "DeviceMotion API for strudel",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
@@ -33,5 +33,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ Pattern.prototype.mqtt = function (
|
||||
cx.connect(props);
|
||||
}
|
||||
return this.withHap((hap) => {
|
||||
const onTrigger = (t_deprecate, hap, currentTime, cps, targetTime) => {
|
||||
const onTrigger = (hap, currentTime, cps, targetTime) => {
|
||||
let msg_topic = topic;
|
||||
if (!cx || !cx.isConnected()) {
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strudel/mqtt",
|
||||
"version": "1.2.2",
|
||||
"version": "1.2.5",
|
||||
"description": "MQTT API for strudel",
|
||||
"main": "mqtt.mjs",
|
||||
"type": "module",
|
||||
@@ -34,5 +34,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
+27
-17
@@ -4,21 +4,13 @@ OSC output for strudel patterns! Currently only tested with super collider / sup
|
||||
|
||||
## Usage
|
||||
|
||||
OSC will only work if you run the REPL locally + the OSC server besides it:
|
||||
Assuming you have [node.js](https://nodejs.org/) installed, you can run the osc bridge server via:
|
||||
|
||||
From the project root:
|
||||
|
||||
```js
|
||||
npm run repl
|
||||
```sh
|
||||
npx @strudel/osc
|
||||
```
|
||||
|
||||
and in a seperate shell:
|
||||
|
||||
```js
|
||||
npm run osc
|
||||
```
|
||||
|
||||
This should give you
|
||||
You should see something like:
|
||||
|
||||
```log
|
||||
osc client running on port 57120
|
||||
@@ -26,14 +18,32 @@ osc server running on port 57121
|
||||
websocket server running on port 8080
|
||||
```
|
||||
|
||||
Now open Supercollider (with the super dirt startup file)
|
||||
### --port
|
||||
|
||||
Now open the REPL and type:
|
||||
By default it will use port 57120 for the osc client, which is what [superdirt](https://github.com/musikinformatik/SuperDirt) uses. You can change it via the `--port` option:
|
||||
|
||||
```js
|
||||
s("<bd sd> hh").osc()
|
||||
```sh
|
||||
npx @strudel/osc --port 7771 # classic dirt
|
||||
```
|
||||
|
||||
or just [click here](https://strudel.cc/#cygiPGJkIHNkPiBoaCIpLm9zYygp)...
|
||||
### --debug
|
||||
|
||||
To log all incoming osc messages, add the `--debug` flag:
|
||||
|
||||
```sh
|
||||
npx @strudel/osc --debug
|
||||
```
|
||||
|
||||
## Usage in Strudel
|
||||
|
||||
To test it in strudel, you have can use `all(osc)` to send all events through osc:
|
||||
|
||||
```js
|
||||
$: s("bd*4")
|
||||
|
||||
all(osc)
|
||||
```
|
||||
|
||||
[open in repl](https://strudel.cc/#JDogcygiYmQqNCIpCgphbGwob3NjKQ%3D%3D)
|
||||
|
||||
You can read more about [how to use Superdirt with Strudel the Tutorial](https://strudel.cc/learn/input-output/#superdirt-api)
|
||||
|
||||
+20
-21
@@ -4,28 +4,25 @@ Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import OSC from 'osc-js';
|
||||
|
||||
import { logger, parseNumeral, Pattern, isNote, noteToMidi, ClockCollator } from '@strudel/core';
|
||||
import { logger, parseNumeral, register, isNote, noteToMidi, ClockCollator } from '@strudel/core';
|
||||
|
||||
let connection; // Promise<OSC>
|
||||
function connect() {
|
||||
if (!connection) {
|
||||
// make sure this runs only once
|
||||
connection = new Promise((resolve, reject) => {
|
||||
const osc = new OSC();
|
||||
osc.open();
|
||||
osc.on('open', () => {
|
||||
const url = osc.options?.plugin?.socket?.url;
|
||||
logger(`[osc] connected${url ? ` to ${url}` : ''}`);
|
||||
resolve(osc);
|
||||
const ws = new WebSocket('ws://localhost:8080');
|
||||
ws.addEventListener('open', (event) => {
|
||||
logger(`[osc] websocket connected`);
|
||||
resolve(ws);
|
||||
});
|
||||
osc.on('close', () => {
|
||||
ws.addEventListener('close', (event) => {
|
||||
logger(`[osc] websocket closed`);
|
||||
connection = undefined; // allows new connection afterwards
|
||||
console.log('[osc] disconnected');
|
||||
reject('OSC connection closed');
|
||||
});
|
||||
osc.on('error', (err) => reject(err));
|
||||
ws.addEventListener('error', (err) => reject(err));
|
||||
}).catch((err) => {
|
||||
connection = undefined;
|
||||
throw new Error('Could not connect to OSC server. Is it running?');
|
||||
@@ -60,16 +57,20 @@ export function parseControlsFromHap(hap, cps) {
|
||||
|
||||
const collator = new ClockCollator({});
|
||||
|
||||
export async function oscTrigger(t_deprecate, hap, currentTime, cps = 1, targetTime) {
|
||||
const osc = await connect();
|
||||
export async function oscTrigger(hap, currentTime, cps = 1, targetTime) {
|
||||
const ws = await connect();
|
||||
const controls = parseControlsFromHap(hap, cps);
|
||||
const keyvals = Object.entries(controls).flat();
|
||||
const ts = collator.calculateTimestamp(currentTime, targetTime) * 1000;
|
||||
const msg = { address: '/dirt/play', args: keyvals, timestamp: ts };
|
||||
|
||||
const ts = Math.round(collator.calculateTimestamp(currentTime, targetTime) * 1000);
|
||||
const message = new OSC.Message('/dirt/play', ...keyvals);
|
||||
const bundle = new OSC.Bundle([message], ts);
|
||||
bundle.timestamp(ts); // workaround for https://github.com/adzialocha/osc-js/issues/60
|
||||
osc.send(bundle);
|
||||
if ('oschost' in hap.value) {
|
||||
msg['host'] = hap.value['oschost'];
|
||||
}
|
||||
if ('oscport' in hap.value) {
|
||||
msg['port'] = hap.value['oscport'];
|
||||
}
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,6 +82,4 @@ export async function oscTrigger(t_deprecate, hap, currentTime, cps = 1, targetT
|
||||
* @memberof Pattern
|
||||
* @returns Pattern
|
||||
*/
|
||||
Pattern.prototype.osc = function () {
|
||||
return this.onTrigger(oscTrigger);
|
||||
};
|
||||
export const osc = register('osc', (pat) => pat.onTrigger(oscTrigger));
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "@strudel/osc",
|
||||
"version": "1.2.2",
|
||||
"version": "1.3.0",
|
||||
"description": "OSC messaging for strudel",
|
||||
"main": "osc.mjs",
|
||||
"bin": "./server.js",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"main": "dist/index.mjs"
|
||||
@@ -37,10 +38,14 @@
|
||||
"homepage": "https://codeberg.org/uzu/strudel#readme",
|
||||
"dependencies": {
|
||||
"@strudel/core": "workspace:*",
|
||||
"osc-js": "^2.4.1"
|
||||
"osc": "^2.4.5",
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"pkg": "^5.8.1",
|
||||
"vite": "^6.0.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
Regular → Executable
+54
-24
@@ -1,34 +1,64 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/*
|
||||
server.js - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/osc/server.js>
|
||||
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 OSC from 'osc-js';
|
||||
// import OSC from 'osc-js';
|
||||
|
||||
const config = {
|
||||
receiver: 'ws', // @param {string} Where messages sent via 'send' method will be delivered to, 'ws' for Websocket clients, 'udp' for udp client
|
||||
udpServer: {
|
||||
host: 'localhost', // @param {string} Hostname of udp server to bind to
|
||||
port: 57121, // @param {number} Port of udp client for messaging
|
||||
// enabling the following line will receive tidal messages:
|
||||
// port: 57120, // @param {number} Port of udp client for messaging
|
||||
exclusive: false, // @param {boolean} Exclusive flag
|
||||
},
|
||||
udpClient: {
|
||||
host: 'localhost', // @param {string} Hostname of udp client for messaging
|
||||
port: 57120, // @param {number} Port of udp client for messaging
|
||||
},
|
||||
wsServer: {
|
||||
host: 'localhost', // @param {string} Hostname of WebSocket server
|
||||
port: 8080, // @param {number} Port of WebSocket server
|
||||
},
|
||||
};
|
||||
import { WebSocketServer } from 'ws';
|
||||
import osc from 'osc';
|
||||
|
||||
const osc = new OSC({ plugin: new OSC.BridgePlugin(config) });
|
||||
const WS_PORT = 8080; // WebSocket server port
|
||||
const OSC_REMOTE_IP = '127.0.0.1';
|
||||
const OSC_REMOTE_PORT = 57120;
|
||||
|
||||
osc.open(); // start a WebSocket server on port 8080
|
||||
const udpPort = new osc.UDPPort({
|
||||
localAddress: '0.0.0.0',
|
||||
localPort: 0,
|
||||
remoteAddress: OSC_REMOTE_IP,
|
||||
remotePort: OSC_REMOTE_PORT,
|
||||
});
|
||||
|
||||
console.log('osc client running on port', config.udpClient.port);
|
||||
console.log('osc server running on port', config.udpServer.port);
|
||||
console.log('websocket server running on port', config.wsServer.port);
|
||||
udpPort.open();
|
||||
console.log(`[Sending OSC] ${OSC_REMOTE_IP}:${OSC_REMOTE_PORT}`);
|
||||
|
||||
udpPort.on('error', (e) => {
|
||||
console.log('Error: ', e);
|
||||
});
|
||||
|
||||
const wss = new WebSocketServer({ port: WS_PORT });
|
||||
console.log(`[Listening WS] ws://localhost:${WS_PORT}`);
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
console.log('New WebSocket connection');
|
||||
|
||||
ws.on('message', (message) => {
|
||||
let osc_host = '127.0.0.1';
|
||||
let osc_port = 57120;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(message);
|
||||
if ('host' in data) {
|
||||
osc_host = data['host'];
|
||||
}
|
||||
if ('port' in data) {
|
||||
osc_port = data['port'];
|
||||
}
|
||||
let msg = { address: data['address'], args: data['args'] };
|
||||
if ('timestamp' in data) {
|
||||
msg = { timeTag: osc.timeTag(0, data['timestamp']), packets: [msg] };
|
||||
}
|
||||
|
||||
udpPort.send(msg, osc_host, osc_port);
|
||||
} catch (err) {
|
||||
console.error('Error parsing message:', err);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('WebSocket connection closed');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { oscTriggerTauri } from '../desktopbridge/oscbridge.mjs';
|
||||
import { isTauri } from '../desktopbridge/utils.mjs';
|
||||
/* import { oscTriggerTauri } from '../desktopbridge/oscbridge.mjs';
|
||||
import { isTauri } from '../desktopbridge/utils.mjs'; */
|
||||
import { oscTrigger } from './osc.mjs';
|
||||
|
||||
const trigger = isTauri() ? oscTriggerTauri : oscTrigger;
|
||||
const trigger = /* isTauri() ? oscTriggerTauri : */ oscTrigger;
|
||||
|
||||
export const superdirtOutput = (hap, deadline, hapDuration, cps, targetTime) => {
|
||||
const currentTime = performance.now() / 1000;
|
||||
return trigger(null, hap, currentTime, cps, targetTime);
|
||||
return trigger(hap, currentTime, cps, targetTime);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strudel/reference",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"description": "Headless reference of all strudel functions",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
@@ -33,5 +33,8 @@
|
||||
"homepage": "https://codeberg.org/uzu/strudel#readme",
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strudel/repl",
|
||||
"version": "1.2.3",
|
||||
"version": "1.2.7",
|
||||
"description": "Strudel REPL as a Web Component",
|
||||
"module": "index.mjs",
|
||||
"publishConfig": {
|
||||
@@ -48,5 +48,8 @@
|
||||
"@rollup/plugin-replace": "^6.0.2",
|
||||
"vite": "^6.0.11",
|
||||
"vite-plugin-bundle-audioworklet": "workspace:*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,10 +20,13 @@ export async function prebake() {
|
||||
// import('@strudel/osc'),
|
||||
);
|
||||
// load samples
|
||||
const ds = 'https://raw.githubusercontent.com/felixroos/dough-samples/main/';
|
||||
const ds = 'https://raw.githubusercontent.com/felixroos/dough-samples/main';
|
||||
|
||||
// TODO: move this onto the strudel repo
|
||||
const ts = 'https://raw.githubusercontent.com/todepond/samples/main/';
|
||||
const ts = 'https://raw.githubusercontent.com/todepond/samples/main';
|
||||
|
||||
const tc = 'https://raw.githubusercontent.com/tidalcycles/uzu-drumkit/main';
|
||||
|
||||
await Promise.all([
|
||||
modulesLoading,
|
||||
registerSynthSounds(),
|
||||
@@ -36,9 +39,9 @@ export async function prebake() {
|
||||
samples(`${ds}/tidal-drum-machines.json`),
|
||||
samples(`${ds}/piano.json`),
|
||||
samples(`${ds}/Dirt-Samples.json`),
|
||||
samples(`${ds}/EmuSP12.json`),
|
||||
samples(`${ds}/vcsl.json`),
|
||||
samples(`${ds}/mridangam.json`),
|
||||
samples(`${tc}/strudel.json`),
|
||||
]);
|
||||
|
||||
aliasBank(`${ts}/tidal-drum-machines-alias.json`);
|
||||
|
||||
@@ -20,3 +20,13 @@ samples('http://localhost:5432')
|
||||
LOG=1 npx @strudel/sampler # adds logging
|
||||
PORT=5555 npx @strudel/sampler # changes port
|
||||
```
|
||||
|
||||
## static json
|
||||
|
||||
when running with `--json`, you will simply get the json logged back:
|
||||
|
||||
```sh
|
||||
npx --yes @strudel/sampler --json > strudel.json
|
||||
```
|
||||
|
||||
this is useful if you want to create a sample pack from the current folder.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strudel/sampler",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.3",
|
||||
"description": "",
|
||||
"keywords": [
|
||||
"tidalcycles",
|
||||
@@ -15,5 +15,8 @@
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"cowsay": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import cowsay from 'cowsay';
|
||||
import { createReadStream, existsSync } from 'fs';
|
||||
import { createReadStream, existsSync, writeFileSync } from 'fs';
|
||||
import { readdir } from 'fs/promises';
|
||||
import http from 'http';
|
||||
import { join, sep } from 'path';
|
||||
import { join, resolve, sep } from 'path';
|
||||
import readline from 'readline';
|
||||
import os from 'os';
|
||||
|
||||
// eslint-disable-next-line
|
||||
const LOG = !!process.env.LOG || false;
|
||||
const PORT = process.env.PORT || 5432;
|
||||
const VALID_AUDIO_EXTENSIONS = ['wav', 'mp3', 'ogg'];
|
||||
|
||||
console.log(
|
||||
cowsay.say({
|
||||
text: 'welcome to @strudel/sampler',
|
||||
e: 'oO',
|
||||
T: 'U ',
|
||||
}),
|
||||
);
|
||||
const isAudioFile = (f) => {
|
||||
const ext = f.split('.').slice(-1)[0].toLowerCase();
|
||||
return VALID_AUDIO_EXTENSIONS.includes(ext);
|
||||
};
|
||||
|
||||
async function getFilesInDirectory(directory) {
|
||||
let files = [];
|
||||
@@ -29,42 +28,89 @@ async function getFilesInDirectory(directory) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const subFiles = (await getFilesInDirectory(fullPath)).filter((f) =>
|
||||
['wav', 'mp3', 'ogg'].includes(f.split('.').slice(-1)[0].toLowerCase()),
|
||||
);
|
||||
const subFiles = (await getFilesInDirectory(fullPath)).filter(isAudioFile);
|
||||
files = files.concat(subFiles);
|
||||
LOG && console.log(`${dirent.name} (${subFiles.length})`);
|
||||
} catch (err) {
|
||||
LOG && console.warn(`skipped due to error: ${fullPath}`);
|
||||
}
|
||||
} else {
|
||||
files.push(fullPath);
|
||||
isAudioFile(fullPath) && files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function getBanks(directory) {
|
||||
async function getBanks(directory, flat = false) {
|
||||
let files = await getFilesInDirectory(directory);
|
||||
let banks = {};
|
||||
directory = directory.split(sep).join('/');
|
||||
files = files.map((path) => {
|
||||
path = path.split(sep).join('/');
|
||||
const [bank] = path.split('/').slice(-2);
|
||||
const subDir = path.replace(directory, '');
|
||||
const subDirFlat = subDir.replaceAll('/', '_').slice(1); // remove initial underscore
|
||||
const subDirFlatStem = subDirFlat.replace(/\.[^.]+$/, ''); // remove extension
|
||||
let bank = flat ? subDirFlatStem : path.split('/').slice(-2)[0];
|
||||
banks[bank] = banks[bank] || [];
|
||||
const relativeUrl = path.replace(directory, '');
|
||||
banks[bank].push(relativeUrl);
|
||||
return relativeUrl;
|
||||
banks[bank].push(subDir);
|
||||
return subDir;
|
||||
});
|
||||
banks._base = `http://localhost:5432`;
|
||||
return { banks, files };
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
const directory = process.cwd();
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
function getArgValue(flag) {
|
||||
const i = args.indexOf(flag);
|
||||
if (i !== -1) {
|
||||
const nextIsFlag = args[i + 1]?.startsWith('--') ?? true;
|
||||
if (nextIsFlag) return true;
|
||||
return args[i + 1];
|
||||
}
|
||||
}
|
||||
|
||||
function getInput(query) {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
return new Promise((resolve) =>
|
||||
rl.question(query, (response) => {
|
||||
rl.close();
|
||||
resolve(response);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let directory = getArgValue('--dir') || process.cwd();
|
||||
directory = resolve(directory);
|
||||
if (args.includes('--json')) {
|
||||
const { banks } = await getBanks(directory, getArgValue('--flat'));
|
||||
const json = JSON.stringify(banks);
|
||||
const outFile = resolve(directory, 'strudel.json');
|
||||
if (existsSync(outFile)) {
|
||||
const answer = await getInput(`Warning: File already exists at ${outFile}. Overwrite? (y/N): `);
|
||||
if (answer.toLowerCase() !== 'y') {
|
||||
console.log('Aborted.');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
writeFileSync(outFile, json, 'utf8');
|
||||
console.log(`Wrote json to ${outFile}`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
cowsay.say({
|
||||
text: 'welcome to @strudel/sampler',
|
||||
e: 'oO',
|
||||
T: 'U ',
|
||||
}),
|
||||
);
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
const { banks, files } = await getBanks(directory);
|
||||
const { banks, files } = await getBanks(directory, getArgValue('--flat'));
|
||||
if (req.url === '/') {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
return res.end(JSON.stringify(banks));
|
||||
@@ -72,7 +118,7 @@ const server = http.createServer(async (req, res) => {
|
||||
let subpath = decodeURIComponent(req.url);
|
||||
const filePath = join(directory, subpath.split('/').join(sep));
|
||||
|
||||
//console.log('GET:', filePath);
|
||||
// console.log('GET:', filePath);
|
||||
const isFound = existsSync(filePath);
|
||||
if (!isFound) {
|
||||
res.statusCode = 404;
|
||||
@@ -88,8 +134,6 @@ const server = http.createServer(async (req, res) => {
|
||||
readStream.pipe(res);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line
|
||||
const PORT = process.env.PORT || 5432;
|
||||
const IP_ADDRESS = '0.0.0.0';
|
||||
let IP;
|
||||
const networkInterfaces = os.networkInterfaces();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strudel/serial",
|
||||
"version": "1.2.2",
|
||||
"version": "1.2.5",
|
||||
"description": "Webserial API for strudel",
|
||||
"main": "serial.mjs",
|
||||
"type": "module",
|
||||
@@ -33,5 +33,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,7 +537,7 @@ export default {
|
||||
],
|
||||
gm_synth_bass_1: [
|
||||
// Synth Bass 1: Bass
|
||||
'0380_Aspirin_sf2_file',
|
||||
// '0380_Aspirin_sf2_file', // broken in safari https://codeberg.org/uzu/strudel/issues/1384
|
||||
'0380_Chaos_sf2_file',
|
||||
'0380_FluidR3_GM_sf2_file',
|
||||
// 0380_GeneralUserGS_sf2_file // laut
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strudel/soundfonts",
|
||||
"version": "1.2.3",
|
||||
"version": "1.2.6",
|
||||
"description": "Soundsfont support for strudel",
|
||||
"main": "index.mjs",
|
||||
"publishConfig": {
|
||||
@@ -37,5 +37,8 @@
|
||||
"devDependencies": {
|
||||
"node-fetch": "^3.3.2",
|
||||
"vite": "^6.0.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getAudioContext, registerSound } from '@strudel/webaudio';
|
||||
import { loadSoundfont as _loadSoundfont, startPresetNote } from 'sfumato';
|
||||
|
||||
Pattern.prototype.soundfont = function (sf, n = 0) {
|
||||
return this.onTrigger((time_deprecate, h, ct, cps, targetTime) => {
|
||||
return this.onTrigger((h, ct, cps, targetTime) => {
|
||||
const ctx = getAudioContext();
|
||||
const note = getPlayableNoteValue(h);
|
||||
const preset = sf.presets[n % sf.presets.length];
|
||||
|
||||
@@ -89,7 +89,7 @@ superdough({ s: 'bd', delay: 0.5 }, 0, 1);
|
||||
- `decay`: seconds of decay phase
|
||||
- `sustain`: gain of sustain phase
|
||||
- `release`: seconds of release phase
|
||||
- `deadline`: seconds until the sound should play (0 = immediate)
|
||||
- `deadline`: seconds from audio context initialization before playing the sound (getAudioContextCurrentTime() = immediate)
|
||||
- `duration`: seconds the sound should last. optional for one shot samples, required for synth sounds
|
||||
|
||||
### registerSynthSounds()
|
||||
@@ -153,6 +153,7 @@ samples('github:tidalcycles/dirt-samples')
|
||||
|
||||
The format is `github:<user>/<repo>/<branch>`.
|
||||
|
||||
If `<repo>` and `<branch>` are not specified, they will default to `samples` and `main` respectively.
|
||||
It expects a `strudel.json` file to be present at the root of the given repository, which declares the sample paths in the repo.
|
||||
|
||||
The format is also expected to be the same as explained above.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
let audioContext;
|
||||
|
||||
export const setDefaultAudioContext = () => {
|
||||
audioContext = new AudioContext();
|
||||
return audioContext;
|
||||
};
|
||||
|
||||
export const getAudioContext = () => {
|
||||
if (!audioContext) {
|
||||
return setDefaultAudioContext();
|
||||
}
|
||||
|
||||
return audioContext;
|
||||
};
|
||||
|
||||
export function getAudioContextCurrentTime() {
|
||||
return getAudioContext().currentTime;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getAudioContext } from './superdough.mjs';
|
||||
import { getAudioContext } from './audioContext.mjs';
|
||||
|
||||
let worklet;
|
||||
export async function dspWorklet(ac, code) {
|
||||
@@ -74,6 +74,6 @@ export const dough = async (code) => {
|
||||
worklet.node.connect(ac.destination);
|
||||
};
|
||||
|
||||
export function doughTrigger(time_deprecate, hap, currentTime, cps, targetTime) {
|
||||
export function doughTrigger(hap, currentTime, cps, targetTime) {
|
||||
window.postMessage({ time: targetTime, dough: hap.value, currentTime, duration: hap.duration, cps });
|
||||
}
|
||||
|
||||
+315
-32
@@ -1,5 +1,9 @@
|
||||
import { getAudioContext } from './superdough.mjs';
|
||||
import { clamp, nanFallback } from './util.mjs';
|
||||
import { getAudioContext } from './audioContext.mjs';
|
||||
import { clamp, nanFallback, midiToFreq, noteToMidi } from './util.mjs';
|
||||
import { getNoiseBuffer } from './noise.mjs';
|
||||
import { logger } from './logger.mjs';
|
||||
|
||||
export const noises = ['pink', 'white', 'brown', 'crackle'];
|
||||
|
||||
export function gainNode(value) {
|
||||
const node = getAudioContext().createGain();
|
||||
@@ -7,6 +11,13 @@ export function gainNode(value) {
|
||||
return node;
|
||||
}
|
||||
|
||||
export function effectSend(input, effect, wet) {
|
||||
const send = gainNode(wet);
|
||||
input.connect(send);
|
||||
send.connect(effect);
|
||||
return send;
|
||||
}
|
||||
|
||||
const getSlope = (y1, y2, x1, x2) => {
|
||||
const denom = x2 - x1;
|
||||
if (denom === 0) {
|
||||
@@ -18,7 +29,9 @@ const getSlope = (y1, y2, x1, x2) => {
|
||||
export function getWorklet(ac, processor, params, config) {
|
||||
const node = new AudioWorkletNode(ac, processor, config);
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
node.parameters.get(key).value = value;
|
||||
if (value !== undefined) {
|
||||
node.parameters.get(key).value = value;
|
||||
}
|
||||
});
|
||||
return node;
|
||||
}
|
||||
@@ -85,6 +98,35 @@ export const getParamADSR = (
|
||||
param[ramp](min, end + release);
|
||||
};
|
||||
|
||||
function getModulationShapeInput(val) {
|
||||
if (typeof val === 'number') {
|
||||
return val % 5;
|
||||
}
|
||||
return { tri: 0, triangle: 0, sine: 1, ramp: 2, saw: 3, square: 4 }[val] ?? 0;
|
||||
}
|
||||
|
||||
export function getLfo(audioContext, begin, end, properties = {}) {
|
||||
const { shape = 0, ...props } = properties;
|
||||
const { dcoffset = -0.5, depth = 1 } = properties;
|
||||
const lfoprops = {
|
||||
frequency: 1,
|
||||
depth,
|
||||
skew: 0.5,
|
||||
phaseoffset: 0,
|
||||
time: begin,
|
||||
begin,
|
||||
end,
|
||||
shape: getModulationShapeInput(shape),
|
||||
dcoffset,
|
||||
min: dcoffset * depth,
|
||||
max: dcoffset * depth + depth,
|
||||
curve: 1,
|
||||
...props,
|
||||
};
|
||||
|
||||
return getWorklet(audioContext, 'lfo-processor', lfoprops);
|
||||
}
|
||||
|
||||
export function getCompressor(ac, threshold, ratio, knee, attack, release) {
|
||||
const options = {
|
||||
threshold: threshold ?? -3,
|
||||
@@ -112,36 +154,110 @@ export const getADSRValues = (params, curve = 'linear', defaultValues) => {
|
||||
return [Math.max(a ?? 0, envmin), Math.max(d ?? 0, envmin), Math.min(sustain, envmax), Math.max(r ?? 0, releaseMin)];
|
||||
};
|
||||
|
||||
export function createFilter(context, type, frequency, Q, att, dec, sus, rel, fenv, start, end, fanchor, model, drive) {
|
||||
const curve = 'exponential';
|
||||
const [attack, decay, sustain, release] = getADSRValues([att, dec, sus, rel], curve, [0.005, 0.14, 0, 0.1]);
|
||||
let filter;
|
||||
let frequencyParam;
|
||||
export function getParamLfo(audioContext, param, start, end, lfoValues) {
|
||||
let { defaultDepth = 1, depth, dcoffset, ...getLfoInputs } = lfoValues;
|
||||
if (depth == null) {
|
||||
const hasLFOParams = Object.values(getLfoInputs).some((v) => v != null);
|
||||
depth = hasLFOParams ? defaultDepth : 0;
|
||||
}
|
||||
let lfo;
|
||||
if (depth) {
|
||||
lfo = getLfo(audioContext, start, end, {
|
||||
depth,
|
||||
dcoffset,
|
||||
...getLfoInputs,
|
||||
});
|
||||
lfo.connect(param);
|
||||
}
|
||||
return lfo;
|
||||
}
|
||||
|
||||
// helper utility for applying standard modulators to a parameter
|
||||
export function applyParameterModulators(audioContext, param, start, end, envelopeValues, lfoValues) {
|
||||
let { amount, offset, defaultAmount = 1, curve = 'linear', values, holdEnd, defaultValues } = envelopeValues;
|
||||
|
||||
if (amount == null) {
|
||||
const hasADSRParams = values.some((p) => p != null);
|
||||
amount = hasADSRParams ? defaultAmount : 0;
|
||||
}
|
||||
|
||||
const min = offset ?? 0;
|
||||
const max = amount + min;
|
||||
const diff = Math.abs(max - min);
|
||||
if (diff) {
|
||||
const [attack, decay, sustain, release] = getADSRValues(values, curve, defaultValues);
|
||||
getParamADSR(param, attack, decay, sustain, release, min, max, start, holdEnd, curve);
|
||||
}
|
||||
const lfo = getParamLfo(audioContext, param, start, end, lfoValues);
|
||||
return { lfo, disconnect: () => lfo?.disconnect() };
|
||||
}
|
||||
export function createFilter(context, start, end, params, cps, cycle) {
|
||||
let {
|
||||
frequency,
|
||||
anchor,
|
||||
env,
|
||||
type,
|
||||
model,
|
||||
q = 1,
|
||||
drive = 0.69,
|
||||
depth,
|
||||
depthfrequency,
|
||||
dcoffset = -0.5,
|
||||
skew,
|
||||
shape,
|
||||
rate,
|
||||
sync,
|
||||
} = params;
|
||||
|
||||
let frequencyParam, filter;
|
||||
if (model === 'ladder') {
|
||||
filter = getWorklet(context, 'ladder-processor', { frequency, q: Q, drive });
|
||||
filter = getWorklet(context, 'ladder-processor', { frequency, q, drive });
|
||||
frequencyParam = filter.parameters.get('frequency');
|
||||
} else {
|
||||
filter = context.createBiquadFilter();
|
||||
filter.type = type;
|
||||
filter.Q.value = Q;
|
||||
filter.Q.value = q;
|
||||
filter.frequency.value = frequency;
|
||||
frequencyParam = filter.frequency;
|
||||
}
|
||||
|
||||
const envelopeValues = [params.attack, params.decay, params.sustain, params.release];
|
||||
const [attack, decay, sustain, release] = getADSRValues(envelopeValues, 'exponential', [0.005, 0.14, 0, 0.1]);
|
||||
// envelope is active when any of these values is set
|
||||
const hasEnvelope = att ?? dec ?? sus ?? rel ?? fenv;
|
||||
const hasEnvelope = [...envelopeValues, env].some((v) => v !== undefined);
|
||||
// Apply ADSR to filter frequency
|
||||
if (hasEnvelope !== undefined) {
|
||||
fenv = nanFallback(fenv, 1, true);
|
||||
fanchor = nanFallback(fanchor, 0, true);
|
||||
const fenvAbs = Math.abs(fenv);
|
||||
const offset = fenvAbs * fanchor;
|
||||
if (hasEnvelope) {
|
||||
env = nanFallback(env, 1, true);
|
||||
anchor = nanFallback(anchor, 0, true);
|
||||
const envAbs = Math.abs(env);
|
||||
const offset = envAbs * anchor;
|
||||
let min = clamp(2 ** -offset * frequency, 0, 20000);
|
||||
let max = clamp(2 ** (fenvAbs - offset) * frequency, 0, 20000);
|
||||
if (fenv < 0) [min, max] = [max, min];
|
||||
getParamADSR(frequencyParam, attack, decay, sustain, release, min, max, start, end, curve);
|
||||
return filter;
|
||||
let max = clamp(2 ** (envAbs - offset) * frequency, 0, 20000);
|
||||
if (env < 0) [min, max] = [max, min];
|
||||
getParamADSR(frequencyParam, attack, decay, sustain, release, min, max, start, end, 'exponential');
|
||||
}
|
||||
|
||||
if (sync != null) {
|
||||
rate = cps * sync;
|
||||
}
|
||||
const hasLFO = [depth, depthfrequency, skew, shape, rate].some((v) => v !== undefined);
|
||||
if (hasLFO) {
|
||||
depth = depth ?? 1;
|
||||
const time = cycle / cps;
|
||||
const modDepth = depthfrequency ?? (depth ?? 1) * frequency;
|
||||
const lfoValues = {
|
||||
depth: modDepth,
|
||||
dcoffset,
|
||||
skew,
|
||||
shape,
|
||||
frequency: rate ?? cps,
|
||||
min: -frequency + 30,
|
||||
max: 20000 - frequency,
|
||||
time,
|
||||
curve: 1,
|
||||
};
|
||||
getParamLfo(context, frequencyParam, start, end, lfoValues);
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
@@ -164,14 +280,22 @@ export function drywet(dry, wet, wetAmount = 0) {
|
||||
let mix = ac.createGain();
|
||||
dry_gain.connect(mix);
|
||||
wet_gain.connect(mix);
|
||||
return mix;
|
||||
return {
|
||||
node: mix,
|
||||
onended: () => {
|
||||
dry_gain.disconnect(mix);
|
||||
wet_gain.disconnect(mix);
|
||||
dry.disconnect(dry_gain);
|
||||
wet.disconnect(wet_gain);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let curves = ['linear', 'exponential'];
|
||||
export function getPitchEnvelope(param, value, t, holdEnd) {
|
||||
// envelope is active when any of these values is set
|
||||
const hasEnvelope = value.pattack ?? value.pdecay ?? value.psustain ?? value.prelease ?? value.penv;
|
||||
if (!hasEnvelope) {
|
||||
if (hasEnvelope === undefined) {
|
||||
return;
|
||||
}
|
||||
const penv = nanFallback(value.penv, 1, true);
|
||||
@@ -199,30 +323,66 @@ export function getVibratoOscillator(param, value, t) {
|
||||
gain.gain.value = vibmod * 100;
|
||||
vibratoOscillator.connect(gain);
|
||||
gain.connect(param);
|
||||
vibratoOscillator.onended = () => {
|
||||
gain.disconnect(param);
|
||||
vibratoOscillator.disconnect(gain);
|
||||
};
|
||||
vibratoOscillator.start(t);
|
||||
return vibratoOscillator;
|
||||
}
|
||||
}
|
||||
|
||||
export function scheduleAtTime(callback, targetTime, audioContext = getAudioContext()) {
|
||||
const currentTime = audioContext.currentTime;
|
||||
webAudioTimeout(audioContext, callback, currentTime, targetTime);
|
||||
}
|
||||
// ConstantSource inherits AudioScheduledSourceNode, which has scheduling abilities
|
||||
// a bit of a hack, but it works very well :)
|
||||
export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) {
|
||||
const constantNode = audioContext.createConstantSource();
|
||||
constantNode.start(startTime);
|
||||
constantNode.stop(stopTime);
|
||||
const constantNode = new ConstantSourceNode(audioContext);
|
||||
|
||||
// Certain browsers requires audio nodes to be connected in order for their onended events
|
||||
// to fire, so we _mute it_ and then connect it to the destination
|
||||
const zeroGain = gainNode(0);
|
||||
zeroGain.connect(audioContext.destination);
|
||||
constantNode.connect(zeroGain);
|
||||
|
||||
// Schedule the `onComplete` callback to occur at `stopTime`
|
||||
constantNode.onended = () => {
|
||||
// Ensure garbage collection
|
||||
try {
|
||||
zeroGain.disconnect();
|
||||
} catch {
|
||||
// pass
|
||||
}
|
||||
try {
|
||||
constantNode.disconnect();
|
||||
} catch {
|
||||
// pass
|
||||
}
|
||||
onComplete();
|
||||
};
|
||||
constantNode.start(startTime);
|
||||
constantNode.stop(stopTime);
|
||||
return constantNode;
|
||||
}
|
||||
const mod = (freq, range = 1, type = 'sine') => {
|
||||
const ctx = getAudioContext();
|
||||
const osc = ctx.createOscillator();
|
||||
osc.type = type;
|
||||
osc.frequency.value = freq;
|
||||
let osc;
|
||||
if (noises.includes(type)) {
|
||||
osc = ctx.createBufferSource();
|
||||
osc.buffer = getNoiseBuffer(type, 2);
|
||||
osc.loop = true;
|
||||
} else {
|
||||
osc = ctx.createOscillator();
|
||||
osc.type = type;
|
||||
osc.frequency.value = freq;
|
||||
}
|
||||
|
||||
osc.start();
|
||||
const g = new GainNode(ctx, { gain: range });
|
||||
const g = gainNode(range);
|
||||
osc.connect(g); // -range, range
|
||||
return { node: g, stop: (t) => osc.stop(t) };
|
||||
return { node: g, stop: (t) => osc.stop(t), osc: osc };
|
||||
};
|
||||
const fm = (frequencyparam, harmonicityRatio, modulationIndex, wave = 'sine') => {
|
||||
const carrfreq = frequencyparam.value;
|
||||
@@ -253,7 +413,7 @@ export function applyFM(param, value, begin) {
|
||||
|
||||
modulator = fmmod.node;
|
||||
stop = fmmod.stop;
|
||||
if (![fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity].find((v) => v !== undefined)) {
|
||||
if (![fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity].some((v) => v !== undefined)) {
|
||||
// no envelope by default
|
||||
modulator.connect(param);
|
||||
} else {
|
||||
@@ -274,6 +434,129 @@ export function applyFM(param, value, begin) {
|
||||
modulator.connect(envGain);
|
||||
envGain.connect(param);
|
||||
}
|
||||
fmmod.osc.onended = () => {
|
||||
envGain.disconnect();
|
||||
modulator.disconnect();
|
||||
fmmod.osc.disconnect();
|
||||
};
|
||||
}
|
||||
return { stop };
|
||||
}
|
||||
|
||||
// Saturation curves
|
||||
|
||||
const __squash = (x) => x / (1 + x); // [0, inf) to [0, 1)
|
||||
const _mod = (n, m) => ((n % m) + m) % m;
|
||||
|
||||
const _scurve = (x, k) => ((1 + k) * x) / (1 + k * Math.abs(x));
|
||||
const _soft = (x, k) => Math.tanh(x * (1 + k));
|
||||
const _hard = (x, k) => clamp((1 + k) * x, -1, 1);
|
||||
|
||||
const _fold = (x, k) => {
|
||||
// Closed form folding for audio rate
|
||||
let y = (1 + 0.5 * k) * x;
|
||||
const window = _mod(y + 1, 4);
|
||||
return 1 - Math.abs(window - 2);
|
||||
};
|
||||
|
||||
const _sineFold = (x, k) => Math.sin((Math.PI / 2) * _fold(x, k));
|
||||
|
||||
const _cubic = (x, k) => {
|
||||
const t = __squash(Math.log1p(k));
|
||||
const cubic = (x - (t / 3) * x * x * x) / (1 - t / 3); // normalized to go from (-1, 1)
|
||||
return _soft(cubic, k);
|
||||
};
|
||||
|
||||
const _diode = (x, k, asym = false) => {
|
||||
const g = 1 + 2 * k; // gain
|
||||
const t = __squash(Math.log1p(k));
|
||||
const bias = 0.07 * t;
|
||||
const pos = _soft(x + bias, 2 * k);
|
||||
const neg = _soft(asym ? bias : -x + bias, 2 * k);
|
||||
const y = pos - neg;
|
||||
// We divide by the derivative at 0 so that the distortion is roughly
|
||||
// the identity map near 0 => small values are preserved and undistorted
|
||||
const sech = 1 / Math.cosh(g * bias);
|
||||
const sech2 = sech * sech; // derivative of soft (i.e. tanh) is sech^2
|
||||
const denom = Math.max(1e-8, (asym ? 1 : 2) * g * sech2); // g from chain rule; 2 if both pos/neg have x
|
||||
return _soft(y / denom, k);
|
||||
};
|
||||
|
||||
const _asym = (x, k) => _diode(x, k, true);
|
||||
|
||||
const _chebyshev = (x, k) => {
|
||||
const kl = 10 * Math.log1p(k);
|
||||
let tnm1 = 1;
|
||||
let tnm2 = x;
|
||||
let tn;
|
||||
let y = 0;
|
||||
for (let i = 1; i < 64; i++) {
|
||||
if (i < 2) {
|
||||
// Already set inital conditions
|
||||
y += i == 0 ? tnm1 : tnm2;
|
||||
continue;
|
||||
}
|
||||
tn = 2 * x * tnm1 - tnm2; // https://en.wikipedia.org/wiki/Chebyshev_polynomials#Recurrence_definition
|
||||
tnm2 = tnm1;
|
||||
tnm1 = tn;
|
||||
if (i % 2 === 0) {
|
||||
y += Math.min((1.3 * kl) / i, 2) * tn;
|
||||
}
|
||||
}
|
||||
// Soft clip
|
||||
return _soft(y, kl / 20);
|
||||
};
|
||||
|
||||
export const distortionAlgorithms = {
|
||||
scurve: _scurve,
|
||||
soft: _soft,
|
||||
hard: _hard,
|
||||
cubic: _cubic,
|
||||
diode: _diode,
|
||||
asym: _asym,
|
||||
fold: _fold,
|
||||
sinefold: _sineFold,
|
||||
chebyshev: _chebyshev,
|
||||
};
|
||||
const _algoNames = Object.freeze(Object.keys(distortionAlgorithms));
|
||||
|
||||
export const getDistortionAlgorithm = (algo) => {
|
||||
let index = algo;
|
||||
if (typeof algo === 'string') {
|
||||
index = _algoNames.indexOf(algo);
|
||||
if (index === -1) {
|
||||
logger(`[superdough] Could not find waveshaping algorithm ${algo}.
|
||||
Available options are ${_algoNames.join(', ')}.
|
||||
Defaulting to ${_algoNames[0]}.`);
|
||||
index = 0;
|
||||
}
|
||||
}
|
||||
const name = _algoNames[index % _algoNames.length]; // allow for wrapping if algo was a number
|
||||
return distortionAlgorithms[name];
|
||||
};
|
||||
|
||||
export const getDistortion = (distort, postgain, algorithm) => {
|
||||
return getWorklet(getAudioContext(), 'distort-processor', { distort, postgain }, { processorOptions: { algorithm } });
|
||||
};
|
||||
|
||||
export const getFrequencyFromValue = (value, defaultNote = 36) => {
|
||||
let { note, freq, octave = 0 } = value;
|
||||
note = note || defaultNote;
|
||||
if (typeof note === 'string') {
|
||||
note = noteToMidi(note); // e.g. c3 => 48
|
||||
}
|
||||
// get frequency
|
||||
if (!freq && typeof note === 'number') {
|
||||
freq = midiToFreq(note); // + 48);
|
||||
}
|
||||
freq *= Math.pow(2, octave);
|
||||
return Number(freq);
|
||||
};
|
||||
|
||||
export const destroyAudioWorkletNode = (node) => {
|
||||
if (node == null) {
|
||||
return;
|
||||
}
|
||||
node.disconnect();
|
||||
node.parameters.get('end')?.setValueAtTime(0, 0);
|
||||
};
|
||||
|
||||
@@ -11,3 +11,5 @@ export * from './synth.mjs';
|
||||
export * from './zzfx.mjs';
|
||||
export * from './logger.mjs';
|
||||
export * from './dspworklet.mjs';
|
||||
export * from './audioContext.mjs';
|
||||
export * from './wavetable.mjs';
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
let log = (msg) => console.log(msg);
|
||||
|
||||
export function errorLogger(e, origin = 'superdough') {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error(e);
|
||||
}
|
||||
logger(`[${origin}] error: ${e.message}`);
|
||||
}
|
||||
|
||||
export const logger = (...args) => log(...args);
|
||||
|
||||
export const setLogger = (fn) => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { drywet } from './helpers.mjs';
|
||||
import { getAudioContext } from './superdough.mjs';
|
||||
import { getAudioContext } from './audioContext.mjs';
|
||||
|
||||
let noiseCache = {};
|
||||
|
||||
// lazy generates noise buffers and keeps them forever
|
||||
function getNoiseBuffer(type, density) {
|
||||
export function getNoiseBuffer(type, density) {
|
||||
const ac = getAudioContext();
|
||||
if (noiseCache[type]) {
|
||||
return noiseCache[type];
|
||||
@@ -65,8 +65,9 @@ export function getNoiseOscillator(type = 'white', t, density = 0.02) {
|
||||
export function getNoiseMix(inputNode, wet, t) {
|
||||
const noiseOscillator = getNoiseOscillator('pink', t);
|
||||
const noiseMix = drywet(inputNode, noiseOscillator.node, wet);
|
||||
noiseOscillator.node.onended = () => noiseMix.onended();
|
||||
return {
|
||||
node: noiseMix,
|
||||
node: noiseMix.node,
|
||||
stop: (time) => noiseOscillator?.stop(time),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "superdough",
|
||||
"version": "1.2.3",
|
||||
"version": "1.2.6",
|
||||
"description": "simple web audio synth and sampler intended for live coding. inspired by superdirt and webdirt.",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
@@ -37,5 +37,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"nanostores": "^0.11.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import reverbGen from './reverbGen.mjs';
|
||||
import { clamp } from './util.mjs';
|
||||
|
||||
if (typeof AudioContext !== 'undefined') {
|
||||
AudioContext.prototype.adjustLength = function (duration, buffer) {
|
||||
AudioContext.prototype.adjustLength = function (duration, buffer, speed = 1, offsetAmount = 0) {
|
||||
const sampleOffset = Math.floor(clamp(offsetAmount, 0, 1) * buffer.length);
|
||||
const newLength = buffer.sampleRate * duration;
|
||||
const newBuffer = this.createBuffer(buffer.numberOfChannels, buffer.length, buffer.sampleRate);
|
||||
for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
|
||||
@@ -9,22 +11,30 @@ if (typeof AudioContext !== 'undefined') {
|
||||
let newData = newBuffer.getChannelData(channel);
|
||||
|
||||
for (let i = 0; i < newLength; i++) {
|
||||
newData[i] = oldData[i] || 0;
|
||||
// loop the buffer around to prevent
|
||||
let position = (sampleOffset + i * Math.abs(speed)) % oldData.length;
|
||||
if (speed < 1) {
|
||||
position = position * -1;
|
||||
}
|
||||
|
||||
newData[i] = oldData.at(position) || 0;
|
||||
}
|
||||
}
|
||||
return newBuffer;
|
||||
};
|
||||
|
||||
AudioContext.prototype.createReverb = function (duration, fade, lp, dim, ir) {
|
||||
AudioContext.prototype.createReverb = function (duration, fade, lp, dim, ir, irspeed, irbegin) {
|
||||
const convolver = this.createConvolver();
|
||||
convolver.generate = (d = 2, fade = 0.1, lp = 15000, dim = 1000, ir) => {
|
||||
convolver.generate = (d = 2, fade = 0.1, lp = 15000, dim = 1000, ir, irspeed, irbegin) => {
|
||||
convolver.duration = d;
|
||||
convolver.fade = fade;
|
||||
convolver.lp = lp;
|
||||
convolver.dim = dim;
|
||||
convolver.ir = ir;
|
||||
convolver.irspeed = irspeed;
|
||||
convolver.irbegin = irbegin;
|
||||
if (ir) {
|
||||
convolver.buffer = this.adjustLength(d, ir);
|
||||
convolver.buffer = this.adjustLength(d, ir, irspeed, irbegin);
|
||||
} else {
|
||||
reverbGen.generateReverb(
|
||||
{
|
||||
@@ -41,7 +51,7 @@ if (typeof AudioContext !== 'undefined') {
|
||||
);
|
||||
}
|
||||
};
|
||||
convolver.generate(duration, fade, lp, dim, ir);
|
||||
convolver.generate(duration, fade, lp, dim, ir, irspeed, irbegin);
|
||||
return convolver;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,6 +104,8 @@ var applyGradualLowpass = function (input, lpFreqStart, lpFreqEnd, lpFreqEndAt,
|
||||
player.start();
|
||||
context.oncomplete = function (event) {
|
||||
callback(event.renderedBuffer);
|
||||
filter.disconnect();
|
||||
player.disconnect();
|
||||
};
|
||||
context.startRendering();
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user