mirror of
https://codeberg.org/uzu/strudel
synced 2026-07-13 22:35:15 -04:00
Compare commits
993 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d198910a86 | |||
| 8ce6a881e6 | |||
| 7bc8576a68 | |||
| bbe95b3995 | |||
| 2dc921e375 | |||
| 3d13955c41 | |||
| ae9638c353 | |||
| 97cd3a5946 | |||
| ca3452bf52 | |||
| 7f528870b7 | |||
| 60e5682829 | |||
| d605fbbde8 | |||
| 21e6d81d34 | |||
| 85e79d9932 | |||
| 71dfbeea88 | |||
| ffcab7bf6c | |||
| c3f6a1d6ba | |||
| eb96d3ad63 | |||
| 06e10cd693 | |||
| 80875cca95 | |||
| b954a25b2b | |||
| cca346d2c2 | |||
| 3343c7bcf0 | |||
| 6e3c5b23dd | |||
| c12a77a0a1 | |||
| e5ab6b3c93 | |||
| e82bb6c410 | |||
| 8b1fb12388 | |||
| 1f1f3288a6 | |||
| d642c0e12f | |||
| 1eb657e6fe | |||
| 7c5f53f9ae | |||
| efe7c9394f | |||
| 35bfd8b3ff | |||
| c2720a5738 | |||
| 5355a0c563 | |||
| 75db9ff16e | |||
| 12cc1e9b5e | |||
| cd48e67675 | |||
| 3a284696c9 | |||
| 753dc48b69 | |||
| 5ee5d4f1b1 | |||
| 31cacc3c29 | |||
| 8d2bca59c4 | |||
| f867cbce92 | |||
| a659c89385 | |||
| 8290e99cf9 | |||
| e648ddd9ff | |||
| 85fd567fb2 | |||
| 20ea2980a2 | |||
| af2d4784c6 | |||
| 526083d7c9 | |||
| 8e792e1bfd | |||
| f3e6f86857 | |||
| 25dbfe4c7d | |||
| 0a586eddb3 | |||
| b6d81ba2a0 | |||
| 8f3dc8daea | |||
| ced434a1cf | |||
| 96626fce25 | |||
| c74707e804 | |||
| 0174e08639 | |||
| af88471f6b | |||
| af855a0c77 | |||
| f9a9993c42 | |||
| 604a1b770c | |||
| 2aa978ca5b | |||
| 0571a8dc7d | |||
| a8a919f581 | |||
| bf21c4cf02 | |||
| 741ff3f3f0 | |||
| 4476921069 | |||
| 8c5e4a5780 | |||
| 34ae1bb305 | |||
| 0cf11adc2c | |||
| dab1676761 | |||
| d753eedb90 | |||
| fd0209dbc0 | |||
| b4e00538f8 | |||
| 7a5020aa71 | |||
| a205f7d873 | |||
| 0e9c50bf0a | |||
| e837c516c5 | |||
| 3b2e9fa0b4 | |||
| 6a47139e0d | |||
| 4743334a17 | |||
| 7cff2050c8 | |||
| eefae851d5 | |||
| 40f9f4cde0 | |||
| f7f8c56c0a | |||
| c7e88e1ff2 | |||
| f6a43f1b10 | |||
| a3b183d304 | |||
| f7175c0505 | |||
| e6e96c8603 | |||
| d4b3a3b972 | |||
| a1d1bd0e11 | |||
| fd2ff255e5 | |||
| 5903070c02 | |||
| e92865cecb | |||
| a5c4fd5bc1 | |||
| e26040d154 | |||
| 17a1b75e5b | |||
| 7df9688bb9 | |||
| 5caaf423ff | |||
| d557bb59f0 | |||
| 679e81eb0f | |||
| 37dd834398 | |||
| 959e8ac7bc | |||
| 16524ca805 | |||
| b4b0194455 | |||
| 8f342262ab | |||
| fd472a64d9 | |||
| 58094973b0 | |||
| e82602af5a | |||
| db49b37780 | |||
| bf243c04b9 | |||
| bfef9ba8d2 | |||
| aa7ba6ae99 | |||
| a86e5751eb | |||
| bc05cffdbc | |||
| 7b1e02e8cf | |||
| 8d79715ce4 | |||
| 257f09da4f | |||
| ecd58fa501 | |||
| c4ece4932e | |||
| 5c9855bf12 | |||
| e54b9c3a32 | |||
| 6afe0dc714 | |||
| ebaf7624f9 | |||
| aa8209ad4c | |||
| 882bcc513f | |||
| aec33710b7 | |||
| d41ebf190b | |||
| 7953fa61e4 | |||
| 6051b922c0 | |||
| 367e07da0c | |||
| 0edede106a | |||
| b6264a1513 | |||
| 680b7d78ec | |||
| e79b4b8ccb | |||
| 98367868ac | |||
| b648961d30 | |||
| 616bc64e70 | |||
| c0f4f47150 | |||
| b4496fcc24 | |||
| 48f6a41683 | |||
| 7bce739068 | |||
| 06f9c2dfb5 | |||
| 30fe5b47e3 | |||
| 1823993e7c | |||
| 1935195e9a | |||
| 300a1bbfd5 | |||
| 2f43dcab0c | |||
| 125eece223 | |||
| 53e49952d8 | |||
| e286ab2830 | |||
| e4e2d60b28 | |||
| 99bec835f8 | |||
| d349d2a0cd | |||
| fc2f5accfe | |||
| c7a2c2bb9c | |||
| 6908ea38e4 | |||
| 5a51b4ec71 | |||
| 3922defab9 | |||
| a9752fb2a4 | |||
| 0a2f2eaa3d | |||
| 0de212c12f | |||
| 181ebee374 | |||
| b376b2616a | |||
| 56ecb006fa | |||
| e6c1e97838 | |||
| 2748189f18 | |||
| 63d254daf4 | |||
| 34991e9602 | |||
| 8df5c80d82 | |||
| e0344dbd67 | |||
| 931d684b19 | |||
| 4a7344595d | |||
| 18b3c66eb8 | |||
| 63de46ae96 | |||
| c598059dc7 | |||
| dec037bed5 | |||
| 29f7f5c1f8 | |||
| bae5571df0 | |||
| 4b08dd8447 | |||
| 11e3da0552 | |||
| ef2ee0969a | |||
| 12f9af227c | |||
| dc5f6827f9 | |||
| 8baecf78b9 | |||
| 8eeebf1e4f | |||
| adf9596d07 | |||
| 4e14718e93 | |||
| 2998de8426 | |||
| c8b2e1ac9e | |||
| c7789a09e3 | |||
| a53385aa11 | |||
| 0a5fb8ca1d | |||
| 99e4999e6a | |||
| cd071cb7c0 | |||
| 8e1fba0436 | |||
| f4df4f679f | |||
| 1d2a3bdb9c | |||
| c6e0f0533e | |||
| 415b6d5f1d | |||
| 4169b764c3 | |||
| e7d1613bfb | |||
| 45aa1f5384 | |||
| 3a17aeabf3 | |||
| 2aeba2e53f | |||
| 3608f1ca25 | |||
| 37990b459f | |||
| a34e58aa09 | |||
| 8cbe4b945c | |||
| 6f9a1470e7 | |||
| 8a3719752f | |||
| 96835be056 | |||
| 960f1de9d2 | |||
| 12884cba3a | |||
| 103b27c21f | |||
| 597c941991 | |||
| 20c513d649 | |||
| e89414e400 | |||
| a79491dd16 | |||
| 765de9daf3 | |||
| 132dea8478 | |||
| b889d19493 | |||
| 71d36b79ed | |||
| d7bf5db8be | |||
| 48231ea12a | |||
| c8a442234e | |||
| efda32eedb | |||
| 95d04d1149 | |||
| 358b1e3f56 | |||
| fe7e8f7add | |||
| 45d329d3d1 | |||
| 5e99393f5a | |||
| 9650c8eb50 | |||
| fab8d2193d | |||
| 150835aa4d | |||
| 76237016a3 | |||
| 6f4d8ebacf | |||
| 4374bdf1bd | |||
| 3cecf4a50e | |||
| 16b5faf333 | |||
| c4826b7dfb | |||
| bd61c1dc7e | |||
| ceb3aa797d | |||
| 9b3277da1c | |||
| 53c101005c | |||
| 82ca77be9d | |||
| 6402ad5d37 | |||
| ab03ece2b7 | |||
| 173e359a65 | |||
| bd282f439a | |||
| 177c3a4a2e | |||
| ad43259353 | |||
| 32924b3fcf | |||
| d4d9b4f543 | |||
| 4c99f4866b | |||
| d50af71ba1 | |||
| 0664f90178 | |||
| 4321814d36 | |||
| 6610713018 | |||
| 600ab0a83e | |||
| 1225d04437 | |||
| f6f507dd29 | |||
| c7586e96ff | |||
| 46291bf85c | |||
| d7e240e2dc | |||
| 0b711e879c | |||
| f0aac2098a | |||
| c1a185e852 | |||
| b9fce15042 | |||
| e01621d0cc | |||
| 196f70ab6b | |||
| 1d4f2b81d8 | |||
| d28c8dac8e | |||
| eb18648a2a | |||
| 25c20296a1 | |||
| 2db30a710b | |||
| 71d1fe99e4 | |||
| 0ec9826de5 | |||
| af7855402f | |||
| 39bff8900f | |||
| 68b1cb87ca | |||
| 05ed16e158 | |||
| 24b69ad1bc | |||
| 5bddb6f48d | |||
| c5250d7794 | |||
| 7d890156f4 | |||
| 8f9967c76d | |||
| 91ebdd5ea6 | |||
| fc74dcdb1a | |||
| b65cc2128f | |||
| 7a3e0fd3fe | |||
| 4061dd4cf8 | |||
| b523205590 | |||
| 956598da18 | |||
| e011f3b982 | |||
| cb591815ed | |||
| 6a4fd27f5b | |||
| d30df8d4cf | |||
| e10cd22c26 | |||
| 18dc4944bf | |||
| d84db1e663 | |||
| a6008e2700 | |||
| 2f8cabbb6b | |||
| ec26392509 | |||
| 30cd486f37 | |||
| cde0520ed9 | |||
| 405338e4f0 | |||
| afbcaa54d0 | |||
| 6a1c6081e0 | |||
| 3c4ba667c8 | |||
| 7fcb5262b8 | |||
| b824c45ba4 | |||
| e47e6c87fc | |||
| 69b5ba89cb | |||
| 33a51761ce | |||
| f4b1baac87 | |||
| d217119391 | |||
| d5d6284a90 | |||
| fb790de26c | |||
| ec6f6a1f1c | |||
| a126bc9b86 | |||
| a5c7fcc136 | |||
| d70b758065 | |||
| 9fdba6374c | |||
| af90baea33 | |||
| 692359309f | |||
| d65b908bda | |||
| 743e239e52 | |||
| f44a96a5ce | |||
| b94a686394 | |||
| 728552b669 | |||
| 8b427bc1dd | |||
| 8529516c66 | |||
| ae47d767ce | |||
| ab64451d65 | |||
| 89da9bbdb0 | |||
| 2bc208d8f5 | |||
| 39e7f70079 | |||
| 7e5a907ce5 | |||
| d7afb8a6c0 | |||
| a278f21f1b | |||
| cfeff26d42 | |||
| e60ecba393 | |||
| f0d49e0e22 | |||
| 7ee6b933cc | |||
| a3947d1ccf | |||
| e86850b6d8 | |||
| 89a6efb69e | |||
| 31c694b596 | |||
| 7a4babf6e1 | |||
| d5e5ab83ac | |||
| 36587ae747 | |||
| 6c01c16a0c | |||
| be36898803 | |||
| df047fd48d | |||
| 68840de188 | |||
| 020a6bc75f | |||
| 20016fd9aa | |||
| 7bc6fcbf2a | |||
| 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 | |||
| 0e3840aff4 | |||
| a929b36770 | |||
| 6f58e74bea | |||
| 907f03f3bf | |||
| 0435711051 | |||
| 8f6718b9e8 | |||
| aa77255d98 | |||
| 7267990e20 | |||
| 387e5db0fe | |||
| d9f63b8dfa | |||
| 8ed72b4573 | |||
| 2794541dad | |||
| 3e118ce0a8 | |||
| c3ad0d1789 | |||
| 19b710c401 | |||
| 74488f8480 | |||
| 8144ec46bd | |||
| 0dbca05de0 | |||
| f8796d039f | |||
| 442f487973 | |||
| 4ec68fcd1d | |||
| 7ccdefe1c6 | |||
| b73f624058 | |||
| 7abfcd0f38 | |||
| 4504d1afd0 | |||
| e4466082eb | |||
| 29b6729246 | |||
| dd2c808cd7 | |||
| b3df7f438f | |||
| 69c5df67f5 | |||
| a21f0d629e | |||
| 371e6c7d6d | |||
| 0bb89d1a9f | |||
| 389a63a850 | |||
| 654c1a86f0 | |||
| 3b1c75d3de | |||
| 48407c309c | |||
| 99beb4454d | |||
| 30cc46ea66 | |||
| ac3f477f30 | |||
| ea7dc7a732 | |||
| 573cd1ae41 | |||
| 641bd2f356 | |||
| f5ed6dbe79 | |||
| 7a3bea6f90 | |||
| cc10122b71 | |||
| b66c0d18b3 | |||
| 17af92b2df | |||
| e90aa5202d | |||
| fb7f686519 | |||
| 0331c91b80 | |||
| 4d5ebfc204 | |||
| aca500ba73 | |||
| 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 | |||
| 03d2f363f1 | |||
| ff370cf86f | |||
| 28056deb66 | |||
| 20bbda30f8 | |||
| 9cbeaef88d | |||
| ca936aada7 | |||
| a8c613b7eb | |||
| fd0a910bfb | |||
| fe70a2df43 | |||
| 6baca67cd3 | |||
| 7071dec775 | |||
| 3d55567445 | |||
| 39e2221c29 | |||
| 85f6454340 | |||
| b8ce920f50 | |||
| ccb5ec1685 | |||
| b09230fe90 | |||
| 8f7b439dce | |||
| c823a37c93 | |||
| 0c9f535ff5 | |||
| fb5884ab81 | |||
| 16f885c010 | |||
| ea91d30ce4 | |||
| 5a70599aac | |||
| f0bd5bffaa | |||
| 7b74a902a2 | |||
| 95700e1196 | |||
| ac37b6a029 | |||
| e7578be6c5 | |||
| 28df4259b8 | |||
| 5e27358fe8 | |||
| a986688874 | |||
| 4ab8c256b2 | |||
| 06ea5a36e2 | |||
| daea207aff | |||
| 92d3fe496d | |||
| 98031676d6 | |||
| 498624f6fe | |||
| bf702c3b2a | |||
| 6e4dae4dfa | |||
| 98f5a56ef6 | |||
| 46af6ed9ef | |||
| 752940b3ab | |||
| c727534d29 | |||
| 20e5fdedfb | |||
| 188f0e9098 | |||
| c2a5562bad | |||
| 57f2a97b7b | |||
| a8ea9a1b63 | |||
| a2b76bde14 | |||
| 0f652e8b5b | |||
| 2a0635879c | |||
| f5fc0ff9d1 | |||
| 7774364ac7 | |||
| 329289fc35 | |||
| 8b3c0d2a4a | |||
| 120b46c4a5 | |||
| 4bdaf577c0 | |||
| 428a4b3dd7 | |||
| ff83994733 | |||
| 418bca79b2 | |||
| b3715dc5df | |||
| 9a47e0f11e | |||
| 908c0e44ff | |||
| 920776fdfe | |||
| 0fbde61b2d | |||
| 4c49cd9297 | |||
| 70d2da81d8 | |||
| 72423c3a25 | |||
| 92a82ce4bd | |||
| b2ce4591d9 | |||
| 1be9f40574 | |||
| 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 | |||
| bd73d96847 | |||
| 7cf9db4872 | |||
| c9381a11f9 | |||
| 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 | |||
| 23aa8ef509 | |||
| 945b325336 | |||
| 502f0b5c23 | |||
| 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 | |||
| 99bb227cf4 | |||
| 70e776e799 | |||
| 66aad0004a | |||
| a968545458 | |||
| 5414bbe85d | |||
| e7e80bfd83 | |||
| 798eb22d9a | |||
| 977420e74d | |||
| 9c108862ef | |||
| 46a45b4596 | |||
| 1205573bc6 | |||
| 2ccbb0596d | |||
| 862b88b6c4 | |||
| 9b1c23d49f | |||
| 42ab65abdb | |||
| bb982e6b9b | |||
| 56b1ea3fa5 | |||
| c199b51645 | |||
| 8d2a368da9 | |||
| 052d09e892 | |||
| 439a7dc5e6 | |||
| 4e2e790864 | |||
| 4f3b40bd7b | |||
| b8e1b8d7ac | |||
| e4070b86f8 | |||
| fe46e1da53 | |||
| d1869c18ba | |||
| 02cd79a6d9 | |||
| 9eeb6da3e6 | |||
| 869141b0a3 | |||
| ea017e7622 | |||
| 763f900c69 | |||
| cd7865a66f | |||
| 9c8e39d83d | |||
| 022ac95bbd | |||
| b3e25cfc52 | |||
| 3d25aa1b91 | |||
| 0a4b6a01b3 | |||
| b3e433291c | |||
| 969c9663ef | |||
| 5e4e678800 | |||
| b8423f0ed1 | |||
| 5d5159a08f | |||
| 1e96d59ac8 | |||
| b8c46d6b26 | |||
| 79453ac2c3 | |||
| b08890bd9e | |||
| 938c92fda2 | |||
| 9a9fe83f9c | |||
| bf5d9917ab | |||
| 4f15d681b0 | |||
| 6a9739bed5 | |||
| d8f0d70908 | |||
| e25eab11ad | |||
| efb337677c | |||
| 4a70ac5a99 | |||
| 554d6118c6 | |||
| 03271ec5fb | |||
| a0760f9468 | |||
| 058d950121 | |||
| c8c6a2ce08 | |||
| a4c040e101 | |||
| 7046c1f546 | |||
| a4ab8e1b06 | |||
| 7f103e77e3 | |||
| a26559fd3d | |||
| 5448fc6077 | |||
| 06fafbd8eb | |||
| 10156f0067 | |||
| 5632d83afb | |||
| a32473d570 | |||
| e87bb4376f | |||
| 0722cf7ded | |||
| 37fde8f3e8 | |||
| 8655c5b73c | |||
| 31c1450edc | |||
| cbb4207bab | |||
| f5ae50c3ce | |||
| 5efcd14f40 | |||
| 8051e56d13 | |||
| 239987af0b | |||
| 8363ba5a41 | |||
| 30f7127a92 | |||
| 14193abe03 | |||
| 3caf08e199 | |||
| 5cbd38a3e8 | |||
| bbd7ed0f27 | |||
| 43504d16b6 | |||
| 7e8206dcbc | |||
| 3963b807eb | |||
| e99adffc56 | |||
| c9f494d865 | |||
| e8254735bb | |||
| 3d30007756 | |||
| aeaed50446 | |||
| 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 |
@@ -0,0 +1,40 @@
|
||||
name: Build and Deploy to beta (warm.strudel.cc)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
# 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
|
||||
|
||||
+1081
File diff suppressed because it is too large
Load Diff
+22
-17
@@ -4,20 +4,13 @@ Thanks for wanting to contribute!!! There are many ways you can add value to thi
|
||||
|
||||
## Move to codeberg
|
||||
|
||||
We are currently in the process of moving from github to codeberg -- not everything is working, please bear with us.
|
||||
|
||||
To update your local clone, you can run this command:
|
||||
|
||||
```
|
||||
git remote set-url origin git@codeberg.org:uzu/strudel.git
|
||||
```
|
||||
|
||||
Along with many other live coding projects, we have moved from Microsoft's Github platform to Codeberg for ethical reasons. Please don't fork the project back to github.
|
||||
|
||||
## Communication Channels
|
||||
|
||||
To get in touch with the contributors, either
|
||||
To get in touch with the community, either
|
||||
|
||||
- [join the Tidal Discord Channel](https://discord.com/invite/HGEdXmRkzT) and go to the #strudel channel
|
||||
- [join the Uzulang Discord Server](https://discord.com/invite/HGEdXmRkzT) and go to the strudel channels (Uzulangs are a family of live coding languages inspired by each other, including TidalCycles as well as Strudel)
|
||||
- Find related discussions on the [tidal club forum](https://club.tidalcycles.org/)
|
||||
|
||||
## Ask a Question
|
||||
@@ -36,22 +29,35 @@ Use one of the Communication Channels listed above and drop us a line or two!
|
||||
## Share Music
|
||||
|
||||
If you made some music with strudel, you can give back some love and share what you've done!
|
||||
Your creation could also be part of the random selection in the REPL if you want.
|
||||
Use one of the Communication Channels listed above.
|
||||
(There used to be a random selection of contributed patterns in the REPL, but that is unfortunately disabled for now due to abuse)
|
||||
|
||||
## Improve the Docs
|
||||
|
||||
If you find some weak spots in the [docs](https://strudel.cc/workshop/getting-started/),
|
||||
you can edit each file directly on codeburg. (we are currently fixing the "Edit this page" links in the right sidebar)
|
||||
If you find some weak spots in the [docs](https://strudel.cc/workshop/getting-started/), you can edit each file directly on codeberg. There are "Edit this page" links in the right sidebar that take you to the right place.
|
||||
|
||||
## Propose a Feature
|
||||
|
||||
If you want a specific feature that is not part of strudel yet, feel free to use one of the communication channels above.
|
||||
Maybe you even want to help with the implementation of that feature!
|
||||
If you want a specific feature that is not part of strudel yet, feel free to use one of the communication channels above. Please bear in mind that this is a free/open source project, oriented around collaborative discussion. Maybe you even want to help with the implementation of that feature!
|
||||
|
||||
## Contribute a feature
|
||||
|
||||
Pull requests welcome! Consider starting with discussion or proof-of-concept first, rather than do loads of work on something and then find it doesn't fit the collective goals of the project, or something like that.
|
||||
|
||||
At the time of writing we have a PR backlog, and generally prioritise bugfixes and contributions that have arisen through community discussion.
|
||||
|
||||
### AI/LLM policy
|
||||
|
||||
Strudel is a project handmade by humans, with thought and nuance.
|
||||
|
||||
If you have used LLMs (so called 'AI'), please detail that in the pull request. We are still developing our response to the onslaught of LLM technology, but for practical and legal reasons are currently not accepting wholly LLM-generated code. We are also not accepting PRs that add LLM features to strudel itself.
|
||||
|
||||
There are #llm-chat and #llm-share channels on our discord. Please do not discuss or share LLM-related things outside of those channels.
|
||||
|
||||
## Report a Bug
|
||||
|
||||
If you've found a bug, or some behaviour that does not seem right, you are welcome to file an [issue](https://codeberg.org/uzu/strudel/issues).
|
||||
|
||||
Please check that it has not been reported before.
|
||||
|
||||
## Fix a Bug
|
||||
@@ -118,8 +124,7 @@ There are also eslint extensions / plugins for most editors.
|
||||
|
||||
## Running all CI Checks
|
||||
|
||||
When opening a PR, the CI runner will automatically check the code style and eslint, as well as run all tests.
|
||||
You can run the same check with `pnpm check`
|
||||
When opening a PR, the CI runner will (once approved by a human) check the code style and eslint, as well as run all tests. You can run the same check with `pnpm check`.
|
||||
|
||||
## Package Workflow
|
||||
|
||||
|
||||
+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"]
|
||||
@@ -1,19 +1,22 @@
|
||||
# strudel
|
||||
|
||||
Live coding patterns on the web
|
||||
https://strudel.cc/
|
||||
|
||||
|
||||
- Try it here: <https://strudel.cc>
|
||||
- Docs: <https://strudel.cc/learn>
|
||||
- Source: https://codeberg.org/uzu/strudel/
|
||||
* Along with many other live coding projects, we have moved from Microsoft's Github platform to Codeberg for ethical reasons. **Please don't fork the project back to github**.
|
||||
- Technical Blog Post: <https://loophole-letters.vercel.app/strudel>
|
||||
- 1 Year of Strudel Blog Post: <https://loophole-letters.vercel.app/strudel1year>
|
||||
- 2 Years of Strudel Blog Post: <https://strudel.cc/blog/#year-2>
|
||||
|
||||
|
||||
## Running Locally
|
||||
|
||||
After cloning the project, you can run the REPL locally:
|
||||
|
||||
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
|
||||
|
||||
@@ -42,6 +42,7 @@ export default [
|
||||
'**/hydra.mjs',
|
||||
'**/jsdoc-synonyms.js',
|
||||
'packages/hs2js/src/hs2js.mjs',
|
||||
'packages/supradough/dough-export.mjs',
|
||||
'**/samples',
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script src="https://unpkg.com/@strudel/repl@1.0.2"></script>
|
||||
<script src="https://unpkg.com/@strudel/repl@1.2.7"></script>
|
||||
<!-- <script src="../../packages/repl/dist/index.js"></script> -->
|
||||
<strudel-editor>
|
||||
<!--
|
||||
// @date 23-08-15
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+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,6 +1,11 @@
|
||||
import jsdoc from '../../doc.json';
|
||||
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';
|
||||
|
||||
const escapeHtml = (str) => {
|
||||
const div = document.createElement('div');
|
||||
@@ -54,11 +59,12 @@ const buildExamples = (examples) =>
|
||||
`
|
||||
: '';
|
||||
|
||||
export const Autocomplete = ({ doc, label }) =>
|
||||
export const Autocomplete = (doc) =>
|
||||
h`
|
||||
<div class="autocomplete-info-container">
|
||||
<div class="autocomplete-info-tooltip">
|
||||
<h3 class="autocomplete-info-function-name">${label || getDocLabel(doc)}</h3>
|
||||
<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)}
|
||||
@@ -74,29 +80,384 @@ const isValidDoc = (doc) => {
|
||||
const hasExcludedTags = (doc) =>
|
||||
['superdirtOnly', 'noAutocomplete'].some((tag) => doc.tags?.find((t) => t.originalTitle === tag));
|
||||
|
||||
const jsdocCompletions = jsdoc.docs
|
||||
.filter((doc) => isValidDoc(doc) && !hasExcludedTags(doc))
|
||||
// https://codemirror.net/docs/ref/#autocomplete.Completion
|
||||
.map((doc) => ({
|
||||
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
|
||||
}));
|
||||
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' }));
|
||||
}
|
||||
|
||||
// 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);
|
||||
// }
|
||||
|
||||
// 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 {
|
||||
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(', '),
|
||||
};
|
||||
};
|
||||
|
||||
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) => {
|
||||
const word = context.matchBefore(/\w*/);
|
||||
if (word.from === word.to && !context.explicit) return null;
|
||||
|
||||
return {
|
||||
from: word.from,
|
||||
options: jsdocCompletions,
|
||||
/* options: [
|
||||
{ label: 'match', type: 'keyword' },
|
||||
{ label: 'hello', type: 'variable', info: '(World)' },
|
||||
{ label: 'magic', type: 'text', apply: '⠁⭒*.✩.*⭒⠁', detail: 'macro' },
|
||||
], */
|
||||
};
|
||||
for (const handler of handlers) {
|
||||
const result = handler(context);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const isAutoCompletionEnabled = (enabled) =>
|
||||
|
||||
@@ -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,31 +1,34 @@
|
||||
import { closeBrackets } from '@codemirror/autocomplete';
|
||||
export { toggleComment, toggleBlockComment, toggleLineComment, toggleBlockCommentByLine } from '@codemirror/commands';
|
||||
// import { search, highlightSelectionMatches } from '@codemirror/search';
|
||||
import { history, indentWithTab } 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';
|
||||
import { jumpToCharacter } from './labelJump.mjs';
|
||||
|
||||
const extensions = {
|
||||
export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands';
|
||||
|
||||
export const extensions = {
|
||||
isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []),
|
||||
isBracketMatchingEnabled: (on) => (on ? bracketMatching({ brackets: '()[]{}<>' }) : []),
|
||||
isBracketClosingEnabled: (on) => (on ? closeBrackets() : []),
|
||||
@@ -46,7 +49,7 @@ const extensions = {
|
||||
]
|
||||
: [],
|
||||
};
|
||||
const compartments = Object.fromEntries(Object.keys(extensions).map((key) => [key, new Compartment()]));
|
||||
export const compartments = Object.fromEntries(Object.keys(extensions).map((key) => [key, new Compartment()]));
|
||||
|
||||
export const defaultSettings = {
|
||||
keybindings: 'codemirror',
|
||||
@@ -85,13 +88,17 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
|
||||
/* search(),
|
||||
highlightSelectionMatches(), */
|
||||
...initialSettings,
|
||||
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(
|
||||
@@ -113,14 +120,22 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
|
||||
preventDefault: true,
|
||||
run: () => onStop?.(),
|
||||
},
|
||||
{
|
||||
key: 'Alt-w',
|
||||
run: (view) => jumpToCharacter(view, '$', 1),
|
||||
},
|
||||
{
|
||||
key: 'Alt-q',
|
||||
run: (view) => jumpToCharacter(view, '$', -1),
|
||||
},
|
||||
/* {
|
||||
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?.()),
|
||||
}, */
|
||||
]),
|
||||
),
|
||||
],
|
||||
@@ -201,7 +216,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
|
||||
@@ -240,6 +256,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));
|
||||
@@ -259,13 +302,23 @@ export class StrudelMirror {
|
||||
console.warn('first frame could not be painted');
|
||||
}
|
||||
}
|
||||
async evaluate() {
|
||||
async evaluate(autostart = true) {
|
||||
this.flash();
|
||||
await this.repl.evaluate(this.code);
|
||||
await this.repl.evaluate(this.code, autostart);
|
||||
}
|
||||
async stop() {
|
||||
this.repl.scheduler.stop();
|
||||
}
|
||||
|
||||
// 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();
|
||||
@@ -341,11 +394,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;
|
||||
@@ -360,7 +420,7 @@ export class StrudelMirror {
|
||||
}
|
||||
}
|
||||
|
||||
function parseBooleans(value) {
|
||||
export function parseBooleans(value) {
|
||||
return { true: true, false: false }[value] ?? value;
|
||||
}
|
||||
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from './flash.mjs';
|
||||
export * from './slider.mjs';
|
||||
export * from './themes.mjs';
|
||||
export * from './widget.mjs';
|
||||
export { Vim } from './keybindings.mjs';
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Prec } from '@codemirror/state';
|
||||
import { defaultKeymap } from '@codemirror/commands';
|
||||
import { Prec, EditorState } 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,6 +20,110 @@ 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,
|
||||
@@ -25,7 +131,13 @@ const keymaps = {
|
||||
vscode: vscodeExtension,
|
||||
};
|
||||
|
||||
export { Vim } from '@replit/codemirror-vim';
|
||||
|
||||
export function keybindings(name) {
|
||||
const active = keymaps[name];
|
||||
return [active ? active() : [], keymap.of(historyKeymap)];
|
||||
const extensions = active ? [Prec.high(active())] : [];
|
||||
if (name === 'vim') {
|
||||
extensions.push(EditorState.allowMultipleSelections.of(true));
|
||||
}
|
||||
return extensions;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { EditorSelection } from '@codemirror/state';
|
||||
import { SearchCursor } from '@codemirror/search';
|
||||
|
||||
export function jumpToCharacter(view, character, direction = 1) {
|
||||
const { state, dispatch } = view;
|
||||
const pos = state.selection.main.head;
|
||||
const cursor = new SearchCursor(state.doc, character);
|
||||
|
||||
let characterPositions = [];
|
||||
let jumpPos;
|
||||
while (!cursor.next().done) {
|
||||
characterPositions.push(cursor.value.to);
|
||||
}
|
||||
if (!characterPositions.length) {
|
||||
return false;
|
||||
}
|
||||
if (direction > 0) {
|
||||
jumpPos = characterPositions.find((x) => x > pos + 1) ?? characterPositions.at(0); // Loop back around for convenience
|
||||
} else {
|
||||
jumpPos = characterPositions.reverse().find((x) => x < pos + 1) ?? characterPositions.at(0);
|
||||
}
|
||||
|
||||
if (jumpPos == null) {
|
||||
return false;
|
||||
}
|
||||
dispatch({
|
||||
selection: EditorSelection.cursor(jumpPos - 1),
|
||||
scrollIntoView: true,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strudel/codemirror",
|
||||
"version": "1.2.3",
|
||||
"version": "1.2.7",
|
||||
"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 };
|
||||
},
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, bench } from 'vitest';
|
||||
|
||||
import { calculateSteps, rand, useRNG } from '../index.mjs';
|
||||
|
||||
const testingResolution = 128;
|
||||
|
||||
const _generateRandomPattern = () => rand.iter(testingResolution).fast(testingResolution).firstCycle();
|
||||
|
||||
describe('old random', () => {
|
||||
calculateSteps(true);
|
||||
bench(
|
||||
'+tactus',
|
||||
() => {
|
||||
useRNG('legacy');
|
||||
_generateRandomPattern();
|
||||
},
|
||||
{
|
||||
time: 1000,
|
||||
teardown() {
|
||||
useRNG('legacy');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
calculateSteps(false);
|
||||
bench(
|
||||
'-tactus',
|
||||
() => {
|
||||
useRNG('precise');
|
||||
_generateRandomPattern();
|
||||
},
|
||||
{
|
||||
time: 1000,
|
||||
teardown() {
|
||||
useRNG('legacy');
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('random', () => {
|
||||
calculateSteps(true);
|
||||
bench('+tactus', _generateRandomPattern, { time: 1000 });
|
||||
|
||||
calculateSteps(false);
|
||||
bench('-tactus', _generateRandomPattern, { time: 1000 });
|
||||
});
|
||||
|
||||
calculateSteps(true);
|
||||
+1112
-65
File diff suppressed because it is too large
Load Diff
@@ -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()) {
|
||||
|
||||
@@ -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,7 +139,7 @@ export const euclid = register('euclid', function (pulses, steps, pat) {
|
||||
return pat.struct(_euclidRot(pulses, steps, 0));
|
||||
});
|
||||
|
||||
export const e = register('e', function (euc, pat) {
|
||||
export const bjork = register('bjork', function (euc, pat) {
|
||||
if (!Array.isArray(euc)) {
|
||||
euc = [euc];
|
||||
}
|
||||
@@ -216,6 +216,6 @@ export const euclidLegatoRot = register(['euclidLegatoRot'], function (pulses, s
|
||||
* .pan(sine.slow(8))
|
||||
*/
|
||||
export const { euclidish, eish } = register(['euclidish', 'eish'], function (pulses, steps, perc, pat) {
|
||||
const morphed = _morph(bjork(pulses, steps), new Array(pulses).fill(1), perc);
|
||||
const morphed = _morph(bjorklund(pulses, steps), new Array(pulses).fill(1), perc);
|
||||
return pat.struct(morphed).setSteps(steps);
|
||||
});
|
||||
|
||||
@@ -126,6 +126,8 @@ export const lcm = (...fractions) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const isFraction = (x) => x instanceof Fraction;
|
||||
|
||||
fraction._original = Fraction;
|
||||
|
||||
export default fraction;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
stateful.mjs - File of shame for stateful, impure and otherwise illegal pattern methods
|
||||
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/index.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { register, reify, Pattern } from './pattern.mjs';
|
||||
|
||||
let timelines = {};
|
||||
|
||||
export const reset_state = function () {
|
||||
reset_timelines();
|
||||
};
|
||||
|
||||
export const reset_timelines = function () {
|
||||
timelines = {};
|
||||
};
|
||||
|
||||
/***
|
||||
* Allows you to switch a pattern between different 'timelines'. This is particularly useful when
|
||||
* live coding, for example when you want to cue a pattern up to play from its start.
|
||||
*
|
||||
* Timelines are specified by number, so that if you had a pattern like
|
||||
* `n("<0 1 2 3>").s("num").timeline(1)` playing, then changed the '1'
|
||||
* to '2', it would always align '0' to the nearest cycle. You will likely want to trigger
|
||||
* an evaluation a little bit before the cycle starts, to avoid missing events.
|
||||
*
|
||||
* After the first use, a timeline will continue with the same 'offset'. That is, if you change
|
||||
* a pattern without changing its timeline number, it will stay on that timeline without resetting.
|
||||
*
|
||||
* Rather than incrementing a timeline to reset it, it's easier to negate it, e.g. by switching between `-2`
|
||||
* and `2`. This is because when you negate a timeline it will always reset.
|
||||
*
|
||||
* You can also pattern the timeline if you want, to create strange resetting patterns.
|
||||
* @param {number | Pattern} timeline The timeline that the pattern should play on.
|
||||
* @example
|
||||
* n("<0 1 2 3>(3,8)")
|
||||
* .sound("num")
|
||||
* // resets the timeline every two cycles, by negating the timeline.
|
||||
* // in a lot of cases this will be edited by a human live coder
|
||||
* // rather than patterned!
|
||||
* .timeline("<2 -2>".slow(2))
|
||||
*/
|
||||
|
||||
export const timeline = register(
|
||||
'timeline',
|
||||
function (tpat, pat) {
|
||||
tpat = reify(tpat);
|
||||
const f = function (state) {
|
||||
// Is this called from the scheduler? (rather than from e.g. the visualiser)
|
||||
const scheduler = !!state.controls.cyclist;
|
||||
const timehaps = tpat.query(state);
|
||||
const result = [];
|
||||
for (const timehap of timehaps) {
|
||||
const tlid = timehap.value;
|
||||
let offset;
|
||||
if (tlid === 0) {
|
||||
offset = 0;
|
||||
} else if (tlid in timelines) {
|
||||
offset = timelines[tlid];
|
||||
} else {
|
||||
const timearc = timehap.wholeOrPart();
|
||||
if (!scheduler || state.span.begin.lt(timearc.midpoint())) {
|
||||
offset = timearc.begin;
|
||||
} else {
|
||||
// Sync to end of timearc if we first see it over halfway into its
|
||||
// timespan. Allows 'cuing up' next timeline when live coding.
|
||||
offset = timearc.end;
|
||||
}
|
||||
}
|
||||
if (scheduler) {
|
||||
// update state
|
||||
timelines[tlid] = offset;
|
||||
if (tlid !== 0) {
|
||||
delete timelines[-tlid];
|
||||
}
|
||||
}
|
||||
|
||||
const pathaps = pat
|
||||
.late(offset)
|
||||
.query(state.setSpan(timehap.part))
|
||||
.map((h) => h.setContext(h.combineContext(timehap)));
|
||||
result.push(...pathaps);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
return new Pattern(f, pat._steps);
|
||||
},
|
||||
false,
|
||||
);
|
||||
+13
-12
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
index.mjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/index.mjs>
|
||||
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/index.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@@ -11,20 +11,21 @@ import createClock from './zyklus.mjs';
|
||||
import { logger } from './logger.mjs';
|
||||
export { Fraction, controls, createClock };
|
||||
export * from './controls.mjs';
|
||||
export * from './hap.mjs';
|
||||
export * from './pattern.mjs';
|
||||
export * from './signal.mjs';
|
||||
export * from './pick.mjs';
|
||||
export * from './state.mjs';
|
||||
export * from './timespan.mjs';
|
||||
export * from './util.mjs';
|
||||
export * from './speak.mjs';
|
||||
export * from './evaluate.mjs';
|
||||
export * from './repl.mjs';
|
||||
export * from './cyclist.mjs';
|
||||
export * from './evaluate.mjs';
|
||||
export * from './hap.mjs';
|
||||
export * from './impure.mjs';
|
||||
export * from './logger.mjs';
|
||||
export * from './time.mjs';
|
||||
export * from './pattern.mjs';
|
||||
export * from './pick.mjs';
|
||||
export * from './repl.mjs';
|
||||
export * from './signal.mjs';
|
||||
export * from './speak.mjs';
|
||||
export * from './state.mjs';
|
||||
export * from './schedulerState.mjs';
|
||||
export * from './timespan.mjs';
|
||||
export * from './ui.mjs';
|
||||
export * from './util.mjs';
|
||||
export { default as drawLine } from './drawLine.mjs';
|
||||
// below won't work with runtime.mjs (json import fails)
|
||||
/* import * as p from './package.json';
|
||||
|
||||
@@ -5,8 +5,9 @@ let debounce = 1000,
|
||||
lastTime;
|
||||
|
||||
export function errorLogger(e, origin = 'cyclist') {
|
||||
//TODO: add some kind of debug flag that enables this while in dev mode
|
||||
// console.error(e);
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error(e);
|
||||
}
|
||||
logger(`[${origin}] error: ${e.message}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,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.3",
|
||||
"version": "1.2.6",
|
||||
"description": "Port of Tidal Cycles to JavaScript",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
@@ -31,11 +31,15 @@
|
||||
},
|
||||
"homepage": "https://strudel.cc",
|
||||
"dependencies": {
|
||||
"@kabelsalat/web": "^0.4.1",
|
||||
"fraction.js": "^5.2.1"
|
||||
},
|
||||
"gitHead": "0e26d4e741500f5bae35b023608f062a794905c2",
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.11",
|
||||
"vitest": "^3.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
+396
-22
@@ -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';
|
||||
@@ -25,7 +25,8 @@ import {
|
||||
stringifyValues,
|
||||
} from './util.mjs';
|
||||
import drawLine from './drawLine.mjs';
|
||||
import { logger } from './logger.mjs';
|
||||
import { errorLogger, logger } from './logger.mjs';
|
||||
import { strudelScope } from './evaluate.mjs';
|
||||
|
||||
let stringParser;
|
||||
|
||||
@@ -98,10 +99,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)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -417,7 +415,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 [];
|
||||
}
|
||||
}
|
||||
@@ -577,7 +575,8 @@ export class Pattern {
|
||||
* Returns a new Pattern, which only returns haps that meet the given test.
|
||||
* @param {Function} hap_test - a function which returns false for haps to be removed from the pattern
|
||||
* @returns Pattern
|
||||
* @noAutocomplete
|
||||
* @example
|
||||
* s("bd*8").velocity(rand).filterHaps((h) => (h.whole.begin % 1) < h.value.velocity)
|
||||
*/
|
||||
filterHaps(hap_test) {
|
||||
return new Pattern((state) => this.query(state).filter(hap_test));
|
||||
@@ -588,7 +587,11 @@ export class Pattern {
|
||||
* inside haps.
|
||||
* @param {Function} value_test
|
||||
* @returns Pattern
|
||||
* @noAutocomplete
|
||||
* @example
|
||||
* const drums = s("bd sd bd sd")
|
||||
* kick: drums.filterValues((v) => v.s === 'bd').duck(2)
|
||||
* snare: drums.filterValues((v) => v.s === 'sd')
|
||||
* bass: s("saw!4").note("G#1").lpf(80).lpenv(4).orbit(2)
|
||||
*/
|
||||
filterValues(value_test) {
|
||||
return new Pattern((state) => this.query(state).filter((hap) => value_test(hap.value))).setSteps(this._steps);
|
||||
@@ -999,7 +1002,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)) {
|
||||
@@ -1246,7 +1249,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
|
||||
@@ -1299,7 +1303,8 @@ 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
|
||||
@@ -1382,11 +1387,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"])
|
||||
*
|
||||
@@ -1588,7 +1593,13 @@ export const func = curry((a, b) => reify(b).func(a));
|
||||
*
|
||||
* @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
|
||||
* @param {bool} patternify defaults to true; if set to false, you will have more control over the arguments to `func` as they will be
|
||||
* in their raw form and it will be up to you to patternify them and/or query them for values
|
||||
* @example
|
||||
* const vlpf = register('vlpf', (freq, pat) => {
|
||||
* return pat.fmap((v) => ({...v, cutoff: freq * (v.velocity ?? 1) }));
|
||||
* })
|
||||
* s("saw").seg(8).velocity(rand).vlpf(800)
|
||||
*
|
||||
*/
|
||||
export function register(name, func, patternify = true, preserveSteps = false, join = (x) => x.innerJoin()) {
|
||||
@@ -1673,7 +1684,9 @@ export function register(name, func, patternify = true, preserveSteps = false, j
|
||||
|
||||
// toplevel functions get curried as well as patternified
|
||||
// because pfunc uses spread args, we need to state the arity explicitly!
|
||||
return curry(pfunc, null, arity);
|
||||
const curried = curry(pfunc, null, arity);
|
||||
strudelScope[name] = curried;
|
||||
return curried;
|
||||
}
|
||||
|
||||
// Like register, but defaults to stepJoin
|
||||
@@ -2235,7 +2248,7 @@ export const brak = register('brak', function (pat) {
|
||||
});
|
||||
|
||||
/**
|
||||
* Reverse all haps in a pattern
|
||||
* Reverse all cycles in a pattern. See also `revv` for reversing a whole pattern.
|
||||
*
|
||||
* @name rev
|
||||
* @memberof Pattern
|
||||
@@ -2267,6 +2280,23 @@ export const rev = register(
|
||||
true,
|
||||
);
|
||||
|
||||
/**
|
||||
* Reverse a whole pattern. See also `rev` for reversing each cycle.
|
||||
*
|
||||
* @name revv
|
||||
* @memberof Pattern
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* // This is the same as `<[g e] [d c]>`. If `rev()` is used, you get
|
||||
* // the same as `<[d c] [g e]>`, where each cycle reverses, but the order of
|
||||
* // cycles stays the same.
|
||||
* note("<[c d] [e g]>").revv()
|
||||
*/
|
||||
export const revv = register('revv', function (pat) {
|
||||
const negateSpan = (span) => new TimeSpan(Fraction(0).sub(span.end), Fraction(0).sub(span.begin));
|
||||
return pat.withQuerySpan(negateSpan).withHapSpan(negateSpan);
|
||||
});
|
||||
|
||||
/** Like press, but allows you to specify the amount by which each
|
||||
* event is shifted. pressBy(0.5) is the same as press, while
|
||||
* pressBy(1/3) shifts each event by a third of its timespan.
|
||||
@@ -2397,6 +2427,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
|
||||
@@ -2524,8 +2605,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'],
|
||||
@@ -2609,8 +2690,13 @@ export const hsl = register('hsl', (h, s, l, pat) => {
|
||||
/**
|
||||
* Tags each Hap with an identifier. Good for filtering. The function populates Hap.context.tags (Array).
|
||||
* @name tag
|
||||
* @noAutocomplete
|
||||
* @param {string} tag anything unique
|
||||
* @example
|
||||
* s("saw!16").note("F1")
|
||||
* .lpf(tri.range(40, 80).slow(4)).lpenv(5).lpq(4).lpd(0.15)
|
||||
* .when(rand.late(0.1).gte(0.5), x => x.transpose("12").tag('altered'))
|
||||
* .when(rand.late(0.2).gte(0.5), x => x.s("square").tag('altered'))
|
||||
* .when("<0 1>", x => x.filter((hap) => hap.hasTag('altered')))
|
||||
*/
|
||||
Pattern.prototype.tag = function (tag) {
|
||||
return this.withContext((ctx) => ({ ...ctx, tags: (ctx.tags || []).concat([tag]) }));
|
||||
@@ -2621,15 +2707,16 @@ Pattern.prototype.tag = function (tag) {
|
||||
* @name filter
|
||||
* @param {Function} test function to test Hap
|
||||
* @example
|
||||
* s("hh!7 oh").filter(hap => hap.value.s==='hh')
|
||||
* s("hh!7 oh").filter(hap => hap.value.s === 'hh')
|
||||
*/
|
||||
export const filter = register('filter', (test, pat) => pat.withHaps((haps) => haps.filter(test)));
|
||||
|
||||
/**
|
||||
* Filters haps by their begin time
|
||||
* @name filterWhen
|
||||
* @noAutocomplete
|
||||
* @param {Function} test function to test Hap.whole.begin
|
||||
* @example
|
||||
* oneCycle: s("bd*4").filterWhen((t) => t < 1)
|
||||
*/
|
||||
export const filterWhen = register('filterWhen', (test, pat) => pat.filter((h) => test(h.whole.begin)));
|
||||
|
||||
@@ -2948,6 +3035,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*
|
||||
*
|
||||
@@ -3483,3 +3588,272 @@ export const morph = (frompat, topat, bypat) => {
|
||||
bypat = reify(bypat);
|
||||
return frompat.innerBind((from) => topat.innerBind((to) => bypat.innerBind((by) => _morph(from, to, by))));
|
||||
};
|
||||
|
||||
const _distortWithAlg = function (name) {
|
||||
const func = function (args, pat) {
|
||||
const argsPat = reify(args).fmap((v) => (Array.isArray(v) ? [...v, name] : [v, 1, name]));
|
||||
if (!pat) {
|
||||
return pure({}).distort(argsPat);
|
||||
}
|
||||
return pat.distort(argsPat);
|
||||
};
|
||||
Pattern.prototype[name] = function (args) {
|
||||
return func(args, this);
|
||||
};
|
||||
return func;
|
||||
};
|
||||
|
||||
/**
|
||||
* Soft-clipping distortion
|
||||
*
|
||||
* @name soft
|
||||
* @param {number | Pattern} distortion amount of distortion to apply
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
*/
|
||||
export const soft = _distortWithAlg('soft');
|
||||
|
||||
/**
|
||||
* Hard-clipping distortion
|
||||
*
|
||||
* @name hard
|
||||
* @param {number | Pattern} distortion amount of distortion to apply
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
*/
|
||||
export const hard = _distortWithAlg('hard');
|
||||
|
||||
/**
|
||||
* Cubic polynomial distortion
|
||||
*
|
||||
* @name cubic
|
||||
* @param {number | Pattern} distortion amount of distortion to apply
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
*/
|
||||
export const cubic = _distortWithAlg('cubic');
|
||||
|
||||
/**
|
||||
* Diode-emulating distortion
|
||||
*
|
||||
* @name diode
|
||||
* @param {number | Pattern} distortion amount of distortion to apply
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
*/
|
||||
export const diode = _distortWithAlg('diode');
|
||||
|
||||
/**
|
||||
* Asymmetrical diode distortion
|
||||
*
|
||||
* @name asym
|
||||
* @param {number | Pattern} distortion amount of distortion to apply
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
*/
|
||||
export const asym = _distortWithAlg('asym');
|
||||
|
||||
/**
|
||||
* Wavefolding distortion
|
||||
*
|
||||
* @name fold
|
||||
* @param {number | Pattern} distortion amount of distortion to apply
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
*/
|
||||
export const fold = _distortWithAlg('fold');
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
*/
|
||||
export const sinefold = _distortWithAlg('sinefold');
|
||||
|
||||
/**
|
||||
* Distortion via Chebyshev polynomials
|
||||
*
|
||||
* @name chebyshev
|
||||
* @param {number | Pattern} distortion amount of distortion to apply
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
*/
|
||||
export const chebyshev = _distortWithAlg('chebyshev');
|
||||
|
||||
/**
|
||||
* 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');
|
||||
};
|
||||
|
||||
/**
|
||||
* Establishes an FX chain. Can be called by chaining .FX(fx1).FX(fx2)..
|
||||
* calls and/or in a single .FX(fx1, fx2, ..) call. The fx1, .. are _patterns_ which
|
||||
* establish the controls of the given effect. See examples.
|
||||
* @name FX
|
||||
* @memberof Pattern
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* $: s("[sbd <hh [bd | lt | oh]>]*4").dec(.4)
|
||||
* .FX(
|
||||
* phaser(0.5).gain(2),
|
||||
* bpf(800),
|
||||
* distort(1.3),
|
||||
* room(0.2),
|
||||
* delay(0.5).gain(1.25),
|
||||
* distort(0.3),
|
||||
* ).fxr(1.7) // sets release time of effects (like delay)
|
||||
* @example
|
||||
* $: s("saw").fm(0.5)
|
||||
* .delay(0.3) // outer effects are applied *last*
|
||||
* .FX(coarse(4)) // first coarse
|
||||
* .FX(lpf(500).lpe(4).lpa(1).lpd(2)) // then lpf
|
||||
* .FX(distort(1)) // then distort
|
||||
*/
|
||||
Pattern.prototype.FX = function (...effects) {
|
||||
effects = effects.map(reify);
|
||||
return this.withValue((v) => (vEff) => {
|
||||
const currFX = v.FX ?? [];
|
||||
return { ...v, FX: currFX.concat(vEff) };
|
||||
}).appLeft(parray(effects));
|
||||
};
|
||||
|
||||
const _asArrayPattern = (pats) => {
|
||||
const pack = (...xs) => xs;
|
||||
let acc = pure(curry(pack, null, pats.length));
|
||||
for (const p of pats) acc = acc.appLeft(p);
|
||||
return acc;
|
||||
};
|
||||
|
||||
/**
|
||||
* Produces a [Kabelsalat](https://kabel.salat.dev/) modular sound engine.
|
||||
* This can be used as either an effect (by including `audioin()` at the beginning
|
||||
* of your kabel expression) or as a sound source (via any expression which doesn't
|
||||
* start with `audioin()`).
|
||||
*
|
||||
* Some helpers you have available to you:
|
||||
* * Strudel mini notation works fine in K(..) via "" or ``
|
||||
* * More complex Strudel expressions (like "0 1 2".fast(4) or irand(24)) can be
|
||||
* written by wrapping them in `S(..)` inside your Kabel code
|
||||
* * We expose Strudel's note frequency under `sFreq` and Strudel's gate
|
||||
* information under `sGate`
|
||||
* * You can use more complex multi-line expressions (like `let x = a; let y = b; x.lpf(y);`)
|
||||
* by wrapping them inside a function in K (see example).
|
||||
*
|
||||
* @name K
|
||||
* @param {KabelsalatExpression | Function} expr Kabelsalat graph definition
|
||||
* @memberof Pattern
|
||||
* @returns Pattern
|
||||
*
|
||||
* @example
|
||||
* note("A c e".fast(4)).transpose("<0 2 4 6 8>")
|
||||
* .scale("F:minor").transpose("12")
|
||||
* .s("saw")
|
||||
* .K(
|
||||
* // audioin().mul(sGate.adsr(0.001, 0.3, 0, 0.2)) // as effect
|
||||
* saw(saw(sFreq / "2!3 16").mul(8).add(sFreq).lag("0!3 0.1")).mul(0.3) // as source
|
||||
* .mul(sGate.adsr(0, 0.15, 0.5, "0.1!3 1"))
|
||||
* .lpf(sGate.adsr(0, 0.2, 0.3, 0.2).mul(1).add(0))
|
||||
* .add(x => x.delay(S("0.3 0.2".fast(2))).mul(0.7))
|
||||
* .add(x => x.delay("0.03 [0.08 0.01] 0.01 0.013").mul(0.77)).mul(0.7)
|
||||
* .add(x => x.delay(.13).mul(0.7))
|
||||
* .out()
|
||||
* )
|
||||
*
|
||||
* @example
|
||||
* n("<0 1 <2 3 2 4>>*16")
|
||||
* .scale("G#2:minor").sometimes(x => x.transpose("12 | 24"))
|
||||
* .K(() => {
|
||||
* const att = S(rand.range(0, 0.05))
|
||||
* const dec = S(rand.range(0.05, 0.2))
|
||||
* let f = n(sFreq);
|
||||
* const mod = sine(f).mul("0.1 | 0.2 | 0.3")
|
||||
* .add("[[1.5 1] | 1 | 2 | 4 | [6 4@3]]*2")
|
||||
* saw(f.mul(mod))
|
||||
* .mul(sGate.ad(att, dec))
|
||||
* .add(x => x.delay(0.4).mul(0.3))
|
||||
* .out()
|
||||
* }).fxr(1).room(0.3)
|
||||
*/
|
||||
/**
|
||||
* Creates a worklet effect. Typically derived by writing K(...) in the REPL which will parse
|
||||
* Kabelsalat code.
|
||||
*
|
||||
* @name worklet
|
||||
* @param {string} src Source code of the worklet update function
|
||||
* @param {...number | ...Pattern} inputs Worklet inputs
|
||||
* @memberof Pattern
|
||||
* @returns Pattern
|
||||
* @noAutocomplete
|
||||
*/
|
||||
Pattern.prototype.worklet = function (src, ...inputs) {
|
||||
inputs = inputs.map(reify);
|
||||
return this.outerBind((v) => {
|
||||
return _asArrayPattern(inputs).withValue((vInput) => {
|
||||
const currInputs = v.workletInputs ?? [];
|
||||
return { ...v, workletSrc: src, workletInputs: currInputs.concat(vInput) };
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const worklet = (...args) => pure({}).worklet(...args);
|
||||
|
||||
+60
-10
@@ -2,9 +2,17 @@ import { NeoCyclist } from './neocyclist.mjs';
|
||||
import { Cyclist } from './cyclist.mjs';
|
||||
import { evaluate as _evaluate } from './evaluate.mjs';
|
||||
import { errorLogger, logger } from './logger.mjs';
|
||||
import { setTime } from './time.mjs';
|
||||
import {
|
||||
setCpsFunc,
|
||||
setIsStarted,
|
||||
setPattern as exposeSchedulerPattern,
|
||||
setTime,
|
||||
setTriggerFunc,
|
||||
} from './schedulerState.mjs';
|
||||
import { evalScope } from './evaluate.mjs';
|
||||
import { register, Pattern, isPattern, silence, stack } from './pattern.mjs';
|
||||
import { reset_state } from './impure.mjs';
|
||||
import { SalatRepl } from '@kabelsalat/web';
|
||||
|
||||
export function repl({
|
||||
defaultOutput,
|
||||
@@ -23,6 +31,7 @@ export function repl({
|
||||
id,
|
||||
mondo = false,
|
||||
}) {
|
||||
const kabel = new SalatRepl({ localScope: true });
|
||||
const state = {
|
||||
schedulerError: undefined,
|
||||
evalError: undefined,
|
||||
@@ -51,7 +60,11 @@ export function repl({
|
||||
getTime,
|
||||
onToggle: (started) => {
|
||||
updateState({ started });
|
||||
setIsStarted(started);
|
||||
onToggle?.(started);
|
||||
if (!started) {
|
||||
reset_state();
|
||||
}
|
||||
},
|
||||
setInterval,
|
||||
clearInterval,
|
||||
@@ -61,6 +74,8 @@ export function repl({
|
||||
// NeoCyclist uses a shared worker to communicate between instances, which is not supported on mobile chrome
|
||||
const scheduler =
|
||||
sync && typeof SharedWorker != 'undefined' ? new NeoCyclist(schedulerOptions) : new Cyclist(schedulerOptions);
|
||||
setTriggerFunc(schedulerOptions.onTrigger);
|
||||
setCpsFunc(() => scheduler.cps);
|
||||
let pPatterns = {};
|
||||
let anonymousIndex = 0;
|
||||
let allTransform;
|
||||
@@ -74,9 +89,23 @@ export function repl({
|
||||
return silence;
|
||||
};
|
||||
|
||||
const compileKabel = (code) => {
|
||||
const node = kabel.evaluate(code);
|
||||
return node.compile({ log: false });
|
||||
};
|
||||
|
||||
// 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);
|
||||
exposeSchedulerPattern(pattern);
|
||||
return pattern;
|
||||
};
|
||||
setTime(() => scheduler.now()); // TODO: refactor?
|
||||
@@ -85,7 +114,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
|
||||
@@ -97,7 +129,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`..
|
||||
|
||||
@@ -138,9 +173,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;
|
||||
@@ -180,6 +215,7 @@ export function repl({
|
||||
setcps: setCps,
|
||||
setCpm,
|
||||
setcpm: setCpm,
|
||||
compileKabel,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -200,7 +236,21 @@ export function repl({
|
||||
}
|
||||
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));
|
||||
@@ -210,13 +260,13 @@ export function repl({
|
||||
pattern = eachTransform(pattern);
|
||||
}
|
||||
if (allTransforms.length) {
|
||||
for (let i in allTransforms) {
|
||||
pattern = allTransforms[i](pattern);
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
schedulerState.mjs - Module to pipe out various parameters from the scheduler for global consumption
|
||||
Copyright (C) 2026 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/schedulerState.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
let time;
|
||||
let cpsFunc;
|
||||
let pattern;
|
||||
let triggerFunc;
|
||||
let isStarted;
|
||||
export function getTime() {
|
||||
if (!time) {
|
||||
throw new Error('no time set! use setTime to define a time source');
|
||||
}
|
||||
return time();
|
||||
}
|
||||
|
||||
export function setTime(func) {
|
||||
time = func;
|
||||
}
|
||||
|
||||
export function setCpsFunc(func) {
|
||||
cpsFunc = func;
|
||||
}
|
||||
|
||||
export function getCps() {
|
||||
return cpsFunc?.();
|
||||
}
|
||||
|
||||
export function setPattern(pat) {
|
||||
pattern = pat;
|
||||
}
|
||||
|
||||
export function getPattern() {
|
||||
return pattern;
|
||||
}
|
||||
|
||||
export function setTriggerFunc(func) {
|
||||
triggerFunc = func;
|
||||
}
|
||||
|
||||
export function getTriggerFunc() {
|
||||
return triggerFunc;
|
||||
}
|
||||
|
||||
export function setIsStarted(val) {
|
||||
isStarted = !!val;
|
||||
}
|
||||
|
||||
export function getIsStarted() {
|
||||
return isStarted;
|
||||
}
|
||||
+218
-40
@@ -16,7 +16,7 @@ export function steady(value) {
|
||||
}
|
||||
|
||||
export const signal = (func) => {
|
||||
const query = (state) => [new Hap(undefined, state.span, func(state.span.begin))];
|
||||
const query = (state) => [new Hap(undefined, state.span, func(state.span.begin, state.controls))];
|
||||
return new Pattern(query);
|
||||
};
|
||||
|
||||
@@ -186,38 +186,97 @@ export const mouseY = signal(() => _mouseY);
|
||||
export const mousex = signal(() => _mouseX);
|
||||
export const mouseX = signal(() => _mouseX);
|
||||
|
||||
// random signals
|
||||
// Random number generators
|
||||
|
||||
const xorwise = (x) => {
|
||||
// Produce "Avalanche effect" where flipping a single bit of x
|
||||
// results in all output bits flipping with probability 0.5
|
||||
// See e.g. https://github.com/aappleby/smhasher/blob/0ff96f7835817a27d0487325b6c16033e2992eb5/src/MurmurHash3.cpp#L68-L77
|
||||
const _murmurHashFinalizer = (x) => {
|
||||
x |= 0;
|
||||
x ^= x >>> 16;
|
||||
x = Math.imul(x, 0x85ebca6b);
|
||||
x ^= x >>> 13;
|
||||
x = Math.imul(x, 0xc2b2ae35);
|
||||
x ^= x >>> 16;
|
||||
return x >>> 0; // unsigned
|
||||
};
|
||||
|
||||
// Convert t to a 32 bit integer, preserving temporal resolution down to 1/2^29
|
||||
const _tToT = (t) => {
|
||||
return Math.floor(t * 536870912);
|
||||
};
|
||||
|
||||
// Used to decorrelate nearby T, i, and seed prior to hashing
|
||||
const _decorrelate = (T, i = 0, seed = 0) => {
|
||||
const lowBits = (T >>> 0) >>> 0;
|
||||
const highBits = Math.floor(T / 4294967296) >>> 0; // 2^32
|
||||
let key = lowBits ^ Math.imul(highBits ^ 0x85ebca6b, 0xc2b2ae35);
|
||||
key ^= Math.imul(i ^ 0x7f4a7c15, 0x9e3779b9);
|
||||
key ^= Math.imul(seed ^ 0x165667b1, 0x27d4eb2d);
|
||||
return key >>> 0;
|
||||
};
|
||||
|
||||
const randAt = (T, i = 0, seed = 0) => {
|
||||
return _murmurHashFinalizer(_decorrelate(T, i, seed)) / 4294967296; // 2^32
|
||||
};
|
||||
|
||||
// n samples at time t
|
||||
const timeToRands = (t, n, seed = 0) => {
|
||||
const T = _tToT(t);
|
||||
if (n === 1) {
|
||||
return randAt(T, 0, seed);
|
||||
}
|
||||
const out = new Array(n);
|
||||
for (let i = 0; i < n; i++) out[i] = randAt(T, i, seed);
|
||||
return out;
|
||||
};
|
||||
|
||||
// Old random signals. Currently the default, but can also be chosen via
|
||||
// `useRNG('legacy')`
|
||||
|
||||
// stretch 300 cycles over the range of [0,2**29 == 536870912) then apply the xorshift algorithm
|
||||
const __xorwise = (x) => {
|
||||
const a = (x << 13) ^ x;
|
||||
const b = (a >> 17) ^ a;
|
||||
return (b << 5) ^ b;
|
||||
};
|
||||
|
||||
// stretch 300 cycles over the range of [0,2**29 == 536870912) then apply the xorshift algorithm
|
||||
const _frac = (x) => x - Math.trunc(x);
|
||||
|
||||
const timeToIntSeed = (x) => xorwise(Math.trunc(_frac(x / 300) * 536870912));
|
||||
|
||||
const intSeedToRand = (x) => (x % 536870912) / 536870912;
|
||||
|
||||
const timeToRand = (x) => Math.abs(intSeedToRand(timeToIntSeed(x)));
|
||||
|
||||
const timeToRandsPrime = (seed, n) => {
|
||||
const __frac = (x) => x - Math.trunc(x);
|
||||
const __timeToIntSeed = (x) => __xorwise(Math.trunc(__frac(x / 300) * 536870912));
|
||||
const __intSeedToRand = (x) => (x % 536870912) / 536870912;
|
||||
const __timeToRandsPrime = (seed, n) => {
|
||||
if (n === 1) {
|
||||
return Math.abs(__intSeedToRand(seed));
|
||||
}
|
||||
const result = [];
|
||||
// eslint-disable-next-line
|
||||
for (let i = 0; i < n; ++i) {
|
||||
result.push(intSeedToRand(seed));
|
||||
seed = xorwise(seed);
|
||||
for (let i = 0; i < n; i++) {
|
||||
result.push(__intSeedToRand(seed));
|
||||
seed = __xorwise(seed);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const __timeToRands = (t, n) => __timeToRandsPrime(__timeToIntSeed(t), n);
|
||||
|
||||
const timeToRands = (t, n) => timeToRandsPrime(timeToIntSeed(t), n);
|
||||
// End old random
|
||||
|
||||
let RNG_MODE = 'legacy';
|
||||
export const getRandsAtTime = (t, n = 1, seed = 0) => {
|
||||
return RNG_MODE === 'legacy' ? __timeToRands(t + seed, n) : timeToRands(t, n, seed);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets which random number generator to use. Historically Strudel would
|
||||
* use `useRNG('legacy')`, which remains the default. To use a new more statistically
|
||||
* precise RNG, try `useRNG('precise')`.
|
||||
*
|
||||
* @name useRNG
|
||||
* @param {string} mod - Mode. One of 'legacy', 'precise'
|
||||
* @example
|
||||
* useRNG('legacy')
|
||||
* // Repeats every 300 cycles
|
||||
* $: n(irand(50)).seg(16).scale("C:minor").ribbon(88, 32)
|
||||
* $: n(irand(50)).seg(16).scale("C:minor").ribbon(388, 32)
|
||||
*/
|
||||
export const useRNG = (mode = 'legacy') => (RNG_MODE = mode);
|
||||
|
||||
/**
|
||||
* A discrete pattern of numbers from 0 to n-1
|
||||
@@ -228,7 +287,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
|
||||
@@ -242,7 +301,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
|
||||
@@ -258,10 +317,55 @@ 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) => getRandsAtTime(t, nVal).map(Math.abs)).appLeft(reify(n));
|
||||
};
|
||||
|
||||
export const randrun = (n) => {
|
||||
return signal((t) => {
|
||||
return signal((t, controls) => {
|
||||
// Without adding 0.5, the first cycle is always 0,1,2,3,...
|
||||
const rands = timeToRands(t.floor().add(0.5), n);
|
||||
const rands = getRandsAtTime(t.floor().add(0.5), n, controls.randSeed);
|
||||
const nums = rands
|
||||
.map((n, i) => [n, i])
|
||||
.sort((a, b) => (a[0] > b[0]) - (a[0] < b[0]))
|
||||
@@ -302,6 +406,37 @@ export const scramble = register('scramble', (n, pat) => {
|
||||
return _rearrangeWith(_irand(n)._segment(n), n, pat);
|
||||
});
|
||||
|
||||
/**
|
||||
* Modify a pattern by applying a function to the `randomSeed` control if present
|
||||
*
|
||||
* @param {Function} func Function from seed (or undefined) to seed (or undefined)
|
||||
* @param {Pattern} pat Pattern to update
|
||||
* @returns Pattern
|
||||
*/
|
||||
export const withSeed = (func, pat) => {
|
||||
return new Pattern((state) => {
|
||||
let { randSeed, ...controls } = state.controls;
|
||||
randSeed = func(randSeed);
|
||||
return pat.query(state.setControls({ ...controls, randSeed }));
|
||||
}, pat._steps);
|
||||
};
|
||||
|
||||
/**
|
||||
* Change the seed for random signals. Normally, random signals depend on time,
|
||||
* so two patterns at the same time will have the same random values. Specifying
|
||||
* a new seed changes the signal output by `rand`. This also affects other functions
|
||||
* that use randomness, like `shuffle` and `sometimes`.
|
||||
*
|
||||
* @name seed
|
||||
* @param {number} n A new seed. Can be any number.
|
||||
* @example
|
||||
* $: s("hh*4").degrade();
|
||||
* $: s("bd*4").degrade().seed(1); // Will degrade different events from the hi-hat
|
||||
*/
|
||||
export const seed = register('seed', (n, pat) => {
|
||||
return withSeed(() => n, pat);
|
||||
});
|
||||
|
||||
/**
|
||||
* A continuous pattern of random numbers, between 0 and 1.
|
||||
*
|
||||
@@ -311,7 +446,7 @@ export const scramble = register('scramble', (n, pat) => {
|
||||
* s("bd*4,hh*8").cutoff(rand.range(500,8000))
|
||||
*
|
||||
*/
|
||||
export const rand = signal(timeToRand);
|
||||
export const rand = signal((t, controls) => getRandsAtTime(t, 1, controls.randSeed));
|
||||
/**
|
||||
* A continuous pattern of random numbers, between -1 and 1
|
||||
*/
|
||||
@@ -479,7 +614,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()
|
||||
@@ -488,36 +623,32 @@ export const wchooseCycles = (...pairs) => _wchooseWith(rand.segment(1), ...pair
|
||||
|
||||
export const wrandcat = wchooseCycles;
|
||||
|
||||
function _perlin(t) {
|
||||
function _perlin(t, seed = 0) {
|
||||
let ta = Math.floor(t);
|
||||
let tb = ta + 1;
|
||||
const smootherStep = (x) => 6.0 * x ** 5 - 15.0 * x ** 4 + 10.0 * x ** 3;
|
||||
const interp = (x) => (a) => (b) => a + smootherStep(x) * (b - a);
|
||||
const v = interp(t - ta)(timeToRand(ta))(timeToRand(tb));
|
||||
const ra = getRandsAtTime(ta, 1, seed);
|
||||
const rb = getRandsAtTime(tb, 1, seed);
|
||||
const v = interp(t - ta)(ra)(rb);
|
||||
return v;
|
||||
}
|
||||
export const perlinWith = (tpat) => {
|
||||
return tpat.fmap(_perlin);
|
||||
};
|
||||
|
||||
function _berlin(t) {
|
||||
function _berlin(t, seed = 0) {
|
||||
const prevRidgeStartIndex = Math.floor(t);
|
||||
const nextRidgeStartIndex = prevRidgeStartIndex + 1;
|
||||
|
||||
const prevRidgeBottomPoint = timeToRand(prevRidgeStartIndex);
|
||||
const nextRidgeTopPoint = timeToRand(nextRidgeStartIndex) + prevRidgeBottomPoint;
|
||||
const prevRidgeBottomPoint = getRandsAtTime(prevRidgeStartIndex, 1, seed);
|
||||
const height = getRandsAtTime(nextRidgeStartIndex, 1, seed);
|
||||
const nextRidgeTopPoint = prevRidgeBottomPoint + height;
|
||||
|
||||
const currentPercent = (t - prevRidgeStartIndex) / (nextRidgeStartIndex - prevRidgeStartIndex);
|
||||
const interp = (a, b, t) => {
|
||||
return a + (b - a) * t;
|
||||
return a + t * (b - a);
|
||||
};
|
||||
return interp(prevRidgeBottomPoint, nextRidgeTopPoint, currentPercent) / 2;
|
||||
}
|
||||
|
||||
export const berlinWith = (tpat) => {
|
||||
return tpat.fmap(_berlin);
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a continuous pattern of [perlin noise](https://en.wikipedia.org/wiki/Perlin_noise), in the range 0..1.
|
||||
*
|
||||
@@ -527,7 +658,7 @@ export const berlinWith = (tpat) => {
|
||||
* s("bd*4,hh*8").cutoff(perlin.range(500,8000))
|
||||
*
|
||||
*/
|
||||
export const perlin = perlinWith(time.fmap((v) => Number(v)));
|
||||
export const perlin = signal((t, controls) => _perlin(t, controls.randSeed));
|
||||
|
||||
/**
|
||||
* Generates a continuous pattern of [berlin noise](conceived by Jame Coyne and Jade Rowland as a joke but turned out to be surprisingly cool and useful,
|
||||
@@ -539,7 +670,7 @@ export const perlin = perlinWith(time.fmap((v) => Number(v)));
|
||||
* n("0!16".add(berlin.fast(4).mul(14))).scale("d:minor")
|
||||
*
|
||||
*/
|
||||
export const berlin = berlinWith(time.fmap((v) => Number(v)));
|
||||
export const berlin = signal((t, controls) => _berlin(t, controls.randSeed));
|
||||
|
||||
export const degradeByWith = register(
|
||||
'degradeByWith',
|
||||
@@ -837,3 +968,50 @@ export const whenKey = register('whenKey', function (input, func, pat) {
|
||||
export const keyDown = register('keyDown', function (pat) {
|
||||
return pat.fmap(_keyDown);
|
||||
});
|
||||
|
||||
/**
|
||||
* A pattern measuring the duration of events,
|
||||
* in cycles per event. `cyclesPer` doesn't have structure itself, but takes structure, and therefore
|
||||
* event durations, from the pattern that it is combined with.
|
||||
* For example `cyclesPer.struct("1 1 [1 1] 1")` would give the same as `"0.25 0.25 [0.125 0.125] 0.25"`.
|
||||
* See also its reciprocal, `per`, also known as `perCycle`.
|
||||
* @example
|
||||
* // Shorter events are lower in pitch
|
||||
* sound("saw saw [saw saw] saw")
|
||||
* .note(cyclesPer.range(50, 100))
|
||||
* @example
|
||||
* sound("bd sd [bd bd] sd*4 [- sd] [bd [bd bd]]")
|
||||
* .note(cyclesPer.add(20))
|
||||
*/
|
||||
export const cyclesPer = new Pattern(function (state) {
|
||||
return [new Hap(undefined, state.span, state.span.duration)];
|
||||
});
|
||||
|
||||
/**
|
||||
* A pattern measuring the 'shortness' of events, or in other words, the duration of pattern events,
|
||||
* in events per cycle. `per` doesn't have structure itself, but takes structure, and therefore
|
||||
* event durations, from the pattern that it is combined with.
|
||||
* For example `per.struct("1 1 [1 1] 1")` would give the same as `"4 4 [8 8] 4"`.
|
||||
* See also its reciprocal, `cyclesPer`.
|
||||
* @synonyms perCycle
|
||||
* @example
|
||||
* // Shorter events are more distorted
|
||||
* n("0 0*2 0 0*2 0 [0 0 0]@2").sound("bd")
|
||||
* .distort(per.div(2))
|
||||
*/
|
||||
export const per = new Pattern(function (state) {
|
||||
return [new Hap(undefined, state.span, Fraction(1).div(state.span.duration))];
|
||||
});
|
||||
|
||||
export const perCycle = per;
|
||||
|
||||
/**
|
||||
* Like `per` but measures the shortness of events according to an exponential curve. In
|
||||
* particular, where the event duration halves, the
|
||||
* returned value increases by one. `perx.struct("1 1 [1 [1 1]] 1")` would therefore be
|
||||
* the same as `"3 3 [4 [5 5]] 3"`.
|
||||
*/
|
||||
export const perx = new Pattern(function (state) {
|
||||
const n = Fraction(1).div(state.span.duration);
|
||||
return [new Hap(undefined, state.span, Math.log(n) / Math.log(2) + 1)];
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -586,6 +586,18 @@ describe('Pattern', () => {
|
||||
.map((a) => a.value),
|
||||
).toStrictEqual(['c', 'b', 'a']);
|
||||
});
|
||||
it('Does not reverse the order of cycles', () => {
|
||||
expect(fastcat('a', 'b', 'c', 'd').slow(2).rev().fast(2).sortHapsByPart().firstCycle()).toStrictEqual(
|
||||
fastcat('b', 'a', 'd', 'c').firstCycle(),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('revv()', () => {
|
||||
it('Does reverse the order of cycles', () => {
|
||||
expect(fastcat('a', 'b', 'c', 'd').slow(2).revv().fast(2).sortHapsByPart().firstCycle()).toStrictEqual(
|
||||
fastcat('d', 'c', 'b', 'a').firstCycle(),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('sequence()', () => {
|
||||
it('Can work like fastcat', () => {
|
||||
@@ -728,20 +740,6 @@ describe('Pattern', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('signal()', () => {
|
||||
it('Can make saw/saw2', () => {
|
||||
expect(saw.struct(true, true, true, true).firstCycle()).toStrictEqual(
|
||||
sequence(0, 1 / 4, 1 / 2, 3 / 4).firstCycle(),
|
||||
);
|
||||
|
||||
expect(saw2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(-1, -0.5, 0, 0.5).firstCycle());
|
||||
});
|
||||
it('Can make isaw/isaw2', () => {
|
||||
expect(isaw.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.75, 0.5, 0.25).firstCycle());
|
||||
|
||||
expect(isaw2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.5, 0, -0.5).firstCycle());
|
||||
});
|
||||
});
|
||||
describe('_setContext()', () => {
|
||||
it('Can set the hap context', () => {
|
||||
expect(
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
signal.test.mjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/test/pattern.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 Fraction from 'fraction.js';
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import { saw, saw2, isaw, isaw2, per, perx, cyclesPer } from '../signal.mjs';
|
||||
import { fastcat, sequence, State, TimeSpan, Hap } from '../index.mjs';
|
||||
|
||||
const st = (begin, end) => new State(ts(begin, end));
|
||||
const ts = (begin, end) => new TimeSpan(Fraction(begin), Fraction(end));
|
||||
const hap = (whole, part, value, context = {}) => new Hap(whole, part, value, context);
|
||||
|
||||
const third = Fraction(1, 3);
|
||||
const twothirds = Fraction(2, 3);
|
||||
|
||||
const sameFirst = (a, b) => {
|
||||
return expect(a.sortHapsByPart().firstCycle()).toStrictEqual(b.sortHapsByPart().firstCycle());
|
||||
};
|
||||
|
||||
describe('signal()', () => {
|
||||
it('Can make saw/saw2', () => {
|
||||
expect(saw.struct(true, true, true, true).firstCycle()).toStrictEqual(
|
||||
sequence(0, 1 / 4, 1 / 2, 3 / 4).firstCycle(),
|
||||
);
|
||||
|
||||
expect(saw2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(-1, -0.5, 0, 0.5).firstCycle());
|
||||
});
|
||||
it('Can make isaw/isaw2', () => {
|
||||
expect(isaw.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.75, 0.5, 0.25).firstCycle());
|
||||
|
||||
expect(isaw2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.5, 0, -0.5).firstCycle());
|
||||
});
|
||||
});
|
||||
|
||||
describe('cyclesPer', () => {
|
||||
it('gives cycles per hap', () => {
|
||||
sameFirst(
|
||||
cyclesPer.struct(true, true, true, fastcat(true, true)),
|
||||
sequence(0.25, 0.25, 0.25, fastcat(0.125, 0.125)).fmap(Fraction),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('per', () => {
|
||||
it('gives haps per cycle', () => {
|
||||
sameFirst(per.struct(true, true, true, fastcat(true, true)), sequence(4, 4, 4, fastcat(8, 8)).fmap(Fraction));
|
||||
});
|
||||
});
|
||||
|
||||
describe('perx', () => {
|
||||
it('gives exponential haps per cycle', () => {
|
||||
sameFirst(
|
||||
perx.struct(true, true, true, fastcat(true, fastcat(true, true))),
|
||||
sequence(3, 3, 3, fastcat(4, fastcat(5, 5))),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
let time;
|
||||
export function getTime() {
|
||||
if (!time) {
|
||||
throw new Error('no time set! use setTime to define a time source');
|
||||
}
|
||||
return time();
|
||||
}
|
||||
|
||||
export function setTime(func) {
|
||||
time = func;
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strudel/csound",
|
||||
"version": "1.2.4",
|
||||
"version": "1.2.7",
|
||||
"description": "csound bindings for strudel",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
@@ -38,5 +38,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strudel/draw",
|
||||
"version": "1.2.3",
|
||||
"version": "1.2.6",
|
||||
"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.1",
|
||||
"version": "1.1.2",
|
||||
"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.3",
|
||||
"version": "1.2.6",
|
||||
"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.3",
|
||||
"version": "1.2.6",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
+248
-63
@@ -5,9 +5,24 @@ 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 {
|
||||
Hap,
|
||||
Pattern,
|
||||
TimeSpan,
|
||||
getCps,
|
||||
getIsStarted,
|
||||
getPattern,
|
||||
getTime,
|
||||
getTriggerFunc,
|
||||
isPattern,
|
||||
logger,
|
||||
ref,
|
||||
reify,
|
||||
} from '@strudel/core';
|
||||
import { noteToMidi, getControlName } from '@strudel/core';
|
||||
import { Note } from 'webmidi';
|
||||
import { getAudioContext } from '@strudel/webaudio';
|
||||
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 +205,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 +213,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 +244,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 +258,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 +295,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 +336,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 +359,13 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
||||
logger(`Midi device disconnected! Available: ${getMidiDeviceNamesString(outputs)}`),
|
||||
});
|
||||
|
||||
return this.onTrigger((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 +401,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 +413,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,74 +428,76 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let listeners = {};
|
||||
const refs = {};
|
||||
|
||||
/**
|
||||
* MIDI input: Opens a MIDI input port to receive MIDI control change messages.
|
||||
* @param {string | number} input MIDI device name or index defaulting to 0
|
||||
* @returns {Function}
|
||||
* @example
|
||||
* let cc = await midin('IAC Driver Bus 1')
|
||||
* note("c a f e").lpf(cc(0).range(0, 1000)).lpq(cc(1).range(0, 10)).sound("sawtooth")
|
||||
* Initialize a midi device
|
||||
*/
|
||||
export async function midin(input) {
|
||||
async function _initialize(input) {
|
||||
if (isPattern(input)) {
|
||||
throw new Error(
|
||||
`midin: does not accept Pattern as input. Make sure to pass device name with single quotes. Example: midin('${
|
||||
`[midi] Midi input cannot be a pattern. Make sure to pass device name with single quotes. Example: midin('${
|
||||
WebMidi.outputs?.[0]?.name || 'IAC Driver Bus 1'
|
||||
}')`,
|
||||
);
|
||||
@@ -483,29 +506,191 @@ export async function midin(input) {
|
||||
const device = getDevice(input, WebMidi.inputs);
|
||||
if (!device) {
|
||||
throw new Error(
|
||||
`midiin: device "${input}" not found.. connected devices: ${getMidiDeviceNamesString(WebMidi.inputs)}`,
|
||||
`[midi] Midi device "${input}" not found.. connected devices: ${getMidiDeviceNamesString(WebMidi.inputs)}`,
|
||||
);
|
||||
}
|
||||
if (initial) {
|
||||
const otherInputs = WebMidi.inputs.filter((o) => o.name !== device.name);
|
||||
logger(
|
||||
`Midi enabled! Using "${device.name}". ${
|
||||
`[midi] Midi enabled! Using "${device.name}". ${
|
||||
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);
|
||||
return device;
|
||||
}
|
||||
|
||||
/**
|
||||
* MIDI input: Opens a MIDI input port to receive MIDI control change messages.
|
||||
*
|
||||
* The output is a function that accepts a midi cc value to query as well as (optionally) a midi channel
|
||||
*
|
||||
* @name midin
|
||||
* @param {string | number} input MIDI device name or index defaulting to 0
|
||||
* @returns {function(number, number=): Pattern} A function from (cc, channel?) to a pattern.
|
||||
* When queried, the pattern will produces the most recently received midi value (normalized to 0 to 1)
|
||||
* that came through that cc number (and channel, if provided)
|
||||
* @example
|
||||
* const cc = await midin('IAC Driver Bus 1')
|
||||
* note("c a f e").lpf(cc(0).range(0, 1000)).lpq(cc(1).range(0, 10)).sound("sawtooth")
|
||||
* @example
|
||||
* const allCC = await midin('IAC Driver Bus 1')
|
||||
* const cc = (ccNum) => allCC(ccNum, 2) // just channel 2
|
||||
* note("c a f e").s("saw")
|
||||
* .when(cc(0).gt(0), x => x.postgain(0))
|
||||
*/
|
||||
let listeners = {};
|
||||
const refs = {};
|
||||
const refsByChan = {};
|
||||
export async function midin(input) {
|
||||
const device = await _initialize(input);
|
||||
refs[input] ??= {};
|
||||
refsByChan[input] ??= {};
|
||||
const cc = (cc, chan) => {
|
||||
if (chan !== undefined) {
|
||||
return ref(() => refsByChan[input][cc]?.[chan] || 0);
|
||||
}
|
||||
return ref(() => refs[input][cc] || 0);
|
||||
};
|
||||
|
||||
listeners[input] && device.removeListener('midimessage', listeners[input]);
|
||||
listeners[input] = (e) => {
|
||||
const cc = e.dataBytes[0];
|
||||
const v = e.dataBytes[1];
|
||||
refs[input] && (refs[input][cc] = v / 127);
|
||||
const [ccNum, v] = e.dataBytes;
|
||||
const chan = e.message.channel;
|
||||
const scaled = v / 127;
|
||||
refsByChan[input][ccNum] ??= {};
|
||||
refsByChan[input][ccNum][chan] = scaled;
|
||||
refs[input][ccNum] = scaled;
|
||||
};
|
||||
device.addListener('midimessage', listeners[input]);
|
||||
return cc;
|
||||
}
|
||||
|
||||
/**
|
||||
* MIDI keyboard: Opens a MIDI input port to receive MIDI keyboard messages.
|
||||
*
|
||||
* The note length is fixed as Superdough is not currently set up for undetermined
|
||||
* note durations
|
||||
*
|
||||
* @name midikeys
|
||||
* @param {string | number} input MIDI device name or index defaulting to 0
|
||||
* @returns {function((number | Pattern)=): Pattern} A function that produces a pattern.
|
||||
* When queried, the pattern will produces the most recently played midi notes and velocities,
|
||||
* lasting for the specified duration
|
||||
* @example
|
||||
* const kb = await midikeys('Arturia KeyStep 32')
|
||||
* kb().s("tri").lpf(80).lpe(6).lpd(0.1).room(2).delay(0.35)
|
||||
* @example
|
||||
* const kb = await midikeys('Arturia KeyStep 32')
|
||||
* kb("0.5 1")
|
||||
* .s("saw")
|
||||
* .add(note(rand.mul(0.3)))
|
||||
* .lpf(1000).lpe(2).room(0.5)
|
||||
*/
|
||||
const kHaps = {};
|
||||
const kListeners = {};
|
||||
|
||||
function _triggerKeyboard(input, cps, now, latencyCycles) {
|
||||
const pattern = getPattern();
|
||||
const trigger = getTriggerFunc();
|
||||
if (!pattern || !trigger) {
|
||||
return false;
|
||||
}
|
||||
const t = now + latencyCycles;
|
||||
const eps = 1e-6;
|
||||
const haps = pattern.queryArc(t - eps, t + eps, { _cps: cps });
|
||||
// Only keep haps coming from `midikeys`
|
||||
const kbHaps = haps.filter((hap) => hap.value?.midikey?.startsWith(`${input}_`));
|
||||
const ctxNow = getAudioContext().currentTime;
|
||||
if (!kbHaps.length) {
|
||||
return false;
|
||||
}
|
||||
kbHaps.forEach((hap) => {
|
||||
if (!hap.hasOnset()) {
|
||||
return;
|
||||
}
|
||||
const t = ctxNow + (hap.whole.begin - now) / cps;
|
||||
const duration = hap.duration / cps;
|
||||
trigger(hap, t - ctxNow, duration, cps, t);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
export async function midikeys(input) {
|
||||
const device = await _initialize(input);
|
||||
if (!kHaps[input]) {
|
||||
kHaps[input] = [];
|
||||
}
|
||||
kListeners[input] && device.removeListener('midimessage', kListeners[input]);
|
||||
kListeners[input] = (e) => {
|
||||
const { dataBytes, message } = e;
|
||||
const noteon = message.command === 9;
|
||||
let noteoff = message.command === 8;
|
||||
// Don't enqueue or trigger midi notes if scheduler is not started
|
||||
const notStarted = !getIsStarted();
|
||||
// Ignore non-note messages (e.g. CC, pitchbend, modwheel, etc.)
|
||||
const notANote = !noteon && !noteoff;
|
||||
if (notStarted || notANote) {
|
||||
return;
|
||||
}
|
||||
const [note, velocity] = dataBytes;
|
||||
noteoff ||= noteon && velocity === 0; // handle devices which may use velocity = 0 to signal noteoff
|
||||
const key = `${input}_${note}`;
|
||||
const cps = getCps() ?? 0.5;
|
||||
const triggerAvailable = !!(getPattern() && getTriggerFunc());
|
||||
const latencySeconds = triggerAvailable ? 0.01 : 0.06; // avoid missing notes due to cyclist / trigger latency
|
||||
const now = getTime();
|
||||
const t = now + latencySeconds * cps;
|
||||
const span = new TimeSpan(t, t);
|
||||
let value = { midikey: key };
|
||||
if (noteoff) {
|
||||
/* TODO: It's a big effort, but we could modify superdough to allow for situations where
|
||||
we don't know the hap duration in advance. This would mean, for example, that if the hap
|
||||
is flagged as such a special note-on event, we have all effects be persistent & all ADSR
|
||||
envelopes stop at the S stage [and store references to them by `midikey`]
|
||||
If this is implemented, then getting full keyboard functionality should be as simple
|
||||
as sending the corresponding note-off event below and triggering `release` on each of those
|
||||
referenced effects/envelopes
|
||||
|
||||
value = { ...value, noteoff: true };
|
||||
|
||||
If this is achieved, we can remove the noteLength parameter
|
||||
*/
|
||||
return;
|
||||
} else {
|
||||
value = { ...value, note: Math.round(note), velocity: velocity / 127 };
|
||||
}
|
||||
kHaps[input].push(new Hap(span, span, value, {}));
|
||||
if (!noteoff && triggerAvailable) {
|
||||
// If we have access to a trigger function, we call it to immediately
|
||||
// dispatch to the audio engine, rather than waiting for cyclist to catch these haps
|
||||
const triggered = _triggerKeyboard(input, cps, now, latencySeconds * cps);
|
||||
if (triggered) {
|
||||
kHaps[input] = [];
|
||||
}
|
||||
}
|
||||
};
|
||||
device.addListener('midimessage', kListeners[input]);
|
||||
const kb = (noteLength = 0.5) => {
|
||||
const nlPat = reify(noteLength);
|
||||
const query = (state) => {
|
||||
const haps = kHaps[input].flatMap((hap) => {
|
||||
const lenHaps = nlPat.query(state.setSpan(hap.wholeOrPart()));
|
||||
return lenHaps.map((lenHap) => {
|
||||
const nl = lenHap.value ?? 0.5;
|
||||
const whole = new TimeSpan(hap.whole.begin, hap.whole.begin.add(nl));
|
||||
const part = new TimeSpan(hap.part.begin, hap.part.begin.add(nl));
|
||||
const context = hap.combineContext(lenHap);
|
||||
return new Hap(whole, part, hap.value, context);
|
||||
});
|
||||
});
|
||||
if (state.controls.cyclist) {
|
||||
// Notes have been sent; clear them
|
||||
kHaps[input] = [];
|
||||
}
|
||||
return haps;
|
||||
};
|
||||
return new Pattern(query);
|
||||
};
|
||||
return kb;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strudel/midi",
|
||||
"version": "1.2.4",
|
||||
"version": "1.2.7",
|
||||
"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,6 +1,6 @@
|
||||
{
|
||||
"name": "@strudel/mini",
|
||||
"version": "1.2.3",
|
||||
"version": "1.2.6",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,13 +21,14 @@ export class MondoParser {
|
||||
close_curly: /^\}/,
|
||||
number: /^-?[0-9]*\.?[0-9]+/, // before pipe!
|
||||
// TODO: better error handling when "-" is used as rest, e.g "s [- bd]"
|
||||
op: /^[*/:!@%?+-]|^\.{2}/, // * / : ! @ % ? ..
|
||||
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) {
|
||||
@@ -150,9 +151,9 @@ export class MondoParser {
|
||||
}
|
||||
return children;
|
||||
}
|
||||
desugar_ops(children) {
|
||||
desugar_ops(children, types) {
|
||||
while (true) {
|
||||
let opIndex = children.findIndex((child) => child.type === 'op');
|
||||
let opIndex = children.findIndex((child) => child.type === 'op' && types.includes(child.value));
|
||||
if (opIndex === -1) break;
|
||||
const op = { type: 'plain', value: children[opIndex].value };
|
||||
if (opIndex === children.length - 1) {
|
||||
@@ -263,8 +264,10 @@ export class MondoParser {
|
||||
// the type we've removed before splitting needs to be added back
|
||||
children = [{ type: 'plain', value: type }, ...children];
|
||||
}
|
||||
children = this.desugar_ops(children);
|
||||
// children = this.desugar_pipes(children, (children) => this.desugar_dollars(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;
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mondolang",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.2",
|
||||
"description": "a language for functional composition that translates to js",
|
||||
"main": "mondo.mjs",
|
||||
"type": "module",
|
||||
@@ -33,5 +33,8 @@
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.11",
|
||||
"vitest": "^3.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,7 @@ describe('mondo sugar', () => {
|
||||
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))'));
|
||||
|
||||
@@ -5,12 +5,13 @@ import {
|
||||
slow,
|
||||
seq,
|
||||
stepcat,
|
||||
extend,
|
||||
replicate,
|
||||
expand,
|
||||
pace,
|
||||
chooseIn,
|
||||
degradeBy,
|
||||
silence,
|
||||
bjork,
|
||||
} from '@strudel/core';
|
||||
import { registerLanguage } from '@strudel/transpiler';
|
||||
import { MondoRunner } from 'mondolang';
|
||||
@@ -36,10 +37,11 @@ lib.square = (...args) => stepcat(...args).setSteps(1);
|
||||
lib.angle = (...args) => stepcat(...args).pace(1);
|
||||
lib['*'] = fast;
|
||||
lib['/'] = slow;
|
||||
lib['!'] = extend;
|
||||
lib['!'] = replicate;
|
||||
lib['@'] = expand;
|
||||
lib['%'] = pace;
|
||||
lib['?'] = degradeBy; // todo: default 0.5 not working..
|
||||
lib['&'] = bjork;
|
||||
lib[':'] = tail;
|
||||
lib['..'] = range;
|
||||
lib['def'] = () => silence;
|
||||
@@ -85,7 +87,7 @@ function evaluator(node, scope) {
|
||||
let pat;
|
||||
if (type === 'plain' && typeof variable !== 'undefined') {
|
||||
// some function names are not patternable, so we skip reification here
|
||||
if (['!', 'extend', '@', 'expand', 'square', 'angle'].includes(value)) {
|
||||
if (['!', 'extend', '@', 'expand', 'square', 'angle', 'all', 'setcpm', 'setcps'].includes(value)) {
|
||||
return variable;
|
||||
}
|
||||
pat = reify(variable);
|
||||
@@ -108,7 +110,7 @@ export function mondo(code, offset = 0) {
|
||||
return pat.markcss('color: var(--caret,--foreground);text-decoration:underline');
|
||||
}
|
||||
|
||||
let getLocations = (code, offset) => runner.parser.get_locations(code, offset);
|
||||
export let getLocations = (code, offset) => runner.parser.get_locations(code, offset);
|
||||
|
||||
export const mondi = (str, offset) => {
|
||||
const code = `[${str}]`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strudel/mondo",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.6",
|
||||
"description": "mondo notation for strudel",
|
||||
"main": "mondough.mjs",
|
||||
"type": "module",
|
||||
@@ -40,5 +40,8 @@
|
||||
"mondo": "*",
|
||||
"vite": "^6.0.11",
|
||||
"vitest": "^3.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineConfig } from 'vite';
|
||||
//import { dependencies } from './package.json';
|
||||
import { dependencies } from './package.json';
|
||||
import { resolve } from 'path';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
@@ -12,7 +12,7 @@ export default defineConfig({
|
||||
fileName: (ext) => ({ es: 'mondough.mjs' })[ext],
|
||||
},
|
||||
rollupOptions: {
|
||||
// external: [...Object.keys(dependencies)],
|
||||
external: [...Object.keys(dependencies)],
|
||||
},
|
||||
target: 'esnext',
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strudel/motion",
|
||||
"version": "1.2.3",
|
||||
"version": "1.2.6",
|
||||
"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.3",
|
||||
"version": "1.2.6",
|
||||
"description": "MQTT API for strudel",
|
||||
"main": "mqtt.mjs",
|
||||
"type": "module",
|
||||
@@ -34,5 +34,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
+28
-18
@@ -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
|
||||
|
||||
You can read more about [how to use Superdirt with Strudel the Tutorial](https://strudel.cc/learn/input-output/#superdirt-api)
|
||||
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](https://strudel.cc/learn/input-output/#oscsuperdirtstrudeldirt) in the tutorial.
|
||||
|
||||
+19
-20
@@ -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?');
|
||||
@@ -61,15 +58,19 @@ export function parseControlsFromHap(hap, cps) {
|
||||
const collator = new ClockCollator({});
|
||||
|
||||
export async function oscTrigger(hap, currentTime, cps = 1, targetTime) {
|
||||
const osc = await connect();
|
||||
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(hap, currentTime, cps = 1, targetTime) {
|
||||
* @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.3",
|
||||
"version": "1.3.1",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
+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,6 +1,6 @@
|
||||
{
|
||||
"name": "@strudel/reference",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.2",
|
||||
"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.4",
|
||||
"version": "1.2.8",
|
||||
"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}/uzu-drumkit.json`),
|
||||
samples(`${ds}/vcsl.json`),
|
||||
samples(`${ds}/mridangam.json`),
|
||||
samples(`${tc}/strudel.json`),
|
||||
]);
|
||||
|
||||
aliasBank(`${ts}/tidal-drum-machines-alias.json`);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { silence } from '@strudel/core';
|
||||
import { getDrawContext } from '@strudel/draw';
|
||||
import { transpiler } from '@strudel/transpiler';
|
||||
import { getAudioContext, webaudioOutput } from '@strudel/webaudio';
|
||||
import { getAudioContext, webaudioOutput, initAudioOnFirstClick } from '@strudel/webaudio';
|
||||
import { StrudelMirror, codemirrorSettings } from '@strudel/codemirror';
|
||||
import { prebake } from './prebake.mjs';
|
||||
|
||||
if (typeof HTMLElement !== 'undefined') {
|
||||
initAudioOnFirstClick();
|
||||
class StrudelRepl extends HTMLElement {
|
||||
static observedAttributes = ['code'];
|
||||
settings = codemirrorSettings.get();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strudel/sampler",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.4",
|
||||
"description": "",
|
||||
"keywords": [
|
||||
"tidalcycles",
|
||||
@@ -15,5 +15,8 @@
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"cowsay": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import readline from 'readline';
|
||||
import os from 'os';
|
||||
|
||||
const LOG = !!process.env.LOG || false;
|
||||
const PORT = process.env.PORT || 5432;
|
||||
const VALID_AUDIO_EXTENSIONS = ['wav', 'mp3', 'ogg'];
|
||||
|
||||
const isAudioFile = (f) => {
|
||||
@@ -54,7 +55,6 @@ async function getBanks(directory, flat = false) {
|
||||
banks[bank].push(subDir);
|
||||
return subDir;
|
||||
});
|
||||
banks._base = `http://localhost:5432`;
|
||||
return { banks, files };
|
||||
}
|
||||
|
||||
@@ -134,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.3",
|
||||
"version": "1.2.6",
|
||||
"description": "Webserial API for strudel",
|
||||
"main": "serial.mjs",
|
||||
"type": "module",
|
||||
@@ -33,5 +33,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ Pattern.prototype.serial = function (br = 115200, sendcrc = false, singlecharids
|
||||
if (!(name in writeMessagers)) {
|
||||
getWriter(name, br);
|
||||
}
|
||||
const onTrigger = (t_deprecate, hap, currentTime, cps, targetTime) => {
|
||||
const onTrigger = (hap, currentTime, _cps, targetTime) => {
|
||||
var message = '';
|
||||
var chk = 0;
|
||||
if (typeof hap.value === 'object') {
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
getADSRValues,
|
||||
getPitchEnvelope,
|
||||
getVibratoOscillator,
|
||||
onceEnded,
|
||||
releaseAudioNode,
|
||||
} from '@strudel/webaudio';
|
||||
import gm from './gm.mjs';
|
||||
|
||||
@@ -164,19 +166,18 @@ export function registerSoundfonts() {
|
||||
let envEnd = holdEnd + release + 0.01;
|
||||
|
||||
// vibrato
|
||||
let vibratoOscillator = getVibratoOscillator(bufferSource.detune, value, time);
|
||||
const vibratoHandle = getVibratoOscillator(bufferSource.detune, value, time);
|
||||
// pitch envelope
|
||||
getPitchEnvelope(bufferSource.detune, value, time, holdEnd);
|
||||
|
||||
bufferSource.stop(envEnd);
|
||||
const stop = (releaseTime) => {};
|
||||
bufferSource.onended = () => {
|
||||
bufferSource.disconnect();
|
||||
vibratoOscillator?.stop();
|
||||
node.disconnect();
|
||||
onceEnded(bufferSource, () => {
|
||||
releaseAudioNode(bufferSource);
|
||||
vibratoHandle?.stop();
|
||||
onended();
|
||||
};
|
||||
return { node, stop };
|
||||
});
|
||||
return { node, stop, nodes: { source: [bufferSource], ...vibratoHandle?.nodes } };
|
||||
},
|
||||
{ type: 'soundfont', prebake: true, fonts },
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strudel/soundfonts",
|
||||
"version": "1.2.4",
|
||||
"version": "1.2.7",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,32 @@
|
||||
/*
|
||||
audioContext.mjs - Audio Context manager
|
||||
|
||||
Sets up a common and accessible audio context for all superdough operations
|
||||
|
||||
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/audiocontext.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
let audioContext;
|
||||
|
||||
export const setDefaultAudioContext = () => {
|
||||
audioContext = new AudioContext();
|
||||
return audioContext;
|
||||
};
|
||||
|
||||
export const setAudioContext = (context) => {
|
||||
audioContext = context;
|
||||
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) {
|
||||
|
||||
@@ -5,19 +5,18 @@ if (typeof DelayNode !== 'undefined') {
|
||||
wet = Math.abs(wet);
|
||||
this.delayTime.value = time;
|
||||
|
||||
const feedbackGain = ac.createGain();
|
||||
feedbackGain.gain.value = Math.min(Math.abs(feedback), 0.995);
|
||||
this.feedback = feedbackGain.gain;
|
||||
this.feedbackGain = ac.createGain();
|
||||
this.feedbackGain.gain.value = Math.min(Math.abs(feedback), 0.995);
|
||||
this.feedback = this.feedbackGain.gain;
|
||||
|
||||
const delayGain = ac.createGain();
|
||||
delayGain.gain.value = wet;
|
||||
this.delayGain = delayGain;
|
||||
this.delayGain = ac.createGain();
|
||||
this.delayGain.gain.value = wet;
|
||||
|
||||
this.connect(feedbackGain);
|
||||
this.connect(delayGain);
|
||||
feedbackGain.connect(this);
|
||||
this.connect(this.feedbackGain);
|
||||
this.connect(this.delayGain);
|
||||
this.feedbackGain.connect(this);
|
||||
|
||||
this.connect = (target) => delayGain.connect(target);
|
||||
this.connect = (target) => this.delayGain.connect(target);
|
||||
return this;
|
||||
}
|
||||
start(t) {
|
||||
@@ -25,7 +24,7 @@ if (typeof DelayNode !== 'undefined') {
|
||||
}
|
||||
}
|
||||
|
||||
AudioContext.prototype.createFeedbackDelay = function (wet, time, feedback) {
|
||||
BaseAudioContext.prototype.createFeedbackDelay = function (wet, time, feedback) {
|
||||
return new FeedbackDelayNode(this, wet, time, feedback);
|
||||
};
|
||||
}
|
||||
|
||||
+453
-103
@@ -1,6 +1,8 @@
|
||||
import { getAudioContext } from './superdough.mjs';
|
||||
import { clamp, nanFallback } from './util.mjs';
|
||||
import { getAudioContext } from './audioContext.mjs';
|
||||
import { logger } from './logger.mjs';
|
||||
import { getNoiseBuffer } from './noise.mjs';
|
||||
import { getNodeFromPool } from './nodePools.mjs';
|
||||
import { clamp, nanFallback, midiToFreq, noteToMidi } from './util.mjs';
|
||||
|
||||
export const noises = ['pink', 'white', 'brown', 'crackle'];
|
||||
|
||||
@@ -10,6 +12,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) {
|
||||
@@ -21,7 +30,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;
|
||||
}
|
||||
@@ -32,6 +43,7 @@ export const getParamADSR = (
|
||||
decay,
|
||||
sustain,
|
||||
release,
|
||||
// min = value at start of attack, max = value at end of attack; it is possible that max < min
|
||||
min,
|
||||
max,
|
||||
begin,
|
||||
@@ -49,17 +61,15 @@ export const getParamADSR = (
|
||||
max = max === 0 ? 0.001 : max;
|
||||
}
|
||||
const range = max - min;
|
||||
const peak = max;
|
||||
const sustainVal = min + sustain * range;
|
||||
const duration = end - begin;
|
||||
|
||||
const envValAtTime = (time) => {
|
||||
let val;
|
||||
if (attack > time) {
|
||||
let slope = getSlope(min, peak, 0, attack);
|
||||
val = time * slope + (min > peak ? min : 0);
|
||||
val = time * getSlope(min, max, 0, attack) + min;
|
||||
} else {
|
||||
val = (time - attack) * getSlope(peak, sustainVal, 0, decay) + peak;
|
||||
val = (time - attack) * getSlope(max, sustainVal, 0, decay) + max;
|
||||
}
|
||||
if (curve === 'exponential') {
|
||||
val = val || 0.001;
|
||||
@@ -88,7 +98,55 @@ 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 getEnvelope(audioContext, properties = {}) {
|
||||
return getWorklet(audioContext, 'envelope-processor', properties);
|
||||
}
|
||||
|
||||
export function getLfo(audioContext, properties = {}) {
|
||||
const {
|
||||
shape = 0,
|
||||
begin = 0,
|
||||
end = 0,
|
||||
time,
|
||||
depth = 1,
|
||||
dcoffset = -0.5,
|
||||
frequency = 1,
|
||||
skew = 0.5,
|
||||
phaseoffset = 0,
|
||||
curve = 1,
|
||||
min,
|
||||
max,
|
||||
...props
|
||||
} = properties;
|
||||
|
||||
const lfoprops = {
|
||||
begin,
|
||||
end,
|
||||
time: time ?? begin,
|
||||
depth,
|
||||
dcoffset,
|
||||
frequency,
|
||||
skew,
|
||||
phaseoffset,
|
||||
curve,
|
||||
shape: getModulationShapeInput(shape),
|
||||
min: min ?? dcoffset * depth,
|
||||
max: max ?? dcoffset * depth + depth,
|
||||
...props,
|
||||
};
|
||||
|
||||
return getWorklet(audioContext, 'lfo-processor', lfoprops);
|
||||
}
|
||||
|
||||
export function getCompressor(ac, threshold, ratio, knee, attack, release) {
|
||||
const node = getNodeFromPool('compressor', () => new DynamicsCompressorNode(ac, {}));
|
||||
const options = {
|
||||
threshold: threshold ?? -3,
|
||||
ratio: ratio ?? 10,
|
||||
@@ -96,7 +154,10 @@ export function getCompressor(ac, threshold, ratio, knee, attack, release) {
|
||||
attack: attack ?? 0.005,
|
||||
release: release ?? 0.05,
|
||||
};
|
||||
return new DynamicsCompressorNode(ac, options);
|
||||
Object.entries(options).forEach(([key, value]) => {
|
||||
node[key].value = value;
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
// changes the default values of the envelope based on what parameters the user has defined
|
||||
@@ -115,37 +176,116 @@ 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;
|
||||
if (model === 'ladder') {
|
||||
filter = getWorklet(context, 'ladder-processor', { frequency, q: Q, drive });
|
||||
frequencyParam = filter.parameters.get('frequency');
|
||||
} else {
|
||||
filter = context.createBiquadFilter();
|
||||
filter.type = type;
|
||||
filter.Q.value = Q;
|
||||
filter.frequency.value = frequency;
|
||||
frequencyParam = filter.frequency;
|
||||
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, {
|
||||
begin: 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;
|
||||
}
|
||||
|
||||
// envelope is active when any of these values is set
|
||||
const hasEnvelope = att ?? dec ?? sus ?? rel ?? fenv;
|
||||
// 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;
|
||||
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;
|
||||
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);
|
||||
}
|
||||
return filter;
|
||||
const lfo = getParamLfo(audioContext, param, start, end, lfoValues);
|
||||
return lfo;
|
||||
}
|
||||
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, drive });
|
||||
frequencyParam = filter.parameters.get('frequency');
|
||||
} else {
|
||||
const factory = () => context.createBiquadFilter();
|
||||
filter = getNodeFromPool('filter', factory);
|
||||
filter.type = type;
|
||||
Object.entries({ Q: q, frequency }).forEach(([key, value]) => {
|
||||
filter[key].value = value;
|
||||
});
|
||||
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 = [...envelopeValues, env].some((v) => v !== undefined);
|
||||
// Apply ADSR to filter frequency
|
||||
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 ** (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);
|
||||
let lfo;
|
||||
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,
|
||||
};
|
||||
lfo = getParamLfo(context, frequencyParam, start, end, lfoValues);
|
||||
}
|
||||
|
||||
return { filter, lfo };
|
||||
}
|
||||
|
||||
// stays 1 until .5, then fades out
|
||||
@@ -167,14 +307,25 @@ export function drywet(dry, wet, wetAmount = 0) {
|
||||
let mix = ac.createGain();
|
||||
dry_gain.connect(mix);
|
||||
wet_gain.connect(mix);
|
||||
return mix;
|
||||
return {
|
||||
node: mix,
|
||||
teardown: () => {
|
||||
releaseAudioNode(dry_gain);
|
||||
releaseAudioNode(wet_gain);
|
||||
// it is not the responsability of drywet
|
||||
// to call `releaseAudioNode` on
|
||||
// the 2 external args dry and wet
|
||||
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);
|
||||
@@ -202,10 +353,19 @@ export function getVibratoOscillator(param, value, t) {
|
||||
gain.gain.value = vibmod * 100;
|
||||
vibratoOscillator.connect(gain);
|
||||
gain.connect(param);
|
||||
onceEnded(vibratoOscillator, () => {
|
||||
releaseAudioNode(gain);
|
||||
releaseAudioNode(vibratoOscillator);
|
||||
});
|
||||
vibratoOscillator.start(t);
|
||||
return vibratoOscillator;
|
||||
return { stop: (t) => vibratoOscillator.stop(t), nodes: { vib: [vibratoOscillator], vib_gain: [gain] } };
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -218,25 +378,17 @@ export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) {
|
||||
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
|
||||
}
|
||||
onceEnded(constantNode, () => {
|
||||
releaseAudioNode(zeroGain);
|
||||
releaseAudioNode(constantNode);
|
||||
onComplete();
|
||||
};
|
||||
});
|
||||
constantNode.start(startTime);
|
||||
constantNode.stop(stopTime);
|
||||
return constantNode;
|
||||
}
|
||||
const mod = (freq, range = 1, type = 'sine') => {
|
||||
|
||||
const mod = (freq, type = 'sine') => {
|
||||
const ctx = getAudioContext();
|
||||
let osc;
|
||||
if (noises.includes(type)) {
|
||||
@@ -248,62 +400,260 @@ const mod = (freq, range = 1, type = 'sine') => {
|
||||
osc.type = type;
|
||||
osc.frequency.value = freq;
|
||||
}
|
||||
|
||||
osc.start();
|
||||
const g = new GainNode(ctx, { gain: range });
|
||||
osc.connect(g); // -range, range
|
||||
return { node: g, stop: (t) => osc.stop(t) };
|
||||
return osc;
|
||||
};
|
||||
const fm = (frequencyparam, harmonicityRatio, modulationIndex, wave = 'sine') => {
|
||||
|
||||
const fm = (frequencyparam, harmonicityRatio, wave = 'sine') => {
|
||||
const carrfreq = frequencyparam.value;
|
||||
const modfreq = carrfreq * harmonicityRatio;
|
||||
const modgain = modfreq * modulationIndex;
|
||||
return mod(modfreq, modgain, wave);
|
||||
return { osc: mod(modfreq, wave), freq: modfreq };
|
||||
};
|
||||
|
||||
export function applyFM(param, value, begin) {
|
||||
const {
|
||||
fmh: fmHarmonicity = 1,
|
||||
fmi: fmModulationIndex,
|
||||
fmenv: fmEnvelopeType = 'exp',
|
||||
fmattack: fmAttack,
|
||||
fmdecay: fmDecay,
|
||||
fmsustain: fmSustain,
|
||||
fmrelease: fmRelease,
|
||||
fmvelocity: fmVelocity,
|
||||
fmwave: fmWaveform = 'sine',
|
||||
duration,
|
||||
} = value;
|
||||
let modulator;
|
||||
let stop = () => {};
|
||||
|
||||
if (fmModulationIndex) {
|
||||
const ac = getAudioContext();
|
||||
const envGain = ac.createGain();
|
||||
const fmmod = fm(param, fmHarmonicity, fmModulationIndex, fmWaveform);
|
||||
|
||||
modulator = fmmod.node;
|
||||
stop = fmmod.stop;
|
||||
if (![fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity].some((v) => v !== undefined)) {
|
||||
// no envelope by default
|
||||
modulator.connect(param);
|
||||
} else {
|
||||
const [attack, decay, sustain, release] = getADSRValues([fmAttack, fmDecay, fmSustain, fmRelease]);
|
||||
const holdEnd = begin + duration;
|
||||
getParamADSR(
|
||||
envGain.gain,
|
||||
attack,
|
||||
decay,
|
||||
sustain,
|
||||
release,
|
||||
0,
|
||||
1,
|
||||
begin,
|
||||
holdEnd,
|
||||
fmEnvelopeType === 'exp' ? 'exponential' : 'linear',
|
||||
);
|
||||
modulator.connect(envGain);
|
||||
envGain.connect(param);
|
||||
const ac = getAudioContext();
|
||||
const toStop = []; // fm oscillators we will expose `stop` for
|
||||
const fms = {};
|
||||
const nodes = {};
|
||||
// Matrix
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
for (let j = 0; j <= 8; j++) {
|
||||
let control;
|
||||
if (i === j + 1) {
|
||||
// Standard fm3 -> fm2 -> fm1 -> param usage
|
||||
const iS = i === 1 ? '' : i;
|
||||
control = `fmi${iS}`;
|
||||
} else {
|
||||
control = `fmi${i}${j}`;
|
||||
}
|
||||
const amt = value[control];
|
||||
if (!amt) continue;
|
||||
let io = [];
|
||||
for (let [isMod, idx] of [
|
||||
[true, i], // source
|
||||
[false, j], // target
|
||||
]) {
|
||||
if (idx === 0) {
|
||||
io.push(param);
|
||||
continue;
|
||||
}
|
||||
if (!fms[idx]) {
|
||||
const idxS = idx === 1 ? '' : idx;
|
||||
const { osc, freq } = fm(param, value[`fmh${idxS}`] ?? 1, value[`fmwave${idxS}`] ?? 'sine');
|
||||
toStop.push(osc);
|
||||
const toCleanup = [osc]; // nodes we want to cleanup after oscillator `stop`
|
||||
const adsr = ['attack', 'decay', 'sustain', 'release'].map((s) => value[`fm${s}${idxS}`]);
|
||||
let output = osc;
|
||||
if (adsr.some((v) => v !== undefined)) {
|
||||
const envGain = ac.createGain();
|
||||
const [attack, decay, sustain, release] = getADSRValues(adsr);
|
||||
const holdEnd = begin + value.duration;
|
||||
const fmEnvelopeType = value[`fmenv${idxS}`] ?? 'exp';
|
||||
getParamADSR(
|
||||
envGain.gain,
|
||||
attack,
|
||||
decay,
|
||||
sustain,
|
||||
release,
|
||||
0,
|
||||
1,
|
||||
begin,
|
||||
holdEnd,
|
||||
fmEnvelopeType === 'exp' ? 'exponential' : 'linear',
|
||||
);
|
||||
toCleanup.push(envGain);
|
||||
output = osc.connect(envGain);
|
||||
}
|
||||
fms[idx] = { input: osc.frequency, output, freq, osc, toCleanup };
|
||||
nodes[`fm_${idx}`] = [osc];
|
||||
}
|
||||
const { input, output, freq, osc, toCleanup } = fms[idx];
|
||||
const gAmt = gainNode(amt);
|
||||
const gFreq = gainNode(freq);
|
||||
io.push(isMod ? output.connect(gAmt).connect(gFreq) : input);
|
||||
cleanupOnEnd(osc, [...toCleanup, gAmt, gFreq]);
|
||||
nodes[`fm_${idx}_gain`] = [gAmt];
|
||||
}
|
||||
if (!io[1]) {
|
||||
logger(
|
||||
`[superdough] control ${control} failed to connect FM ${i} to target ${j} due to missing frequency parameter (likely because fm${j} is noise)`,
|
||||
'warning',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
io[0].connect(io[1]);
|
||||
}
|
||||
}
|
||||
return { stop };
|
||||
return {
|
||||
nodes,
|
||||
stop: (t) => toStop.forEach((m) => m?.stop(t)),
|
||||
};
|
||||
}
|
||||
|
||||
// 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);
|
||||
};
|
||||
|
||||
// This helper should be used instead of the `node.onended = callback` pattern
|
||||
// It adds a mechanism to help minimize gc retention
|
||||
export const onceEnded = (node, callback) => {
|
||||
const onended = callback;
|
||||
node.onended = function cleanup() {
|
||||
onended && onended();
|
||||
this.onended = null;
|
||||
};
|
||||
};
|
||||
|
||||
export const releaseAudioNode = (node) => {
|
||||
if (node == null) return;
|
||||
|
||||
// check we received an AudioNode
|
||||
if (!(node instanceof AudioNode)) {
|
||||
throw new Error('releaseAudioNode can only release an AudioNode');
|
||||
}
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/AudioNode/disconnect
|
||||
node.disconnect();
|
||||
|
||||
// make sure all AudioScheduledSourceNodes are in a stopped state
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/AudioScheduledSourceNode
|
||||
if (node instanceof AudioScheduledSourceNode) {
|
||||
if (process.env.NODE_ENV === 'development' && node.onended && node.onended.name !== 'cleanup') {
|
||||
logger(
|
||||
`[superdough] Deprecation warning: it seems your code path is setting 'node.onended = callback' instead of using the onceEnded helper`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
node.stop();
|
||||
} catch (e) {
|
||||
// At the stage, `start` was not called on the node
|
||||
// but an `onended` callback releasing resources may exist
|
||||
// and we want it to fire :
|
||||
// - we force a start/stop cycle so that `onended` gets called
|
||||
// - we `lock` the node so that no-one can start it
|
||||
node.start(node.context.currentTime + 5); // will never happen
|
||||
node.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// https://www.w3.org/TR/webaudio-1.1/#AudioNode-actively-processing
|
||||
// An AudioWorkletNode is actively processing when its AudioWorkletProcessor's [[callable process]]
|
||||
// returns true and either its active source flag is true or
|
||||
// any AudioNode connected to one of its inputs is actively processing.
|
||||
if (node instanceof AudioWorkletNode) {
|
||||
// while `end` is not native to the web audio API, it is common practice in superdough
|
||||
// to use that param in the worklets to trigger returning false from the processor
|
||||
node.parameters.get('end')?.setValueAtTime(0, 0);
|
||||
}
|
||||
};
|
||||
|
||||
// Once the `anchor` node has ended, release all nodes in `toCleanup`
|
||||
export const cleanupOnEnd = (anchor, toCleanup) => {
|
||||
onceEnded(anchor, () => toCleanup.forEach((n) => releaseAudioNode(n)));
|
||||
};
|
||||
|
||||
@@ -10,4 +10,7 @@ export * from './helpers.mjs';
|
||||
export * from './synth.mjs';
|
||||
export * from './zzfx.mjs';
|
||||
export * from './logger.mjs';
|
||||
export * from './modulators.mjs';
|
||||
export * from './dspworklet.mjs';
|
||||
export * from './audioContext.mjs';
|
||||
export * from './wavetable.mjs';
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
let log = (msg) => console.log(msg);
|
||||
|
||||
export function errorLogger(e, origin = 'cyclist') {
|
||||
//TODO: add some kind of debug flag that enables this while in dev mode
|
||||
// console.error(e);
|
||||
export function errorLogger(e, origin = 'superdough') {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error(e);
|
||||
}
|
||||
logger(`[${origin}] error: ${e.message}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
modulators.mjs - Helpers for constructing modulators (envelopes, LFOs, etc.)
|
||||
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/modulators.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { getAudioContext } from './audioContext.mjs';
|
||||
import { gainNode, getEnvelope, getLfo, webAudioTimeout } from './helpers.mjs';
|
||||
import { errorLogger } from './logger.mjs';
|
||||
import { getSuperdoughControlTargets } from './superdoughdata.mjs';
|
||||
import { clamp } from './util.mjs';
|
||||
|
||||
const getNodeParam = (node, name) => {
|
||||
// Worklet case
|
||||
if (node?.parameters) {
|
||||
const p = node.parameters.get(name);
|
||||
if (p instanceof AudioParam) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
// Built-in node case
|
||||
let p = node?.[name];
|
||||
if (p === undefined && name === 'frequency') {
|
||||
// Fallbacks for source nodes without 'frequency' params (e.g. soundfonts)
|
||||
p = node?.['detune'] ?? node?.['playbackRate'];
|
||||
}
|
||||
if (p instanceof AudioParam) {
|
||||
return p;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const controlTargets = getSuperdoughControlTargets();
|
||||
|
||||
const getControlData = (control, subControl) => {
|
||||
const controlNoIdx = control.split('_')[0];
|
||||
return controlTargets[`${controlNoIdx}_${subControl}`] ?? controlTargets[controlNoIdx];
|
||||
};
|
||||
|
||||
const getRangeForParam = (paramName, currentValue) => {
|
||||
// We clamp the frequency to a reasonable range unless the currentValue
|
||||
// is low, which indicates this may be an LFO
|
||||
if (paramName === 'frequency' && currentValue >= 30) {
|
||||
return { min: 20 - currentValue, max: 24000 - currentValue };
|
||||
}
|
||||
return { min: undefined, max: undefined };
|
||||
};
|
||||
|
||||
const clampWithWaveShaper = (modulator, min, max) => {
|
||||
const ac = getAudioContext();
|
||||
const curve = new Float32Array(256);
|
||||
for (let i = 0; i < curve.length; i++) {
|
||||
const x = (i / (curve.length - 1)) * 2 - 1;
|
||||
curve[i] = clamp(x * max, min, max);
|
||||
}
|
||||
const shaper = new WaveShaperNode(ac, { curve });
|
||||
const scaleGain = gainNode(1 / max);
|
||||
modulator.connect(scaleGain).connect(shaper);
|
||||
return { modulator, toCleanup: [shaper, scaleGain] };
|
||||
};
|
||||
|
||||
const getTargetParamsForControl = (control, nodes, subControl) => {
|
||||
const targetInfo = getControlData(control, subControl);
|
||||
if (!targetInfo) {
|
||||
errorLogger(
|
||||
new Error(`Could not find control data for target '${control}'. It may not be modulatable.`),
|
||||
'superdough',
|
||||
);
|
||||
return { targetParams: [], paramName: control };
|
||||
}
|
||||
const paramName = targetInfo.param;
|
||||
const nodeKey = nodes[targetInfo.node] ? targetInfo.node : control;
|
||||
const targetNodes = nodes[nodeKey];
|
||||
if (!targetNodes) {
|
||||
const keys = Object.keys(nodes);
|
||||
errorLogger(
|
||||
new Error(`Could not connect to target '${nodeKey}' — it does not exist. Available targets: ${keys.join(', ')}`),
|
||||
'superdough',
|
||||
);
|
||||
return { targetParams: [], paramName };
|
||||
}
|
||||
const audioParams = [];
|
||||
targetNodes.forEach((targetNode) => {
|
||||
const targetParam = getNodeParam(targetNode, paramName);
|
||||
audioParams.push(targetParam);
|
||||
});
|
||||
return { targetParams: audioParams, paramName };
|
||||
};
|
||||
|
||||
export const connectLFO = (id, params, nodeTracker) => {
|
||||
const {
|
||||
rate = 1,
|
||||
sync,
|
||||
cps,
|
||||
cycle,
|
||||
control = 'lfo',
|
||||
subControl,
|
||||
fxi = 'main',
|
||||
depth = 1,
|
||||
depthabs,
|
||||
...filteredParams
|
||||
} = params;
|
||||
const { targetParams, paramName } = getTargetParamsForControl(control, nodeTracker[fxi], subControl);
|
||||
if (!targetParams.length) return;
|
||||
let currentValue = targetParams[0].value;
|
||||
currentValue = currentValue === 0 ? 1 : currentValue;
|
||||
const { min, max } = getRangeForParam(paramName, currentValue);
|
||||
const depthValue = depthabs != null ? depthabs : depth * currentValue;
|
||||
const modParams = {
|
||||
...filteredParams,
|
||||
frequency: sync !== undefined ? sync * cps : rate,
|
||||
time: cycle / cps,
|
||||
depth: depthValue,
|
||||
min,
|
||||
max,
|
||||
};
|
||||
const lfoNode = getLfo(getAudioContext(), modParams);
|
||||
nodeTracker.main[`lfo_${id}`] = [lfoNode];
|
||||
targetParams.forEach((t) => lfoNode.connect(t));
|
||||
return lfoNode;
|
||||
};
|
||||
|
||||
export const connectEnvelope = (id, params, nodeTracker) => {
|
||||
const { control, subControl, acurve, dcurve, rcurve, depth = 1, depthabs, fxi = 'main', ...filteredParams } = params;
|
||||
const { targetParams, paramName } = getTargetParamsForControl(control, nodeTracker[fxi], subControl);
|
||||
if (!targetParams.length) return;
|
||||
let currentValue = targetParams[0].value;
|
||||
currentValue = currentValue === 0 ? 1 : currentValue;
|
||||
const { min, max } = getRangeForParam(paramName, currentValue);
|
||||
const depthValue = depthabs != null ? depthabs : depth * currentValue;
|
||||
const envNode = getEnvelope(getAudioContext(), {
|
||||
...filteredParams,
|
||||
depth: depthValue,
|
||||
min,
|
||||
max,
|
||||
attackCurve: acurve,
|
||||
decayCurve: dcurve,
|
||||
releaseCurve: rcurve,
|
||||
});
|
||||
nodeTracker.main[`env_${id}`] = [envNode];
|
||||
targetParams.forEach((t) => envNode.connect(t));
|
||||
return envNode;
|
||||
};
|
||||
|
||||
export const connectBusModulator = (params, nodeTracker, controller) => {
|
||||
const ac = getAudioContext();
|
||||
const { control, subControl, depth = 1, depthabs, fxi = 'main' } = params;
|
||||
const { targetParams, paramName } = getTargetParamsForControl(control, nodeTracker[fxi], subControl);
|
||||
if (!targetParams.length) return { toCleanup: [] };
|
||||
const signal = controller.getBus(params.bus);
|
||||
const dc = new ConstantSourceNode(ac, { offset: params.dc ?? 0 });
|
||||
dc.start(params.begin);
|
||||
const shifted = dc.connect(gainNode(1));
|
||||
signal.connect(shifted);
|
||||
let currentValue = targetParams[0].value;
|
||||
currentValue = currentValue === 0 ? 1 : currentValue;
|
||||
const { min, max } = getRangeForParam(paramName, currentValue);
|
||||
const depthValue = depthabs != null ? depthabs : depth * currentValue;
|
||||
const depthGain = gainNode((Math.sign(depthValue) * Math.abs(depthValue)) / 0.3);
|
||||
const unClamped = shifted.connect(depthGain);
|
||||
const toCleanup = [];
|
||||
let modulator = unClamped;
|
||||
if (min !== undefined && max !== undefined) {
|
||||
const wsData = clampWithWaveShaper(unClamped, min, max);
|
||||
modulator = wsData.modulator;
|
||||
toCleanup.push(...wsData.toCleanup);
|
||||
}
|
||||
webAudioTimeout(
|
||||
ac,
|
||||
() => {
|
||||
targetParams.forEach((t) => modulator.connect(t));
|
||||
},
|
||||
0,
|
||||
params.begin,
|
||||
);
|
||||
toCleanup.push(dc, shifted, depthGain);
|
||||
return { modulator, toCleanup };
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
nodePools.mjs - Helper functions related to pooling and re-using audio nodes
|
||||
|
||||
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/nodePools.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const nodePools = new Map();
|
||||
const POOL_KEY = Symbol('nodePoolKey');
|
||||
|
||||
export const isPoolable = (node) => !!node[POOL_KEY];
|
||||
|
||||
const getNodeTime = (node) => {
|
||||
return node.context?.currentTime ?? 0;
|
||||
};
|
||||
|
||||
const getParams = (node) => {
|
||||
const params = new Set();
|
||||
node.parameters?.forEach((param) => params.add(param));
|
||||
const visited = new Set(); // prioritize deepest definition
|
||||
let proto = node;
|
||||
// Move up the prototype chain
|
||||
while (proto !== Object.prototype) {
|
||||
for (const key of Object.getOwnPropertyNames(proto)) {
|
||||
if (visited.has(key)) continue;
|
||||
visited.add(key);
|
||||
const value = node[key];
|
||||
if (value instanceof AudioParam) {
|
||||
params.add(value);
|
||||
}
|
||||
}
|
||||
proto = Object.getPrototypeOf(proto);
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
export const releaseNodeToPool = (node) => {
|
||||
node.disconnect();
|
||||
if (node instanceof AudioScheduledSourceNode) {
|
||||
// not reusable
|
||||
return;
|
||||
}
|
||||
const key = node[POOL_KEY];
|
||||
if (key == null) return;
|
||||
const now = getNodeTime(node);
|
||||
getParams(node).forEach((param) => param.cancelScheduledValues(now));
|
||||
const pool = nodePools.get(key) ?? [];
|
||||
pool.push(new WeakRef(node));
|
||||
nodePools.set(key, pool);
|
||||
};
|
||||
|
||||
// Audio worklets are given a grace period to survive (`return true`) after
|
||||
// being released. This concludes at time `end + 0.5`. We test here whether we are
|
||||
// within some safe distance of that (`end + 0.45`) and if so, permit the node to be
|
||||
// released. This helps to prevent race conditions between node termination and node
|
||||
// re-use
|
||||
const isNodeAlive = (node) => {
|
||||
// Skip check if node is not a worklet
|
||||
if (!(node instanceof AudioWorkletNode)) return true;
|
||||
const now = getNodeTime(node);
|
||||
const end = node?.parameters?.get('end').value ?? 0;
|
||||
return now < end + 0.45;
|
||||
};
|
||||
|
||||
// Attempt to get node from the pool. If this fails, fall back
|
||||
// to building it with the factory
|
||||
export const getNodeFromPool = (key, factory) => {
|
||||
const pool = nodePools.get(key) ?? [];
|
||||
let node;
|
||||
let found = false;
|
||||
while (pool.length) {
|
||||
const ref = pool.pop();
|
||||
node = ref?.deref();
|
||||
if (node != null && isNodeAlive(node)) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
node = factory();
|
||||
}
|
||||
node[POOL_KEY] = key;
|
||||
return node;
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { drywet } from './helpers.mjs';
|
||||
import { getAudioContext } from './superdough.mjs';
|
||||
import { drywet, onceEnded, releaseAudioNode } from './helpers.mjs';
|
||||
import { getAudioContext } from './audioContext.mjs';
|
||||
|
||||
let noiseCache = {};
|
||||
|
||||
@@ -65,8 +65,12 @@ 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);
|
||||
onceEnded(noiseOscillator.node, () => {
|
||||
releaseAudioNode(noiseOscillator.node);
|
||||
});
|
||||
return {
|
||||
node: noiseMix,
|
||||
node: noiseMix.node,
|
||||
stop: (time) => noiseOscillator?.stop(time),
|
||||
teardown: noiseMix.teardown,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "superdough",
|
||||
"version": "1.2.4",
|
||||
"version": "1.2.7",
|
||||
"description": "simple web audio synth and sampler intended for live coding. inspired by superdirt and webdirt.",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
@@ -36,6 +36,10 @@
|
||||
"vite-plugin-bundle-audioworklet": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kabelsalat/lib": "^0.4.1",
|
||||
"nanostores": "^0.11.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import reverbGen from './reverbGen.mjs';
|
||||
import { clamp } from './util.mjs';
|
||||
|
||||
if (typeof AudioContext !== 'undefined') {
|
||||
AudioContext.prototype.adjustLength = function (duration, buffer, speed = 1, offsetAmount = 0) {
|
||||
BaseAudioContext.prototype.adjustLength = function (duration, buffer, speed = 1, offsetAmount = 0) {
|
||||
const sampleOffset = Math.floor(clamp(offsetAmount, 0, 1) * buffer.length);
|
||||
const newLength = buffer.sampleRate * duration;
|
||||
const newBuffer = this.createBuffer(buffer.numberOfChannels, buffer.length, buffer.sampleRate);
|
||||
@@ -23,7 +23,7 @@ if (typeof AudioContext !== 'undefined') {
|
||||
return newBuffer;
|
||||
};
|
||||
|
||||
AudioContext.prototype.createReverb = function (duration, fade, lp, dim, ir, irspeed, irbegin) {
|
||||
BaseAudioContext.prototype.createReverb = function (duration, fade, lp, dim, ir, irspeed, irbegin) {
|
||||
const convolver = this.createConvolver();
|
||||
convolver.generate = (d = 2, fade = 0.1, lp = 15000, dim = 1000, ir, irspeed, irbegin) => {
|
||||
convolver.duration = d;
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { releaseAudioNode } from './helpers.mjs';
|
||||
|
||||
var reverbGen = {};
|
||||
|
||||
/** Generates a reverb impulse response.
|
||||
@@ -104,6 +106,8 @@ var applyGradualLowpass = function (input, lpFreqStart, lpFreqEnd, lpFreqEndAt,
|
||||
player.start();
|
||||
context.oncomplete = function (event) {
|
||||
callback(event.renderedBuffer);
|
||||
releaseAudioNode(filter);
|
||||
releaseAudioNode(player);
|
||||
};
|
||||
context.startRendering();
|
||||
|
||||
|
||||
+123
-118
@@ -1,6 +1,14 @@
|
||||
import { noteToMidi, valueToMidi, getSoundIndex } from './util.mjs';
|
||||
import { getAudioContext, registerSound } from './index.mjs';
|
||||
import { getADSRValues, getParamADSR, getPitchEnvelope, getVibratoOscillator } from './helpers.mjs';
|
||||
import { getBaseURL, getCommonSampleInfo } from './util.mjs';
|
||||
import { registerSound, registerWaveTable } from './index.mjs';
|
||||
import { getAudioContext } from './audioContext.mjs';
|
||||
import {
|
||||
getADSRValues,
|
||||
getParamADSR,
|
||||
getPitchEnvelope,
|
||||
getVibratoOscillator,
|
||||
onceEnded,
|
||||
releaseAudioNode,
|
||||
} from './helpers.mjs';
|
||||
import { logger } from './logger.mjs';
|
||||
|
||||
const bufferCache = {}; // string: Promise<ArrayBuffer>
|
||||
@@ -22,39 +30,16 @@ function humanFileSize(bytes, si) {
|
||||
return bytes.toFixed(1) + ' ' + units[u];
|
||||
}
|
||||
|
||||
// deduces relevant info for sample loading from hap.value and sample definition
|
||||
// it encapsulates the core sampler logic into a pure and synchronous function
|
||||
// hapValue: Hap.value, bank: sample bank definition for sound "s" (values in strudel.json format)
|
||||
export function getSampleInfo(hapValue, bank) {
|
||||
const { s, n = 0, speed = 1.0 } = hapValue;
|
||||
let midi = valueToMidi(hapValue, 36);
|
||||
let transpose = midi - 36; // C3 is middle C;
|
||||
let sampleUrl;
|
||||
let index = 0;
|
||||
if (Array.isArray(bank)) {
|
||||
index = getSoundIndex(n, bank.length);
|
||||
sampleUrl = bank[index];
|
||||
} else {
|
||||
const midiDiff = (noteA) => noteToMidi(noteA) - midi;
|
||||
// object format will expect keys as notes
|
||||
const closest = Object.keys(bank)
|
||||
.filter((k) => !k.startsWith('_'))
|
||||
.reduce(
|
||||
(closest, key, j) => (!closest || Math.abs(midiDiff(key)) < Math.abs(midiDiff(closest)) ? key : closest),
|
||||
null,
|
||||
);
|
||||
transpose = -midiDiff(closest); // semitones to repitch
|
||||
index = getSoundIndex(n, bank[closest].length);
|
||||
sampleUrl = bank[closest][index];
|
||||
}
|
||||
const label = `${s}:${index}`;
|
||||
const { speed = 1.0 } = hapValue;
|
||||
const { transpose, url, index, midi, label } = getCommonSampleInfo(hapValue, bank);
|
||||
let playbackRate = Math.abs(speed) * Math.pow(2, transpose / 12);
|
||||
return { transpose, sampleUrl, index, midi, label, playbackRate };
|
||||
return { transpose, url, index, midi, label, playbackRate };
|
||||
}
|
||||
|
||||
// takes hapValue and returns buffer + playbackRate.
|
||||
export const getSampleBuffer = async (hapValue, bank, resolveUrl) => {
|
||||
let { sampleUrl, label, playbackRate } = getSampleInfo(hapValue, bank);
|
||||
let { url: sampleUrl, label, playbackRate } = getSampleInfo(hapValue, bank);
|
||||
if (resolveUrl) {
|
||||
sampleUrl = await resolveUrl(sampleUrl);
|
||||
}
|
||||
@@ -79,22 +64,23 @@ export const getSampleBufferSource = async (hapValue, bank, resolveUrl) => {
|
||||
bufferSource.buffer = buffer;
|
||||
bufferSource.playbackRate.value = playbackRate;
|
||||
|
||||
const { s, loopBegin = 0, loopEnd = 1, begin = 0, end = 1 } = hapValue;
|
||||
const { loopBegin = 0, loopEnd = 1, begin = 0, end = 1 } = hapValue;
|
||||
const bufferDuration = bufferSource.buffer.duration;
|
||||
|
||||
// "The computation of the offset into the sound is performed using the sound buffer's natural sample rate,
|
||||
// rather than the current playback rate, so even if the sound is playing at twice its normal speed,
|
||||
// the midway point through a 10-second audio buffer is still 5."
|
||||
const offset = begin * bufferSource.buffer.duration;
|
||||
// The computation of the offset into the sound is performed using the sound buffer's natural duration,
|
||||
// rather than the playback duration, so that even if the sound is playing at twice its normal speed,
|
||||
// the midway point through a 10-second audio buffer is still 5.
|
||||
const offset = begin * bufferDuration;
|
||||
|
||||
const loop = s.startsWith('wt_') ? 1 : hapValue.loop;
|
||||
const loop = hapValue.loop;
|
||||
if (loop) {
|
||||
bufferSource.loop = true;
|
||||
bufferSource.loopStart = loopBegin * bufferSource.buffer.duration - offset;
|
||||
bufferSource.loopEnd = loopEnd * bufferSource.buffer.duration - offset;
|
||||
bufferSource.loopStart = loopBegin * bufferDuration;
|
||||
bufferSource.loopEnd = loopEnd * bufferDuration;
|
||||
}
|
||||
const bufferDuration = bufferSource.buffer.duration / bufferSource.playbackRate.value;
|
||||
const sliceDuration = (end - begin) * bufferDuration;
|
||||
return { bufferSource, offset, bufferDuration, sliceDuration };
|
||||
const playbackDuration = bufferDuration / bufferSource.playbackRate.value;
|
||||
const sliceDuration = (end - begin) * playbackDuration;
|
||||
return { bufferSource, offset, bufferDuration, playbackDuration, sliceDuration };
|
||||
};
|
||||
|
||||
export const loadBuffer = (url, ac, s, n = 0) => {
|
||||
@@ -143,13 +129,18 @@ function githubPath(base, subpath = '') {
|
||||
if (!base.startsWith('github:')) {
|
||||
throw new Error('expected "github:" at the start of pseudoUrl');
|
||||
}
|
||||
let [_, path] = base.split('github:');
|
||||
let path = base.slice('github:'.length);
|
||||
path = path.endsWith('/') ? path.slice(0, -1) : path;
|
||||
if (path.split('/').length === 2) {
|
||||
// assume main as default branch if none set
|
||||
path += '/main';
|
||||
}
|
||||
return `https://raw.githubusercontent.com/${path}/${subpath}`;
|
||||
|
||||
let components = path.split('/');
|
||||
let user = components[0];
|
||||
let repo = components.length >= 2 ? components[1] : 'samples';
|
||||
let branch = components.length >= 3 ? components[2] : 'main';
|
||||
let other = components.slice(3);
|
||||
other.push(subpath ? subpath : '');
|
||||
other = other.join('/');
|
||||
|
||||
return `https://raw.githubusercontent.com/${user}/${repo}/${branch}/${other}`;
|
||||
}
|
||||
|
||||
export const processSampleMap = (sampleMap, fn, baseUrl = sampleMap._base || '') => {
|
||||
@@ -196,6 +187,52 @@ function getSamplesPrefixHandler(url) {
|
||||
return;
|
||||
}
|
||||
|
||||
export async function fetchSampleMap(url) {
|
||||
// check if custom prefix handler
|
||||
const handler = getSamplesPrefixHandler(url);
|
||||
if (handler) {
|
||||
return handler(url);
|
||||
}
|
||||
url = resolveSpecialPaths(url);
|
||||
if (url.startsWith('github:')) {
|
||||
url = githubPath(url, 'strudel.json');
|
||||
}
|
||||
if (url.startsWith('local:')) {
|
||||
url = `http://localhost:5432`;
|
||||
}
|
||||
if (url.startsWith('shabda:')) {
|
||||
let [_, path] = url.split('shabda:');
|
||||
url = `https://shabda.ndre.gr/${path}.json?strudel=1`;
|
||||
}
|
||||
if (url.startsWith('shabda/speech')) {
|
||||
let [_, path] = url.split('shabda/speech');
|
||||
path = path.startsWith('/') ? path.substring(1) : path;
|
||||
let [params, words] = path.split(':');
|
||||
let gender = 'f';
|
||||
let language = 'en-GB';
|
||||
if (params) {
|
||||
[language, gender] = params.split('/');
|
||||
}
|
||||
url = `https://shabda.ndre.gr/speech/${words}.json?gender=${gender}&language=${language}&strudel=1'`;
|
||||
}
|
||||
if (typeof fetch !== 'function') {
|
||||
// not a browser
|
||||
return;
|
||||
}
|
||||
const base = getBaseURL(url);
|
||||
if (typeof fetch === 'undefined') {
|
||||
// skip fetch when in node / testing
|
||||
return;
|
||||
}
|
||||
const json = await fetch(url)
|
||||
.then((res) => res.json())
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
throw new Error(`error loading "${url}"`);
|
||||
});
|
||||
return [json, json._base || base];
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a collection of samples to use with `s`
|
||||
* @example
|
||||
@@ -207,71 +244,20 @@ function getSamplesPrefixHandler(url) {
|
||||
* sd: '808sd/SD0010.WAV'
|
||||
* }, 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/');
|
||||
* s("[bd ~]*2, [~ hh]*2, ~ sd")
|
||||
* @example
|
||||
* samples('shabda:noise,chimp:2')
|
||||
* s("noise <chimp:0*2 chimp:1>")
|
||||
* @example
|
||||
* samples('shabda/speech/fr-FR/f:chocolat')
|
||||
* s("chocolat*4")
|
||||
*/
|
||||
|
||||
export const samples = async (sampleMap, baseUrl = sampleMap._base || '', options = {}) => {
|
||||
if (typeof sampleMap === 'string') {
|
||||
// check if custom prefix handler
|
||||
const handler = getSamplesPrefixHandler(sampleMap);
|
||||
if (handler) {
|
||||
return handler(sampleMap);
|
||||
}
|
||||
sampleMap = resolveSpecialPaths(sampleMap);
|
||||
if (sampleMap.startsWith('github:')) {
|
||||
sampleMap = githubPath(sampleMap, 'strudel.json');
|
||||
}
|
||||
if (sampleMap.startsWith('local:')) {
|
||||
sampleMap = `http://localhost:5432`;
|
||||
}
|
||||
if (sampleMap.startsWith('shabda:')) {
|
||||
let [_, path] = sampleMap.split('shabda:');
|
||||
sampleMap = `https://shabda.ndre.gr/${path}.json?strudel=1`;
|
||||
}
|
||||
if (sampleMap.startsWith('shabda/speech')) {
|
||||
let [_, path] = sampleMap.split('shabda/speech');
|
||||
path = path.startsWith('/') ? path.substring(1) : path;
|
||||
let [params, words] = path.split(':');
|
||||
let gender = 'f';
|
||||
let language = 'en-GB';
|
||||
if (params) {
|
||||
[language, gender] = params.split('/');
|
||||
}
|
||||
sampleMap = `https://shabda.ndre.gr/speech/${words}.json?gender=${gender}&language=${language}&strudel=1'`;
|
||||
}
|
||||
if (typeof fetch !== 'function') {
|
||||
// not a browser
|
||||
return;
|
||||
}
|
||||
const base = sampleMap.split('/').slice(0, -1).join('/');
|
||||
if (typeof fetch === 'undefined') {
|
||||
// skip fetch when in node / testing
|
||||
return;
|
||||
}
|
||||
return fetch(sampleMap)
|
||||
.then((res) => res.json())
|
||||
.then((json) => samples(json, baseUrl || json._base || base, options))
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
throw new Error(`error loading "${sampleMap}"`);
|
||||
});
|
||||
const [json, base] = await fetchSampleMap(sampleMap);
|
||||
return samples(json, baseUrl || base, options);
|
||||
}
|
||||
const { prebake, tag } = options;
|
||||
|
||||
processSampleMap(
|
||||
sampleMap,
|
||||
(key, bank) =>
|
||||
registerSound(key, (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, bank), {
|
||||
type: 'sample',
|
||||
samples: bank,
|
||||
baseUrl,
|
||||
prebake,
|
||||
tag,
|
||||
}),
|
||||
(key, bank) => {
|
||||
registerSampleSource(key, bank, { baseUrl, prebake, tag });
|
||||
},
|
||||
baseUrl,
|
||||
);
|
||||
};
|
||||
@@ -302,19 +288,21 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) {
|
||||
|
||||
const { bufferSource, sliceDuration, offset } = await getSampleBufferSource(value, bank, resolveUrl);
|
||||
|
||||
// asny stuff above took too long?
|
||||
if (ac.currentTime > t) {
|
||||
logger(`[sampler] still loading sound "${s}:${n}"`, 'highlight');
|
||||
// console.warn('sample still loading:', s, n);
|
||||
return;
|
||||
}
|
||||
if (!bufferSource) {
|
||||
logger(`[sampler] could not load "${s}:${n}"`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// async stuff above took too long?
|
||||
if (ac.currentTime > t) {
|
||||
logger(`[sampler] loading sound "${s}:${n}" took too long`, 'highlight');
|
||||
// AudioBufferSourceNode will never be used. discard it
|
||||
releaseAudioNode(bufferSource);
|
||||
return;
|
||||
}
|
||||
|
||||
// vibrato
|
||||
let vibratoOscillator = getVibratoOscillator(bufferSource.detune, value, t);
|
||||
const vibratoHandle = getVibratoOscillator(bufferSource.detune, value, t);
|
||||
|
||||
const time = t + nudge;
|
||||
bufferSource.start(time, offset);
|
||||
@@ -335,19 +323,19 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) {
|
||||
|
||||
const out = ac.createGain(); // we need a separate gain for the cutgroups because firefox...
|
||||
node.connect(out);
|
||||
bufferSource.onended = function () {
|
||||
bufferSource.disconnect();
|
||||
vibratoOscillator?.stop();
|
||||
node.disconnect();
|
||||
out.disconnect();
|
||||
onceEnded(bufferSource, function () {
|
||||
releaseAudioNode(bufferSource);
|
||||
vibratoHandle?.stop();
|
||||
releaseAudioNode(node);
|
||||
releaseAudioNode(out);
|
||||
onended();
|
||||
};
|
||||
});
|
||||
let envEnd = holdEnd + release + 0.01;
|
||||
bufferSource.stop(envEnd);
|
||||
const stop = (endTime) => {
|
||||
bufferSource.stop(endTime);
|
||||
};
|
||||
const handle = { node: out, bufferSource, stop };
|
||||
const handle = { node: out, nodes: { source: [bufferSource], ...vibratoHandle?.nodes }, stop };
|
||||
|
||||
// cut groups
|
||||
if (cut !== undefined) {
|
||||
@@ -361,3 +349,20 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) {
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
function registerSample(key, bank, params) {
|
||||
registerSound(key, (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, bank), {
|
||||
type: 'sample',
|
||||
samples: bank,
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
export function registerSampleSource(key, bank, params) {
|
||||
const isWavetable = key.startsWith('wt_');
|
||||
if (isWavetable) {
|
||||
registerWaveTable(key, bank, params);
|
||||
} else {
|
||||
registerSample(key, bank, params);
|
||||
}
|
||||
}
|
||||
|
||||
+559
-412
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
superdoughdata.mjs - Data needed for running superdough (defaults, mappings, etc.)
|
||||
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdoughdata.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// Mapping from control name to webaudio node and parameter
|
||||
const CONTROL_TARGETS = {
|
||||
stretch: { node: 'stretch', param: 'pitchFactor' },
|
||||
gain: { node: 'gain', param: 'gain' },
|
||||
postgain: { node: 'post', param: 'gain' },
|
||||
pan: { node: 'pan', param: 'pan' },
|
||||
tremolo: { node: 'tremolo', param: 'frequency' },
|
||||
tremolosync: { node: 'tremolo', param: 'frequency' },
|
||||
tremolodepth: { node: 'tremolo_gain', param: 'gain' },
|
||||
tremoloskew: { node: 'tremolo', param: 'skew' },
|
||||
tremolophase: { node: 'tremolo', param: 'phase' },
|
||||
tremoloshape: { node: 'tremolo', param: 'shape' },
|
||||
|
||||
// MODULATORS
|
||||
lfo: { node: 'lfo', param: 'frequency' },
|
||||
lfo_rate: { node: 'lfo', param: 'frequency' },
|
||||
lfo_sync: { node: 'lfo', param: 'frequency' },
|
||||
lfo_depth: { node: 'lfo', param: 'depth' },
|
||||
lfo_depthabs: { node: 'lfo', param: 'depth' },
|
||||
lfo_skew: { node: 'lfo', param: 'skew' },
|
||||
lfo_curve: { node: 'lfo', param: 'curve' },
|
||||
lfo_dcoffset: { node: 'lfo', param: 'dcoffset' },
|
||||
env: { node: 'env', param: 'depth' },
|
||||
env_attack: { node: 'env', param: 'attack' },
|
||||
env_decay: { node: 'env', param: 'decay' },
|
||||
env_sustain: { node: 'env', param: 'sustain' },
|
||||
env_release: { node: 'env', param: 'release' },
|
||||
bmod: { node: 'bmod', param: 'depth' },
|
||||
bmod_depth: { node: 'bmod', param: 'depth' },
|
||||
bmod_depthabs: { node: 'bmod', param: 'depth' },
|
||||
|
||||
// LPF
|
||||
cutoff: { node: 'lpf', param: 'frequency' },
|
||||
resonance: { node: 'lpf', param: 'Q' },
|
||||
lprate: { node: 'lpf_lfo', param: 'rate' },
|
||||
lpsync: { node: 'lpf_lfo', param: 'sync' },
|
||||
lpdepth: { node: 'lpf_lfo', param: 'depth' },
|
||||
lpdepthfrequency: { node: 'lpf_lfo', param: 'depth' },
|
||||
lpshape: { node: 'lpf_lfo', param: 'shape' },
|
||||
lpdc: { node: 'lpf_lfo', param: 'dcoffset' },
|
||||
lpskew: { node: 'lpf_lfo', param: 'skew' },
|
||||
|
||||
// HPF
|
||||
hcutoff: { node: 'hpf', param: 'frequency' },
|
||||
hresonance: { node: 'hpf', param: 'Q' },
|
||||
hprate: { node: 'hpf_lfo', param: 'rate' },
|
||||
hpsync: { node: 'hpf_lfo', param: 'sync' },
|
||||
hpdepth: { node: 'hpf_lfo', param: 'depth' },
|
||||
hpdepthfrequency: { node: 'hpf_lfo', param: 'depth' },
|
||||
hpshape: { node: 'hpf_lfo', param: 'shape' },
|
||||
hpdc: { node: 'hpf_lfo', param: 'dcoffset' },
|
||||
hpskew: { node: 'hpf_lfo', param: 'skew' },
|
||||
|
||||
// BPF
|
||||
bandf: { node: 'bpf', param: 'frequency' },
|
||||
bandq: { node: 'bpf', param: 'Q' },
|
||||
bprate: { node: 'bpf_lfo', param: 'rate' },
|
||||
bpsync: { node: 'bpf_lfo', param: 'sync' },
|
||||
bpdepth: { node: 'bpf_lfo', param: 'depth' },
|
||||
bpdepthfrequency: { node: 'bpf_lfo', param: 'depth' },
|
||||
bpshape: { node: 'bpf_lfo', param: 'shape' },
|
||||
bpdc: { node: 'bpf_lfo', param: 'dcoffset' },
|
||||
bpskew: { node: 'bpf_lfo', param: 'skew' },
|
||||
|
||||
vowel: { node: 'vowel', param: 'frequency' },
|
||||
|
||||
// DISTORTION
|
||||
coarse: { node: 'coarse', param: 'coarse' },
|
||||
crush: { node: 'crush', param: 'crush' },
|
||||
shape: { node: 'shape', param: 'shape' },
|
||||
shapevol: { node: 'shape', param: 'postgain' },
|
||||
distort: { node: 'distort', param: 'distort' },
|
||||
distortvol: { node: 'distort', param: 'postgain' },
|
||||
distorttype: { node: 'distort', param: 'distort' },
|
||||
|
||||
// COMPRESSOR
|
||||
compressor: { node: 'compressor', param: 'threshold' },
|
||||
compressorRatio: { node: 'compressor', param: 'ratio' },
|
||||
compressorKnee: { node: 'compressor', param: 'knee' },
|
||||
compressorAttack: { node: 'compressor', param: 'attack' },
|
||||
compressorRelease: { node: 'compressor', param: 'release' },
|
||||
|
||||
// PHASER
|
||||
phaserrate: { node: 'phaser_lfo', param: 'frequency' },
|
||||
phasersweep: { node: 'phaser_lfo', param: 'depth' },
|
||||
phasercenter: { node: 'phaser', param: 'frequency' },
|
||||
phaserdepth: { node: 'phaser', param: 'Q' },
|
||||
|
||||
// ORBIT EFFECTS
|
||||
delay: { node: 'delay_mix', param: 'gain' },
|
||||
delaytime: { node: 'delay', param: 'delayTime' },
|
||||
delayfeedback: { node: 'delay', param: 'feedback' },
|
||||
delaysync: { node: 'delay', param: 'delayTime' },
|
||||
dry: { node: 'dry', param: 'gain' },
|
||||
room: { node: 'room_mix', param: 'gain' },
|
||||
djf: { node: 'djf', param: 'value' },
|
||||
busgain: { node: 'bus', param: 'gain' },
|
||||
|
||||
// SYNTHS
|
||||
s: { node: 'source', param: 'frequency' },
|
||||
detune: { node: 'source', param: 'freqspread' },
|
||||
wt: { node: 'source', param: 'position' },
|
||||
warp: { node: 'source', param: 'warp' },
|
||||
freq: { node: 'source', param: 'frequency' },
|
||||
note: { node: 'source', param: 'frequency' },
|
||||
wtdc: { node: 'wt_lfo', param: 'dc' },
|
||||
wtskew: { node: 'wt_lfo', param: 'skew' },
|
||||
wtrate: { node: 'wt_lfo', param: 'frequency' },
|
||||
wtsync: { node: 'wt_lfo', param: 'frequency' },
|
||||
wtdepth: { node: 'wt_lfo', param: 'depth' },
|
||||
warpdc: { node: 'warp_lfo', param: 'dc' },
|
||||
warpskew: { node: 'warp_lfo', param: 'skew' },
|
||||
warprate: { node: 'warp_lfo', param: 'frequency' },
|
||||
warpsync: { node: 'warp_lfo', param: 'frequency' },
|
||||
warpdepth: { node: 'warp_lfo', param: 'depth' },
|
||||
fmi: { node: 'fm_1_gain', param: 'gain' },
|
||||
fmi2: { node: 'fm_2_gain', param: 'gain' },
|
||||
fmi3: { node: 'fm_3_gain', param: 'gain' },
|
||||
fmi4: { node: 'fm_4_gain', param: 'gain' },
|
||||
fmi5: { node: 'fm_5_gain', param: 'gain' },
|
||||
fmi6: { node: 'fm_6_gain', param: 'gain' },
|
||||
fmi7: { node: 'fm_7_gain', param: 'gain' },
|
||||
fmi8: { node: 'fm_8_gain', param: 'gain' },
|
||||
fmh: { node: 'fm_1', param: 'frequency' },
|
||||
fmh2: { node: 'fm_2', param: 'frequency' },
|
||||
fmh3: { node: 'fm_3', param: 'frequency' },
|
||||
fmh4: { node: 'fm_4', param: 'frequency' },
|
||||
fmh5: { node: 'fm_5', param: 'frequency' },
|
||||
fmh6: { node: 'fm_6', param: 'frequency' },
|
||||
fmh7: { node: 'fm_7', param: 'frequency' },
|
||||
fmh8: { node: 'fm_8', param: 'frequency' },
|
||||
pw: { node: 'source', param: 'pulsewidth' },
|
||||
pwrate: { node: 'pw_lfo', param: 'frequency' },
|
||||
pwsweep: { node: 'pw_lfo', param: 'depth' },
|
||||
vib: { node: 'vib', param: 'frequency' },
|
||||
vibmod: { node: 'vib_gain', param: 'gain' },
|
||||
byteBeatStartTime: { node: 'source', param: 'byteBeatStartTime' },
|
||||
spread: { node: 'source', param: 'panspread' },
|
||||
transient: { node: 'transient', param: 'attack' },
|
||||
};
|
||||
|
||||
export function getSuperdoughControlTargets() {
|
||||
return CONTROL_TARGETS;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user